diff --git a/bom/camel-bom/pom.xml b/bom/camel-bom/pom.xml index f6f06b4f00b66..88dca8855328f 100644 --- a/bom/camel-bom/pom.xml +++ b/bom/camel-bom/pom.xml @@ -2077,6 +2077,11 @@ camel-rest-openapi 4.22.0-SNAPSHOT + + org.apache.camel + camel-rest-postman + 4.22.0-SNAPSHOT + org.apache.camel camel-robotframework diff --git a/catalog/camel-allcomponents/pom.xml b/catalog/camel-allcomponents/pom.xml index 0afdc8f168ada..678c6ba3a7ce5 100644 --- a/catalog/camel-allcomponents/pom.xml +++ b/catalog/camel-allcomponents/pom.xml @@ -1857,6 +1857,11 @@ camel-rest-openapi ${project.version} + + org.apache.camel + camel-rest-postman + ${project.version} + org.apache.camel camel-robotframework diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components.properties index 883f470b7e2ce..e8a80790f9c4c 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components.properties +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components.properties @@ -314,6 +314,7 @@ ref rest rest-api rest-openapi +rest-postman robotframework rocketmq rss diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/rest-postman.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/rest-postman.json new file mode 100644 index 0000000000000..8b2c3ae3d0686 --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/rest-postman.json @@ -0,0 +1,101 @@ +{ + "component": { + "kind": "component", + "name": "rest-postman", + "title": "REST Postman", + "description": "To call and expose REST services using a Postman Collection as contract.", + "deprecated": false, + "firstVersion": "4.22.0", + "label": "rest,api", + "javaType": "org.apache.camel.component.rest.postman.RestPostmanComponent", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-rest-postman", + "version": "4.22.0-SNAPSHOT", + "scheme": "rest-postman", + "extendsScheme": "", + "syntax": "rest-postman:collectionSource#requestId", + "async": false, + "api": false, + "consumerOnly": false, + "producerOnly": false, + "lenientProperties": false, + "browsable": false, + "remote": true + }, + "componentProperties": { + "basePath": { "index": 0, "kind": "property", "displayName": "Base Path", "group": "common", "label": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "API basePath, for example \/v2. Default is unset, if set overrides the value derived from the request URL in the collection." }, + "collectionSource": { "index": 1, "kind": "property", "displayName": "Collection Source", "group": "common", "label": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The Postman Collection to use, when it is not given on the endpoint. Either a resource URI of a Collection v2.1 JSON document (classpath:, file: or http:), or the uid of a collection to fetch from the Postman cloud." }, + "collectionSourceType": { "index": 2, "kind": "property", "displayName": "Collection Source Type", "group": "common", "label": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "auto", "resource", "cloud" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "auto", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "How to interpret collectionSource. With auto, a bare collection UUID or {ownerId}-{uuid} is fetched from the Postman cloud and anything else is resolved as a resource (classpath:, file:, http:). Use resource or cloud to decide explicitly." }, + "variables": { "index": 3, "kind": "property", "displayName": "Variables", "group": "common", "label": "common", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "variable.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Values for the {{variable}} placeholders used in the collection. These override the variables declared by the collection and its folders. This is a multi-value option with prefix: variable." }, + "failOnUnresolvedVariable": { "index": 4, "kind": "property", "displayName": "Fail On Unresolved Variable", "group": "common (advanced)", "label": "common,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Whether to fail if a {{variable}} placeholder used by the selected request cannot be resolved. When false the placeholder is left as-is." }, + "apiContextPath": { "index": 5, "kind": "property", "displayName": "Api Context Path", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Sets the context-path to use for servicing the Postman collection document. The document is served with all auth blocks and all secret variables removed." }, + "bridgeErrorHandler": { "index": 6, "kind": "property", "displayName": "Bridge Error Handler", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "clientRequestValidation": { "index": 7, "kind": "property", "displayName": "Client Request Validation", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Whether to enable validation of the client request. A Postman collection has no schemas, so this is a best-effort check of required headers, query parameters and body presence only." }, + "missingRequest": { "index": 8, "kind": "property", "displayName": "Missing Request", "group": "consumer", "label": "consumer", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "fail", "ignore", "mock" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "fail", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Whether the consumer should fail, ignore or return a mock response for requests in the collection that are not mapped to a corresponding route." }, + "consumerComponentName": { "index": 9, "kind": "property", "displayName": "Consumer Component Name", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Name of the Camel component that will service the requests. The component must be present in Camel registry and it must be able to service contract-first REST consumers, as platform-http does. If not set CLASSPATH is searched for a single component with that capability." }, + "mockIncludePattern": { "index": 10, "kind": "property", "displayName": "Mock Include Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "classpath:camel-mock\/**", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Used for inclusive filtering of mock data from directories. The pattern is using Ant-path style pattern. Multiple patterns can be specified separated by comma. Saved example responses in the collection are preferred over these files." }, + "requestFilter": { "index": 11, "kind": "property", "displayName": "Request Filter", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Filters which requests of the collection are used, as comma separated Ant-style patterns matched against the folder qualified request id. Prefix a pattern with ! to exclude." }, + "restPostmanProcessorStrategy": { "index": 12, "kind": "property", "displayName": "Rest Postman Processor Strategy", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "object", "javaType": "org.apache.camel.component.rest.postman.RestPostmanProcessorStrategy", "deprecated": false, "autowired": false, "secret": false, "description": "To use a custom strategy for how to service the requests of the collection." }, + "host": { "index": 13, "kind": "property", "displayName": "Host", "group": "producer", "label": "producer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Scheme hostname and port to direct the HTTP requests to in the form of https:\/\/hostname:port. If set overrides any value derived from the collection." }, + "lazyStartProducer": { "index": 14, "kind": "property", "displayName": "Lazy Start Producer", "group": "producer", "label": "producer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, + "runFailFast": { "index": 15, "kind": "property", "displayName": "Run Fail Fast", "group": "producer", "label": "producer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": true, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "When the endpoint runs more than one request, that is when it selects a folder or the whole collection, whether to stop and fail on the first request that fails. When false every request is attempted and the failure is recorded in its result." }, + "componentName": { "index": 16, "kind": "property", "displayName": "Component Name", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Name of the Camel component that will perform the requests. The component must be present in Camel registry and it must implement RestProducerFactory service provider interface. If not set CLASSPATH is searched for single component that implements RestProducerFactory SPI." }, + "consumes": { "index": 17, "kind": "property", "displayName": "Consumes", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "What payload type this component is capable of consuming. This equates to the value of the Accept HTTP header. A Postman collection does not describe responses, so unlike an OpenAPI specification there is nothing to infer this from and it is unset by default." }, + "produces": { "index": 18, "kind": "property", "displayName": "Produces", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "What payload type this component is producing. This equates to the value of the Content-Type HTTP header. If not set it is inferred from the body mode of the request in the collection." }, + "queryParameterMode": { "index": 19, "kind": "property", "displayName": "Query Parameter Mode", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "placeholder", "literal" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "placeholder", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "How to treat the query parameters declared in the collection. With placeholder the parameter names are bound to message headers and the values in the collection are ignored as sample data. With literal the values in the collection are sent as-is." }, + "autowiredEnabled": { "index": 20, "kind": "property", "displayName": "Autowired Enabled", "group": "advanced", "label": "advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Whether autowiring is enabled. This is used for automatic autowiring options (the option must be marked as autowired) by looking up in the registry to find if there is a single instance of matching type, which then gets configured on the component. This can be used for automatic configuring JDBC data sources, JMS connection factories, AWS Clients, etc." }, + "collectionCacheTtl": { "index": 21, "kind": "property", "displayName": "Collection Cache Ttl", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": -1, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "How long a loaded collection is cached, in milliseconds. Use -1 to cache for the lifetime of the component." }, + "configuration": { "index": 22, "kind": "property", "displayName": "Configuration", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "deprecated": false, "autowired": false, "secret": false, "description": "The shared configuration used as the template for every endpoint created by this component." }, + "connectTimeout": { "index": 23, "kind": "property", "displayName": "Connect Timeout", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 15000, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Connection timeout in milliseconds when fetching a collection from the Postman cloud." }, + "requestTimeout": { "index": 24, "kind": "property", "displayName": "Request Timeout", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 30000, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Request timeout in milliseconds when fetching a collection from the Postman cloud." }, + "collectionAuth": { "index": 25, "kind": "property", "displayName": "Collection Auth", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "ignore", "header", "fail" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "ignore", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "What to do with the auth block the collection declares for the target API. With ignore the block is not applied, and a warning names the type that was found. With header the basic, bearer and apikey types are applied as a static header or query parameter, and any other type fails at startup rather than silently sending no credential. With fail any auth block other than noauth is rejected." }, + "oauthProfile": { "index": 26, "kind": "property", "displayName": "OAuth Profile", "group": "security", "label": "consumer,security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The OAuth profile to use for authenticating the incoming requests. The profile is enforced by the consumer component servicing the requests." }, + "postmanApiKey": { "index": 27, "kind": "property", "displayName": "Postman Api Key", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The Postman API key used to fetch the collection from the Postman cloud. This credential authenticates against Postman itself and is never sent to the API the collection describes." }, + "postmanApiKeyHeader": { "index": 28, "kind": "property", "displayName": "Postman Api Key Header", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "X-Api-Key", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The HTTP header used to send the Postman API key when fetching a collection." }, + "sslContextParameters": { "index": 29, "kind": "property", "displayName": "Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.camel.support.jsse.SSLContextParameters", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Customize TLS parameters used by the component. If not set defaults to the TLS parameters set in the Camel context. These parameters are used both when fetching a collection from the Postman cloud and by the delegate producer." }, + "useGlobalSslContextParameters": { "index": 30, "kind": "property", "displayName": "Use Global Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Enable usage of global SSL context parameters." }, + "postmanApiUrl": { "index": 31, "kind": "property", "displayName": "Postman Api Url", "group": "security (advanced)", "label": "security,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "https:\/\/api.getpostman.com", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The base URL of the Postman API used to fetch collections. Must use https, except for localhost, because plain http would send the Postman API key in clear text." } + }, + "headers": { + "CamelRestPostmanRequestId": { "index": 0, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The id of the Postman request being invoked or serviced.", "constantName": "org.apache.camel.component.rest.postman.RestPostmanConstants#REQUEST_ID" }, + "CamelRestPostmanRequestName": { "index": 1, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The name of the Postman request, as written in the collection.", "constantName": "org.apache.camel.component.rest.postman.RestPostmanConstants#REQUEST_NAME" }, + "CamelRestPostmanFolderPath": { "index": 2, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The folder path of the Postman request, with folders separated by a slash.", "constantName": "org.apache.camel.component.rest.postman.RestPostmanConstants#FOLDER_PATH" }, + "CamelRestPostmanRequestCount": { "index": 3, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The number of requests executed when running a folder or a whole collection.", "constantName": "org.apache.camel.component.rest.postman.RestPostmanConstants#REQUEST_COUNT" }, + "CamelRestPostmanFailedCount": { "index": 4, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The number of requests that failed when running a folder or a whole collection with runFailFast disabled.", "constantName": "org.apache.camel.component.rest.postman.RestPostmanConstants#FAILED_COUNT" } + }, + "properties": { + "collectionSource": { "index": 0, "kind": "path", "displayName": "Collection Source", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "postman-collection.json", "description": "The Postman Collection to use. Either a resource URI of a Collection v2.1 JSON document (classpath:, file: or http:), or the uid of a collection to fetch from the Postman cloud, which requires postmanApiKey. Default value notice: By default loads the postman-collection.json file" }, + "requestId": { "index": 1, "kind": "path", "displayName": "Request Id", "group": "producer", "label": "producer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The request to invoke, identified by its id in the collection or by its slugified name, for example getUserById. Use a folder id to run every request in that folder, and leave it out to run the whole collection. Append a slash to force a folder match when a request and a folder share a name." }, + "basePath": { "index": 2, "kind": "parameter", "displayName": "Base Path", "group": "common", "label": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "API basePath, for example \/v2. Default is unset, if set overrides the value derived from the request URL in the collection." }, + "collectionSourceType": { "index": 3, "kind": "parameter", "displayName": "Collection Source Type", "group": "common", "label": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "auto", "resource", "cloud" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "auto", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "How to interpret collectionSource. With auto, a bare collection UUID or {ownerId}-{uuid} is fetched from the Postman cloud and anything else is resolved as a resource (classpath:, file:, http:). Use resource or cloud to decide explicitly." }, + "variables": { "index": 4, "kind": "parameter", "displayName": "Variables", "group": "common", "label": "common", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "variable.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Values for the {{variable}} placeholders used in the collection. These override the variables declared by the collection and its folders. This is a multi-value option with prefix: variable." }, + "failOnUnresolvedVariable": { "index": 5, "kind": "parameter", "displayName": "Fail On Unresolved Variable", "group": "common (advanced)", "label": "common,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Whether to fail if a {{variable}} placeholder used by the selected request cannot be resolved. When false the placeholder is left as-is." }, + "apiContextPath": { "index": 6, "kind": "parameter", "displayName": "Api Context Path", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Sets the context-path to use for servicing the Postman collection document. The document is served with all auth blocks and all secret variables removed." }, + "clientRequestValidation": { "index": 7, "kind": "parameter", "displayName": "Client Request Validation", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Whether to enable validation of the client request. A Postman collection has no schemas, so this is a best-effort check of required headers, query parameters and body presence only." }, + "missingRequest": { "index": 8, "kind": "parameter", "displayName": "Missing Request", "group": "consumer", "label": "consumer", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "fail", "ignore", "mock" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "fail", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Whether the consumer should fail, ignore or return a mock response for requests in the collection that are not mapped to a corresponding route." }, + "bridgeErrorHandler": { "index": 9, "kind": "parameter", "displayName": "Bridge Error Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "consumerComponentName": { "index": 10, "kind": "parameter", "displayName": "Consumer Component Name", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Name of the Camel component that will service the requests. The component must be present in Camel registry and it must be able to service contract-first REST consumers, as platform-http does. If not set CLASSPATH is searched for a single component with that capability." }, + "exceptionHandler": { "index": 11, "kind": "parameter", "displayName": "Exception Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "object", "javaType": "org.apache.camel.spi.ExceptionHandler", "optionalPrefix": "consumer.", "deprecated": false, "autowired": false, "secret": false, "description": "To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "exchangePattern": { "index": 12, "kind": "parameter", "displayName": "Exchange Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "enum", "javaType": "org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ], "deprecated": false, "autowired": false, "secret": false, "description": "Sets the exchange pattern when the consumer creates an exchange." }, + "mockIncludePattern": { "index": 13, "kind": "parameter", "displayName": "Mock Include Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "classpath:camel-mock\/**", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Used for inclusive filtering of mock data from directories. The pattern is using Ant-path style pattern. Multiple patterns can be specified separated by comma. Saved example responses in the collection are preferred over these files." }, + "requestFilter": { "index": 14, "kind": "parameter", "displayName": "Request Filter", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Filters which requests of the collection are used, as comma separated Ant-style patterns matched against the folder qualified request id. Prefix a pattern with ! to exclude." }, + "host": { "index": 15, "kind": "parameter", "displayName": "Host", "group": "producer", "label": "producer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Scheme hostname and port to direct the HTTP requests to in the form of https:\/\/hostname:port. If set overrides any value derived from the collection." }, + "runFailFast": { "index": 16, "kind": "parameter", "displayName": "Run Fail Fast", "group": "producer", "label": "producer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": true, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "When the endpoint runs more than one request, that is when it selects a folder or the whole collection, whether to stop and fail on the first request that fails. When false every request is attempted and the failure is recorded in its result." }, + "componentName": { "index": 17, "kind": "parameter", "displayName": "Component Name", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Name of the Camel component that will perform the requests. The component must be present in Camel registry and it must implement RestProducerFactory service provider interface. If not set CLASSPATH is searched for single component that implements RestProducerFactory SPI." }, + "consumes": { "index": 18, "kind": "parameter", "displayName": "Consumes", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "What payload type this component is capable of consuming. This equates to the value of the Accept HTTP header. A Postman collection does not describe responses, so unlike an OpenAPI specification there is nothing to infer this from and it is unset by default." }, + "lazyStartProducer": { "index": 19, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, + "produces": { "index": 20, "kind": "parameter", "displayName": "Produces", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "What payload type this component is producing. This equates to the value of the Content-Type HTTP header. If not set it is inferred from the body mode of the request in the collection." }, + "queryParameterMode": { "index": 21, "kind": "parameter", "displayName": "Query Parameter Mode", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "placeholder", "literal" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "placeholder", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "How to treat the query parameters declared in the collection. With placeholder the parameter names are bound to message headers and the values in the collection are ignored as sample data. With literal the values in the collection are sent as-is." }, + "collectionCacheTtl": { "index": 22, "kind": "parameter", "displayName": "Collection Cache Ttl", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": -1, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "How long a loaded collection is cached, in milliseconds. Use -1 to cache for the lifetime of the component." }, + "connectTimeout": { "index": 23, "kind": "parameter", "displayName": "Connect Timeout", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 15000, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Connection timeout in milliseconds when fetching a collection from the Postman cloud." }, + "requestTimeout": { "index": 24, "kind": "parameter", "displayName": "Request Timeout", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 30000, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Request timeout in milliseconds when fetching a collection from the Postman cloud." }, + "collectionAuth": { "index": 25, "kind": "parameter", "displayName": "Collection Auth", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "ignore", "header", "fail" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "ignore", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "What to do with the auth block the collection declares for the target API. With ignore the block is not applied, and a warning names the type that was found. With header the basic, bearer and apikey types are applied as a static header or query parameter, and any other type fails at startup rather than silently sending no credential. With fail any auth block other than noauth is rejected." }, + "oauthProfile": { "index": 26, "kind": "parameter", "displayName": "OAuth Profile", "group": "security", "label": "consumer,security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The OAuth profile to use for authenticating the incoming requests. The profile is enforced by the consumer component servicing the requests." }, + "postmanApiKey": { "index": 27, "kind": "parameter", "displayName": "Postman Api Key", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The Postman API key used to fetch the collection from the Postman cloud. This credential authenticates against Postman itself and is never sent to the API the collection describes." }, + "postmanApiKeyHeader": { "index": 28, "kind": "parameter", "displayName": "Postman Api Key Header", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "X-Api-Key", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The HTTP header used to send the Postman API key when fetching a collection." }, + "sslContextParameters": { "index": 29, "kind": "parameter", "displayName": "Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.camel.support.jsse.SSLContextParameters", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Customize TLS parameters used by the component. If not set defaults to the TLS parameters set in the Camel context. These parameters are used both when fetching a collection from the Postman cloud and by the delegate producer." }, + "useGlobalSslContextParameters": { "index": 30, "kind": "parameter", "displayName": "Use Global Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Enable usage of global SSL context parameters." }, + "postmanApiUrl": { "index": 31, "kind": "parameter", "displayName": "Postman Api Url", "group": "security (advanced)", "label": "security,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "https:\/\/api.getpostman.com", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The base URL of the Postman API used to fetch collections. Must use https, except for localhost, because plain http would send the Postman API key in clear text." } + } +} diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties index 36ae1f24a7418..8d546b6b2a1e7 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties @@ -527,6 +527,7 @@ resourceresolver-github rest-api-component rest-component rest-openapi-component +rest-postman-component resumable-eip resume-strategies return-address diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rest-postman-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rest-postman-component.adoc new file mode 100644 index 0000000000000..43513d2229ce3 --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/rest-postman-component.adoc @@ -0,0 +1,232 @@ += REST Postman Component +:doctitle: REST Postman +:shortname: rest-postman +:artifactid: camel-rest-postman +:description: To call and expose REST services using a Postman Collection as contract. +:since: 4.22 +:supportlevel: Preview +:tabs-sync-option: +:component-header: Both producer and consumer are supported + +*Since Camel {since}* + +*{component-header}* + +The REST Postman component configures rest producers and contract-first rest consumers from a +https://learning.postman.com/docs/collections/collections-overview/[Postman Collection], and delegates to a component +implementing the _RestProducerFactory_ interface. Currently, known working components are: + +* xref:http-component.adoc[http] +* xref:netty-http-component.adoc[netty-http] +* xref:undertow-component.adoc[undertow] +* xref:vertx-http-component.adoc[vertx-http] + +It is the Postman equivalent of xref:rest-openapi-component.adoc[rest-openapi]. Use it when a Postman Collection is +the description of the API you have, rather than an OpenAPI specification. + +IMPORTANT: Only the Postman Collection Format v2.1 is supported. + +Maven users will need to add the following dependency to their `pom.xml` for this component: + +[source,xml] +---- + + org.apache.camel + camel-rest-postman + x.x.x + + +---- + +== URI format + +---- +rest-postman:[collectionSource#]requestId +---- + +`collectionSource` is either a resource URI of a Collection v2.1 JSON document (`classpath:`, `file:` or `http:`), or +the uid of a collection to fetch from the Postman cloud. It defaults to `postman-collection.json` on the classpath. + +`requestId` selects what to invoke: + +[cols="1,3", options="header"] +|=== +| Fragment | Selects +| `getPetById` | the single request whose name slugifies to `getPetById` +| `pets/addPet` | the request `Add Pet` inside the folder `Pets`, used when a name is not unique +| `3f2504e0-4f89-11d3-9a0c-0305e82c3301` | the request with that `id`, which only collections fetched from the cloud carry +| `pets` | every request in the folder `Pets` +| `pets/` | the folder `Pets`, forced, for when a request and a folder share a name +| _omitted_ | every request in the collection +|=== + +This component's endpoint URI is lenient, which means that in addition to message headers you can specify a request's +parameters as endpoint parameters. These will be constant for all subsequent invocations, so it makes sense to use +this feature only for parameters that are indeed constant for all invocations. + +// component options: START +include::partial$component-configure-options.adoc[] +include::partial$component-endpoint-options.adoc[] +include::partial$component-endpoint-headers.adoc[] +// component options: END + +== Usage + +=== Identifying requests + +A Postman item has a human readable name rather than an operation id, so this component slugifies it: +`Get Pet By Id` becomes `getPetById`. When two requests slugify to the same thing, both are addressed by their folder +qualified id instead, such as `pets/get` and `users/get`, and using the bare `get` is an error that lists the +alternatives. + +`item.id` is also accepted when the collection records one. Note that it is optional in the v2.1 schema, and Postman's +exporter strips auto-generated item ids, so an exported `collection.json` usually has none. Collections fetched from +the Postman cloud do. + +IMPORTANT: Because the common case is to address a request by its slugified name, renaming a request in the Postman +UI changes its id and will break routes bound to it. + +=== Invoking a single request + +[source,java] +---- +from("direct:start") + .to("rest-postman:petstore.json#getPetById"); +---- + +The message body and headers of the exchange are what is sent. The collection supplies the method, the URL, and any +headers the message does not already carry; the body written in the collection is treated as sample data and is not +sent. + +Path parameters written as `:petId` become `+{petId}+` placeholders resolved per exchange from the message header of the +same name, falling back to the value declared in `url.variable`. Query parameters are bound to message headers in the +same way and are dropped when unresolved. Set `queryParameterMode=literal` to send the values written in the +collection instead. + +=== Running a folder or a whole collection + +Naming a folder, or naming nothing at all, runs every request in turn, in the manner of Postman's collection runner: + +[source,java] +---- +from("direct:smokeTest") + .to("rest-postman:petstore.json#pets") // every request in the Pets folder + .to("rest-postman:petstore.json"); // every request in the collection +---- + +Because one exchange body cannot stand in for many different requests, each request sends the body and headers written +in the collection. `raw`, `graphql` and `urlencoded` bodies are reconstructed; `formdata` and `file` bodies cannot be, +and are skipped with a warning. The `file` body mode is never read from disk, as it records a path on the machine of +whoever authored the collection. + +The message body becomes a `List` of `PostmanRunResult`, one per request, each carrying the request id, method, URI, +status code, response body, headers and any failure. The headers `CamelRestPostmanRequestCount` and +`CamelRestPostmanFailedCount` summarise the run. + +By default the run stops and fails on the first request that fails. Set `runFailFast=false` to attempt every request +and record the failures in their results instead: + +[source,java] +---- +from("timer:smoke?period=60000") + .to("rest-postman:petstore.json?runFailFast=false") + .split(body()) + .filter(simple("${body.success} == false")) + .to("log:failures"); +---- + +=== Contract-first consumer + +Pointing a route's `from` at a collection serves its requests over HTTP, dispatching each one to a route consuming +from `direct:`: + +[source,java] +---- +from("rest-postman:petstore.json") + .to("direct:dummy"); + +from("direct:getPetById") + .setBody(constant("{ \"id\": 42 }")); +---- + +Use a folder id to serve only part of a collection, and `requestFilter` to include or exclude requests by Ant-style +patterns over their folder qualified ids. + +Because a collection does not describe a base path, the split between base path and route path is inferred from what +`+{{baseUrl}}+` expands to. Set `basePath` explicitly to control the context path the consumer serves on. + +`missingRequest` decides what happens when a request has no corresponding route: `fail` (the default) refuses to +start, `ignore` warns, and `mock` returns a mocked response. Mock responses are taken from the collection's own saved +example responses where there are any, which is the one place a collection is richer than an OpenAPI specification, +and fall back to files matched by `mockIncludePattern`. + +If two requests share an HTTP method and path, which is common when a collection keeps a success and an error variant +of the same call, the consumer fails at startup rather than letting one silently shadow the other. Use `requestFilter` +to choose between them. + +=== Variables + +`+{{variable}}+` placeholders are resolved from the collection's own `variable` arrays, with folder scopes overriding +the collection scope, then from the endpoint's `variables` option, then from Camel property placeholders: + +[source,java] +---- +from("direct:start") + .to("rest-postman:petstore.json#getPetById?variable.baseUrl=https://staging.example.com/v3"); +---- + +Postman environment files are not supported. Unresolved placeholders are left as they are unless +`failOnUnresolvedVariable=true`. + +A placeholder name written in the `prefix:value` form of a Camel property placeholder function -- `+{{env:HOME}}+`, +`+{{sys:user.home}}+`, `+{{bean:foo}}+` and the vault functions among them -- is deliberately *not* resolved from +Camel properties. A collection is route-author configuration, but a cloud-hosted one is editable by anyone with +access to the Postman workspace, and resolving those would let its content pull an environment variable into an +outgoing request. Supply such values through the `variables` option instead. + +NOTE: Pre-request and test scripts in the collection's `event` blocks are never parsed or executed. + +== Security + +=== Two different credentials + +There are two unrelated credentials in play, and the option names keep them apart: + +`postmanApiKey`:: authenticates against *Postman itself*, in order to download a collection from the Postman cloud. It +is sent only to `postmanApiUrl`, and never to the API that the collection describes. + +the collection's own `auth` block:: authenticates against *the API the collection describes*. It is governed by the +`collectionAuth` option. + +=== Fetching a collection from the Postman cloud + +[source,java] +---- +from("direct:start") + .to("rest-postman:12ece9e1-2abf-4edc-8e34-de66e74114d2#getPetById?postmanApiKey=PMAK-xxxx"); +---- + +Prefer resolving the key from a vault or a property placeholder over writing it in the URI. Redirects from +`postmanApiUrl` are rejected rather than followed, because following one would send the key to the redirect target, +and `postmanApiUrl` must use HTTPS unless it names a loopback host. + +=== Applying the collection's auth block + +`collectionAuth` defaults to `ignore`: the block is not applied, and a warning names the type that was found. This is +deliberate, because the values in a collection's auth block are usually unresolved `+{{placeholders}}+`, and silently +attaching a credential found in a configuration file to outbound requests is surprising. + +Set `collectionAuth=header` to apply it. The `basic`, `bearer` and `apikey` types are reproduced as a static header or +query parameter. The types that require per-request signing or a token exchange -- `awsv4`, `digest`, `hawk`, +`edgegrid`, `ntlm`, `oauth1` and `oauth2` -- fail at startup rather than silently sending no credential; configure +those on the delegate HTTP component instead. `collectionAuth=fail` rejects any auth block at all. + +On the consumer side the collection's auth block describes what a client must present, and is *not* enforced. Use +`oauthProfile`, or the delegate consumer component's own authentication, for that. + +=== Serving the collection document + +When `apiContextPath` is set, the collection is served on that path with every `auth` block removed and the value of +every variable of type `secret` replaced. This redaction is unconditional. + +include::spring-boot:partial$starter.adoc[] diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/sensitive-keys.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/sensitive-keys.json index 376fbe12a04b7..5e570545f70c1 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/sensitive-keys.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/main/sensitive-keys.json @@ -49,6 +49,7 @@ "passphrase", "password", "personalaccesstoken", + "postmanapikey", "privatekey", "privatekeyfile", "privatekeyname", diff --git a/components/camel-platform-http-vertx/pom.xml b/components/camel-platform-http-vertx/pom.xml index c50726b4b1cf3..870f9b48ab83a 100644 --- a/components/camel-platform-http-vertx/pom.xml +++ b/components/camel-platform-http-vertx/pom.xml @@ -99,6 +99,11 @@ camel-rest-openapi test + + org.apache.camel + camel-rest-postman + test + org.apache.camel camel-vertx-http diff --git a/components/camel-platform-http-vertx/src/test/java/org/apache/camel/component/platform/http/vertx/PlatformHttpRestPostmanConsumerTest.java b/components/camel-platform-http-vertx/src/test/java/org/apache/camel/component/platform/http/vertx/PlatformHttpRestPostmanConsumerTest.java new file mode 100644 index 0000000000000..ae2f889441b53 --- /dev/null +++ b/components/camel-platform-http-vertx/src/test/java/org/apache/camel/component/platform/http/vertx/PlatformHttpRestPostmanConsumerTest.java @@ -0,0 +1,261 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.platform.http.vertx; + +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.hamcrest.Matchers.equalTo; + +/** + * Contract-first consumer driven by a Postman Collection. + */ +class PlatformHttpRestPostmanConsumerTest { + + @Test + void shouldServeARequestOfTheCollection() throws Exception { + final CamelContext context = VertxPlatformHttpEngineTest.createCamelContext(); + + try { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("rest-postman:classpath:postman-petstore.json?missingRequest=ignore") + .to("mock:result"); + + from("direct:getPetById") + .setBody().constant("{\"pet\": \"tony the tiger\"}"); + } + }); + + VertxPlatformHttpEngineTest.startCamelContext(context); + + MockEndpoint mock = context.getEndpoint("mock:result", MockEndpoint.class); + mock.expectedMessageCount(1); + + given() + .when() + .get("/api/v3/pet/123") + .then() + .statusCode(200) + .body(equalTo("{\"pet\": \"tony the tiger\"}")); + + mock.assertIsSatisfied(); + } finally { + context.stop(); + } + } + + @Test + void shouldMapPathParametersToHeaders() throws Exception { + final CamelContext context = VertxPlatformHttpEngineTest.createCamelContext(); + + try { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("rest-postman:classpath:postman-petstore.json?missingRequest=ignore").stop(); + + from("direct:getPetById") + .setBody().simple("pet=${header.petId}"); + } + }); + + VertxPlatformHttpEngineTest.startCamelContext(context); + + given() + .when() + .get("/api/v3/pet/123") + .then() + .statusCode(200) + .body(equalTo("pet=123")); + } finally { + context.stop(); + } + } + + @Test + void shouldReturn404ForAPathTheCollectionDoesNotDescribe() throws Exception { + final CamelContext context = VertxPlatformHttpEngineTest.createCamelContext(); + + try { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("rest-postman:classpath:postman-petstore.json?missingRequest=ignore").stop(); + + from("direct:getPetById").setBody().constant("ok"); + } + }); + + VertxPlatformHttpEngineTest.startCamelContext(context); + + given() + .when() + .get("/api/v3/unknown") + .then() + .statusCode(404); + } finally { + context.stop(); + } + } + + /** + * Note that for a path the collection does describe, the 405 is produced by the vert.x router itself, because the + * path was registered with only the verbs the collection uses. The router does not populate {@code Allow}, so this + * asserts the status only. + */ + @Test + void shouldReturn405ForAKnownPathOnTheWrongMethod() throws Exception { + final CamelContext context = VertxPlatformHttpEngineTest.createCamelContext(); + + try { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("rest-postman:classpath:postman-petstore.json?missingRequest=ignore").stop(); + + from("direct:getPetById").setBody().constant("ok"); + } + }); + + VertxPlatformHttpEngineTest.startCamelContext(context); + + given() + .when() + .delete("/api/v3/pet") + .then() + .statusCode(405); + } finally { + context.stop(); + } + } + + /** + * The default refuses to start rather than silently serving a collection whose requests go nowhere. + */ + @Test + void shouldFailToStartWhenARequestHasNoRoute() throws Exception { + final CamelContext context = VertxPlatformHttpEngineTest.createCamelContext(); + + try { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("rest-postman:classpath:postman-petstore.json").stop(); + } + }); + + assertThatThrownBy(() -> VertxPlatformHttpEngineTest.startCamelContext(context)) + .rootCause() + .hasMessageContaining("not mapped to a corresponding route") + .hasMessageContaining("direct:getPetById"); + } finally { + context.stop(); + } + } + + /** + * Saved example responses in the collection are real recorded responses, so they make better mocks than anything + * that could be generated. + */ + @Test + void shouldMockFromASavedExampleResponse() throws Exception { + final CamelContext context = VertxPlatformHttpEngineTest.createCamelContext(); + + try { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("rest-postman:classpath:postman-petstore.json?missingRequest=mock").stop(); + } + }); + + VertxPlatformHttpEngineTest.startCamelContext(context); + + given() + .when() + .get("/api/v3/pet/123") + .then() + .statusCode(200) + .body(equalTo("{\"pet\": \"mocked tiger\"}")); + } finally { + context.stop(); + } + } + + @Test + void shouldServeTheCollectionWithCredentialsRedacted() throws Exception { + final CamelContext context = VertxPlatformHttpEngineTest.createCamelContext(); + + try { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("rest-postman:classpath:postman-petstore.json" + + "?missingRequest=ignore&apiContextPath=/collection").stop(); + + from("direct:getPetById").setBody().constant("ok"); + } + }); + + VertxPlatformHttpEngineTest.startCamelContext(context); + + String document = given() + .when() + .get("/api/v3/collection") + .then() + .statusCode(200) + .extract().body().asString(); + + assertThat(document).contains("Petstore").contains("Get Pet By Id"); + // the bearer token and the secret variable must not be published + assertThat(document).doesNotContain("s3cr3t").doesNotContain("\"auth\""); + } finally { + context.stop(); + } + } + + @Test + void shouldServeOnlyTheRequestsSelectedByTheFilter() throws Exception { + final CamelContext context = VertxPlatformHttpEngineTest.createCamelContext(); + + try { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("rest-postman:classpath:postman-petstore.json?requestFilter=getPetById").stop(); + + from("direct:getPetById").setBody().constant("ok"); + } + }); + + // starting proves validation passed even though addPet has no route, because it was filtered out + VertxPlatformHttpEngineTest.startCamelContext(context); + + given().when().get("/api/v3/pet/123").then().statusCode(200).body(equalTo("ok")); + given().when().post("/api/v3/pet").then().statusCode(404); + } finally { + context.stop(); + } + } +} diff --git a/components/camel-platform-http-vertx/src/test/resources/postman-petstore.json b/components/camel-platform-http-vertx/src/test/resources/postman-petstore.json new file mode 100644 index 0000000000000..005d236178c14 --- /dev/null +++ b/components/camel-platform-http-vertx/src/test/resources/postman-petstore.json @@ -0,0 +1,88 @@ +{ + "info": { + "_postman_id": "9c6b0f52-4c1e-4a3a-9e57-3a2b4a1f9c22", + "name": "Petstore", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://api.example.com/api/v3" + }, + { + "key": "apiToken", + "value": "s3cr3t", + "type": "secret" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{apiToken}}" + } + ] + }, + "item": [ + { + "name": "Get Pet By Id", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/pet/:petId", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "pet", + ":petId" + ] + } + }, + "response": [ + { + "name": "found", + "code": 200, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\"pet\": \"mocked tiger\"}" + } + ] + }, + { + "name": "Add Pet", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"name\":\"Rex\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/pet", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "pet" + ] + } + } + } + ] +} diff --git a/components/camel-rest-postman/pom.xml b/components/camel-rest-postman/pom.xml new file mode 100644 index 0000000000000..ce8b1af53c4f5 --- /dev/null +++ b/components/camel-rest-postman/pom.xml @@ -0,0 +1,96 @@ + + + + + 4.0.0 + + + org.apache.camel + components + 4.22.0-SNAPSHOT + + + camel-rest-postman + + Camel :: REST Postman + Camel REST support using Postman Collections + + + 4.22.0 + + + + + + + org.apache.camel + camel-support + + + + org.apache.camel + camel-rest + + + org.apache.camel + camel-platform-http + + + + + org.apache.camel + camel-test-junit6 + test + + + + org.junit.jupiter + junit-jupiter + test + + + + org.assertj + assertj-core + test + + + + org.wiremock + wiremock + ${wiremock-version} + test + + + + org.apache.camel + camel-http + test + + + + org.apache.camel + camel-vertx-http + test + + + + + diff --git a/components/camel-rest-postman/src/generated/java/org/apache/camel/component/rest/postman/RestPostmanComponentConfigurer.java b/components/camel-rest-postman/src/generated/java/org/apache/camel/component/rest/postman/RestPostmanComponentConfigurer.java new file mode 100644 index 0000000000000..008d1da49dfa8 --- /dev/null +++ b/components/camel-rest-postman/src/generated/java/org/apache/camel/component/rest/postman/RestPostmanComponentConfigurer.java @@ -0,0 +1,237 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.rest.postman; + +import javax.annotation.processing.Generated; +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.spi.ExtendedPropertyConfigurerGetter; +import org.apache.camel.spi.PropertyConfigurerGetter; +import org.apache.camel.spi.ConfigurerStrategy; +import org.apache.camel.spi.GeneratedPropertyConfigurer; +import org.apache.camel.util.CaseInsensitiveMap; +import org.apache.camel.support.component.PropertyConfigurerSupport; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.EndpointSchemaGeneratorMojo") +@SuppressWarnings("unchecked") +public class RestPostmanComponentConfigurer extends PropertyConfigurerSupport implements GeneratedPropertyConfigurer, PropertyConfigurerGetter { + + private org.apache.camel.component.rest.postman.RestPostmanConfiguration getOrCreateConfiguration(RestPostmanComponent target) { + if (target.getConfiguration() == null) { + target.setConfiguration(new org.apache.camel.component.rest.postman.RestPostmanConfiguration()); + } + return target.getConfiguration(); + } + + @Override + public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { + RestPostmanComponent target = (RestPostmanComponent) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "apicontextpath": + case "apiContextPath": getOrCreateConfiguration(target).setApiContextPath(property(camelContext, java.lang.String.class, value)); return true; + case "autowiredenabled": + case "autowiredEnabled": target.setAutowiredEnabled(property(camelContext, boolean.class, value)); return true; + case "basepath": + case "basePath": getOrCreateConfiguration(target).setBasePath(property(camelContext, java.lang.String.class, value)); return true; + case "bridgeerrorhandler": + case "bridgeErrorHandler": target.setBridgeErrorHandler(property(camelContext, boolean.class, value)); return true; + case "clientrequestvalidation": + case "clientRequestValidation": getOrCreateConfiguration(target).setClientRequestValidation(property(camelContext, boolean.class, value)); return true; + case "collectionauth": + case "collectionAuth": getOrCreateConfiguration(target).setCollectionAuth(property(camelContext, java.lang.String.class, value)); return true; + case "collectioncachettl": + case "collectionCacheTtl": getOrCreateConfiguration(target).setCollectionCacheTtl(property(camelContext, long.class, value)); return true; + case "collectionsource": + case "collectionSource": target.setCollectionSource(property(camelContext, java.lang.String.class, value)); return true; + case "collectionsourcetype": + case "collectionSourceType": getOrCreateConfiguration(target).setCollectionSourceType(property(camelContext, java.lang.String.class, value)); return true; + case "componentname": + case "componentName": getOrCreateConfiguration(target).setComponentName(property(camelContext, java.lang.String.class, value)); return true; + case "configuration": target.setConfiguration(property(camelContext, org.apache.camel.component.rest.postman.RestPostmanConfiguration.class, value)); return true; + case "connecttimeout": + case "connectTimeout": getOrCreateConfiguration(target).setConnectTimeout(property(camelContext, long.class, value)); return true; + case "consumercomponentname": + case "consumerComponentName": getOrCreateConfiguration(target).setConsumerComponentName(property(camelContext, java.lang.String.class, value)); return true; + case "consumes": getOrCreateConfiguration(target).setConsumes(property(camelContext, java.lang.String.class, value)); return true; + case "failonunresolvedvariable": + case "failOnUnresolvedVariable": getOrCreateConfiguration(target).setFailOnUnresolvedVariable(property(camelContext, boolean.class, value)); return true; + case "host": getOrCreateConfiguration(target).setHost(property(camelContext, java.lang.String.class, value)); return true; + case "lazystartproducer": + case "lazyStartProducer": target.setLazyStartProducer(property(camelContext, boolean.class, value)); return true; + case "missingrequest": + case "missingRequest": getOrCreateConfiguration(target).setMissingRequest(property(camelContext, java.lang.String.class, value)); return true; + case "mockincludepattern": + case "mockIncludePattern": getOrCreateConfiguration(target).setMockIncludePattern(property(camelContext, java.lang.String.class, value)); return true; + case "oauthprofile": + case "oauthProfile": getOrCreateConfiguration(target).setOauthProfile(property(camelContext, java.lang.String.class, value)); return true; + case "postmanapikey": + case "postmanApiKey": getOrCreateConfiguration(target).setPostmanApiKey(property(camelContext, java.lang.String.class, value)); return true; + case "postmanapikeyheader": + case "postmanApiKeyHeader": getOrCreateConfiguration(target).setPostmanApiKeyHeader(property(camelContext, java.lang.String.class, value)); return true; + case "postmanapiurl": + case "postmanApiUrl": getOrCreateConfiguration(target).setPostmanApiUrl(property(camelContext, java.lang.String.class, value)); return true; + case "produces": getOrCreateConfiguration(target).setProduces(property(camelContext, java.lang.String.class, value)); return true; + case "queryparametermode": + case "queryParameterMode": getOrCreateConfiguration(target).setQueryParameterMode(property(camelContext, java.lang.String.class, value)); return true; + case "requestfilter": + case "requestFilter": getOrCreateConfiguration(target).setRequestFilter(property(camelContext, java.lang.String.class, value)); return true; + case "requesttimeout": + case "requestTimeout": getOrCreateConfiguration(target).setRequestTimeout(property(camelContext, long.class, value)); return true; + case "restpostmanprocessorstrategy": + case "restPostmanProcessorStrategy": target.setRestPostmanProcessorStrategy(property(camelContext, org.apache.camel.component.rest.postman.RestPostmanProcessorStrategy.class, value)); return true; + case "runfailfast": + case "runFailFast": getOrCreateConfiguration(target).setRunFailFast(property(camelContext, boolean.class, value)); return true; + case "sslcontextparameters": + case "sslContextParameters": getOrCreateConfiguration(target).setSslContextParameters(property(camelContext, org.apache.camel.support.jsse.SSLContextParameters.class, value)); return true; + case "useglobalsslcontextparameters": + case "useGlobalSslContextParameters": getOrCreateConfiguration(target).setUseGlobalSslContextParameters(property(camelContext, boolean.class, value)); return true; + case "variables": getOrCreateConfiguration(target).setVariables(property(camelContext, java.util.Map.class, value)); return true; + default: return false; + } + } + + @Override + public Class getOptionType(String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "apicontextpath": + case "apiContextPath": return java.lang.String.class; + case "autowiredenabled": + case "autowiredEnabled": return boolean.class; + case "basepath": + case "basePath": return java.lang.String.class; + case "bridgeerrorhandler": + case "bridgeErrorHandler": return boolean.class; + case "clientrequestvalidation": + case "clientRequestValidation": return boolean.class; + case "collectionauth": + case "collectionAuth": return java.lang.String.class; + case "collectioncachettl": + case "collectionCacheTtl": return long.class; + case "collectionsource": + case "collectionSource": return java.lang.String.class; + case "collectionsourcetype": + case "collectionSourceType": return java.lang.String.class; + case "componentname": + case "componentName": return java.lang.String.class; + case "configuration": return org.apache.camel.component.rest.postman.RestPostmanConfiguration.class; + case "connecttimeout": + case "connectTimeout": return long.class; + case "consumercomponentname": + case "consumerComponentName": return java.lang.String.class; + case "consumes": return java.lang.String.class; + case "failonunresolvedvariable": + case "failOnUnresolvedVariable": return boolean.class; + case "host": return java.lang.String.class; + case "lazystartproducer": + case "lazyStartProducer": return boolean.class; + case "missingrequest": + case "missingRequest": return java.lang.String.class; + case "mockincludepattern": + case "mockIncludePattern": return java.lang.String.class; + case "oauthprofile": + case "oauthProfile": return java.lang.String.class; + case "postmanapikey": + case "postmanApiKey": return java.lang.String.class; + case "postmanapikeyheader": + case "postmanApiKeyHeader": return java.lang.String.class; + case "postmanapiurl": + case "postmanApiUrl": return java.lang.String.class; + case "produces": return java.lang.String.class; + case "queryparametermode": + case "queryParameterMode": return java.lang.String.class; + case "requestfilter": + case "requestFilter": return java.lang.String.class; + case "requesttimeout": + case "requestTimeout": return long.class; + case "restpostmanprocessorstrategy": + case "restPostmanProcessorStrategy": return org.apache.camel.component.rest.postman.RestPostmanProcessorStrategy.class; + case "runfailfast": + case "runFailFast": return boolean.class; + case "sslcontextparameters": + case "sslContextParameters": return org.apache.camel.support.jsse.SSLContextParameters.class; + case "useglobalsslcontextparameters": + case "useGlobalSslContextParameters": return boolean.class; + case "variables": return java.util.Map.class; + default: return null; + } + } + + @Override + public Object getOptionValue(Object obj, String name, boolean ignoreCase) { + RestPostmanComponent target = (RestPostmanComponent) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "apicontextpath": + case "apiContextPath": return getOrCreateConfiguration(target).getApiContextPath(); + case "autowiredenabled": + case "autowiredEnabled": return target.isAutowiredEnabled(); + case "basepath": + case "basePath": return getOrCreateConfiguration(target).getBasePath(); + case "bridgeerrorhandler": + case "bridgeErrorHandler": return target.isBridgeErrorHandler(); + case "clientrequestvalidation": + case "clientRequestValidation": return getOrCreateConfiguration(target).isClientRequestValidation(); + case "collectionauth": + case "collectionAuth": return getOrCreateConfiguration(target).getCollectionAuth(); + case "collectioncachettl": + case "collectionCacheTtl": return getOrCreateConfiguration(target).getCollectionCacheTtl(); + case "collectionsource": + case "collectionSource": return target.getCollectionSource(); + case "collectionsourcetype": + case "collectionSourceType": return getOrCreateConfiguration(target).getCollectionSourceType(); + case "componentname": + case "componentName": return getOrCreateConfiguration(target).getComponentName(); + case "configuration": return target.getConfiguration(); + case "connecttimeout": + case "connectTimeout": return getOrCreateConfiguration(target).getConnectTimeout(); + case "consumercomponentname": + case "consumerComponentName": return getOrCreateConfiguration(target).getConsumerComponentName(); + case "consumes": return getOrCreateConfiguration(target).getConsumes(); + case "failonunresolvedvariable": + case "failOnUnresolvedVariable": return getOrCreateConfiguration(target).isFailOnUnresolvedVariable(); + case "host": return getOrCreateConfiguration(target).getHost(); + case "lazystartproducer": + case "lazyStartProducer": return target.isLazyStartProducer(); + case "missingrequest": + case "missingRequest": return getOrCreateConfiguration(target).getMissingRequest(); + case "mockincludepattern": + case "mockIncludePattern": return getOrCreateConfiguration(target).getMockIncludePattern(); + case "oauthprofile": + case "oauthProfile": return getOrCreateConfiguration(target).getOauthProfile(); + case "postmanapikey": + case "postmanApiKey": return getOrCreateConfiguration(target).getPostmanApiKey(); + case "postmanapikeyheader": + case "postmanApiKeyHeader": return getOrCreateConfiguration(target).getPostmanApiKeyHeader(); + case "postmanapiurl": + case "postmanApiUrl": return getOrCreateConfiguration(target).getPostmanApiUrl(); + case "produces": return getOrCreateConfiguration(target).getProduces(); + case "queryparametermode": + case "queryParameterMode": return getOrCreateConfiguration(target).getQueryParameterMode(); + case "requestfilter": + case "requestFilter": return getOrCreateConfiguration(target).getRequestFilter(); + case "requesttimeout": + case "requestTimeout": return getOrCreateConfiguration(target).getRequestTimeout(); + case "restpostmanprocessorstrategy": + case "restPostmanProcessorStrategy": return target.getRestPostmanProcessorStrategy(); + case "runfailfast": + case "runFailFast": return getOrCreateConfiguration(target).isRunFailFast(); + case "sslcontextparameters": + case "sslContextParameters": return getOrCreateConfiguration(target).getSslContextParameters(); + case "useglobalsslcontextparameters": + case "useGlobalSslContextParameters": return getOrCreateConfiguration(target).isUseGlobalSslContextParameters(); + case "variables": return getOrCreateConfiguration(target).getVariables(); + default: return null; + } + } + + @Override + public Object getCollectionValueType(Object target, String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "variables": return java.lang.Object.class; + default: return null; + } + } +} + diff --git a/components/camel-rest-postman/src/generated/java/org/apache/camel/component/rest/postman/RestPostmanConfigurationConfigurer.java b/components/camel-rest-postman/src/generated/java/org/apache/camel/component/rest/postman/RestPostmanConfigurationConfigurer.java new file mode 100644 index 0000000000000..c5536f3151314 --- /dev/null +++ b/components/camel-rest-postman/src/generated/java/org/apache/camel/component/rest/postman/RestPostmanConfigurationConfigurer.java @@ -0,0 +1,234 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.rest.postman; + +import javax.annotation.processing.Generated; +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.spi.ExtendedPropertyConfigurerGetter; +import org.apache.camel.spi.PropertyConfigurerGetter; +import org.apache.camel.spi.ConfigurerStrategy; +import org.apache.camel.spi.GeneratedPropertyConfigurer; +import org.apache.camel.util.CaseInsensitiveMap; +import org.apache.camel.component.rest.postman.RestPostmanConfiguration; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.GenerateConfigurerMojo") +@SuppressWarnings("unchecked") +public class RestPostmanConfigurationConfigurer extends org.apache.camel.support.component.PropertyConfigurerSupport implements GeneratedPropertyConfigurer, ExtendedPropertyConfigurerGetter { + + private static final Map ALL_OPTIONS; + static { + Map map = new CaseInsensitiveMap(); + map.put("ApiContextPath", java.lang.String.class); + map.put("BasePath", java.lang.String.class); + map.put("ClientRequestValidation", boolean.class); + map.put("CollectionAuth", java.lang.String.class); + map.put("CollectionCacheTtl", long.class); + map.put("CollectionSourceType", java.lang.String.class); + map.put("ComponentName", java.lang.String.class); + map.put("ConnectTimeout", long.class); + map.put("ConsumerComponentName", java.lang.String.class); + map.put("Consumes", java.lang.String.class); + map.put("FailOnUnresolvedVariable", boolean.class); + map.put("Host", java.lang.String.class); + map.put("MissingRequest", java.lang.String.class); + map.put("MockIncludePattern", java.lang.String.class); + map.put("OauthProfile", java.lang.String.class); + map.put("PostmanApiKey", java.lang.String.class); + map.put("PostmanApiKeyHeader", java.lang.String.class); + map.put("PostmanApiUrl", java.lang.String.class); + map.put("Produces", java.lang.String.class); + map.put("QueryParameterMode", java.lang.String.class); + map.put("RequestFilter", java.lang.String.class); + map.put("RequestTimeout", long.class); + map.put("RunFailFast", boolean.class); + map.put("SslContextParameters", org.apache.camel.support.jsse.SSLContextParameters.class); + map.put("UseGlobalSslContextParameters", boolean.class); + map.put("Variables", java.util.Map.class); + ALL_OPTIONS = map; + } + + @Override + public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { + org.apache.camel.component.rest.postman.RestPostmanConfiguration target = (org.apache.camel.component.rest.postman.RestPostmanConfiguration) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "apicontextpath": + case "apiContextPath": target.setApiContextPath(property(camelContext, java.lang.String.class, value)); return true; + case "basepath": + case "basePath": target.setBasePath(property(camelContext, java.lang.String.class, value)); return true; + case "clientrequestvalidation": + case "clientRequestValidation": target.setClientRequestValidation(property(camelContext, boolean.class, value)); return true; + case "collectionauth": + case "collectionAuth": target.setCollectionAuth(property(camelContext, java.lang.String.class, value)); return true; + case "collectioncachettl": + case "collectionCacheTtl": target.setCollectionCacheTtl(property(camelContext, long.class, value)); return true; + case "collectionsourcetype": + case "collectionSourceType": target.setCollectionSourceType(property(camelContext, java.lang.String.class, value)); return true; + case "componentname": + case "componentName": target.setComponentName(property(camelContext, java.lang.String.class, value)); return true; + case "connecttimeout": + case "connectTimeout": target.setConnectTimeout(property(camelContext, long.class, value)); return true; + case "consumercomponentname": + case "consumerComponentName": target.setConsumerComponentName(property(camelContext, java.lang.String.class, value)); return true; + case "consumes": target.setConsumes(property(camelContext, java.lang.String.class, value)); return true; + case "failonunresolvedvariable": + case "failOnUnresolvedVariable": target.setFailOnUnresolvedVariable(property(camelContext, boolean.class, value)); return true; + case "host": target.setHost(property(camelContext, java.lang.String.class, value)); return true; + case "missingrequest": + case "missingRequest": target.setMissingRequest(property(camelContext, java.lang.String.class, value)); return true; + case "mockincludepattern": + case "mockIncludePattern": target.setMockIncludePattern(property(camelContext, java.lang.String.class, value)); return true; + case "oauthprofile": + case "oauthProfile": target.setOauthProfile(property(camelContext, java.lang.String.class, value)); return true; + case "postmanapikey": + case "postmanApiKey": target.setPostmanApiKey(property(camelContext, java.lang.String.class, value)); return true; + case "postmanapikeyheader": + case "postmanApiKeyHeader": target.setPostmanApiKeyHeader(property(camelContext, java.lang.String.class, value)); return true; + case "postmanapiurl": + case "postmanApiUrl": target.setPostmanApiUrl(property(camelContext, java.lang.String.class, value)); return true; + case "produces": target.setProduces(property(camelContext, java.lang.String.class, value)); return true; + case "queryparametermode": + case "queryParameterMode": target.setQueryParameterMode(property(camelContext, java.lang.String.class, value)); return true; + case "requestfilter": + case "requestFilter": target.setRequestFilter(property(camelContext, java.lang.String.class, value)); return true; + case "requesttimeout": + case "requestTimeout": target.setRequestTimeout(property(camelContext, long.class, value)); return true; + case "runfailfast": + case "runFailFast": target.setRunFailFast(property(camelContext, boolean.class, value)); return true; + case "sslcontextparameters": + case "sslContextParameters": target.setSslContextParameters(property(camelContext, org.apache.camel.support.jsse.SSLContextParameters.class, value)); return true; + case "useglobalsslcontextparameters": + case "useGlobalSslContextParameters": target.setUseGlobalSslContextParameters(property(camelContext, boolean.class, value)); return true; + case "variables": target.setVariables(property(camelContext, java.util.Map.class, value)); return true; + default: return false; + } + } + + @Override + public Map getAllOptions(Object target) { + return ALL_OPTIONS; + } + + @Override + public Class getOptionType(String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "apicontextpath": + case "apiContextPath": return java.lang.String.class; + case "basepath": + case "basePath": return java.lang.String.class; + case "clientrequestvalidation": + case "clientRequestValidation": return boolean.class; + case "collectionauth": + case "collectionAuth": return java.lang.String.class; + case "collectioncachettl": + case "collectionCacheTtl": return long.class; + case "collectionsourcetype": + case "collectionSourceType": return java.lang.String.class; + case "componentname": + case "componentName": return java.lang.String.class; + case "connecttimeout": + case "connectTimeout": return long.class; + case "consumercomponentname": + case "consumerComponentName": return java.lang.String.class; + case "consumes": return java.lang.String.class; + case "failonunresolvedvariable": + case "failOnUnresolvedVariable": return boolean.class; + case "host": return java.lang.String.class; + case "missingrequest": + case "missingRequest": return java.lang.String.class; + case "mockincludepattern": + case "mockIncludePattern": return java.lang.String.class; + case "oauthprofile": + case "oauthProfile": return java.lang.String.class; + case "postmanapikey": + case "postmanApiKey": return java.lang.String.class; + case "postmanapikeyheader": + case "postmanApiKeyHeader": return java.lang.String.class; + case "postmanapiurl": + case "postmanApiUrl": return java.lang.String.class; + case "produces": return java.lang.String.class; + case "queryparametermode": + case "queryParameterMode": return java.lang.String.class; + case "requestfilter": + case "requestFilter": return java.lang.String.class; + case "requesttimeout": + case "requestTimeout": return long.class; + case "runfailfast": + case "runFailFast": return boolean.class; + case "sslcontextparameters": + case "sslContextParameters": return org.apache.camel.support.jsse.SSLContextParameters.class; + case "useglobalsslcontextparameters": + case "useGlobalSslContextParameters": return boolean.class; + case "variables": return java.util.Map.class; + default: return null; + } + } + + @Override + public Object getOptionValue(Object obj, String name, boolean ignoreCase) { + org.apache.camel.component.rest.postman.RestPostmanConfiguration target = (org.apache.camel.component.rest.postman.RestPostmanConfiguration) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "apicontextpath": + case "apiContextPath": return target.getApiContextPath(); + case "basepath": + case "basePath": return target.getBasePath(); + case "clientrequestvalidation": + case "clientRequestValidation": return target.isClientRequestValidation(); + case "collectionauth": + case "collectionAuth": return target.getCollectionAuth(); + case "collectioncachettl": + case "collectionCacheTtl": return target.getCollectionCacheTtl(); + case "collectionsourcetype": + case "collectionSourceType": return target.getCollectionSourceType(); + case "componentname": + case "componentName": return target.getComponentName(); + case "connecttimeout": + case "connectTimeout": return target.getConnectTimeout(); + case "consumercomponentname": + case "consumerComponentName": return target.getConsumerComponentName(); + case "consumes": return target.getConsumes(); + case "failonunresolvedvariable": + case "failOnUnresolvedVariable": return target.isFailOnUnresolvedVariable(); + case "host": return target.getHost(); + case "missingrequest": + case "missingRequest": return target.getMissingRequest(); + case "mockincludepattern": + case "mockIncludePattern": return target.getMockIncludePattern(); + case "oauthprofile": + case "oauthProfile": return target.getOauthProfile(); + case "postmanapikey": + case "postmanApiKey": return target.getPostmanApiKey(); + case "postmanapikeyheader": + case "postmanApiKeyHeader": return target.getPostmanApiKeyHeader(); + case "postmanapiurl": + case "postmanApiUrl": return target.getPostmanApiUrl(); + case "produces": return target.getProduces(); + case "queryparametermode": + case "queryParameterMode": return target.getQueryParameterMode(); + case "requestfilter": + case "requestFilter": return target.getRequestFilter(); + case "requesttimeout": + case "requestTimeout": return target.getRequestTimeout(); + case "runfailfast": + case "runFailFast": return target.isRunFailFast(); + case "sslcontextparameters": + case "sslContextParameters": return target.getSslContextParameters(); + case "useglobalsslcontextparameters": + case "useGlobalSslContextParameters": return target.isUseGlobalSslContextParameters(); + case "variables": return target.getVariables(); + default: return null; + } + } + + @Override + public Object getCollectionValueType(Object target, String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "variables": return java.lang.Object.class; + default: return null; + } + } +} + diff --git a/components/camel-rest-postman/src/generated/java/org/apache/camel/component/rest/postman/RestPostmanEndpointConfigurer.java b/components/camel-rest-postman/src/generated/java/org/apache/camel/component/rest/postman/RestPostmanEndpointConfigurer.java new file mode 100644 index 0000000000000..1083cdca9b81a --- /dev/null +++ b/components/camel-rest-postman/src/generated/java/org/apache/camel/component/rest/postman/RestPostmanEndpointConfigurer.java @@ -0,0 +1,221 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.rest.postman; + +import javax.annotation.processing.Generated; +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.spi.ExtendedPropertyConfigurerGetter; +import org.apache.camel.spi.PropertyConfigurerGetter; +import org.apache.camel.spi.ConfigurerStrategy; +import org.apache.camel.spi.GeneratedPropertyConfigurer; +import org.apache.camel.util.CaseInsensitiveMap; +import org.apache.camel.support.component.PropertyConfigurerSupport; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.EndpointSchemaGeneratorMojo") +@SuppressWarnings("unchecked") +public class RestPostmanEndpointConfigurer extends PropertyConfigurerSupport implements GeneratedPropertyConfigurer, PropertyConfigurerGetter { + + @Override + public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { + RestPostmanEndpoint target = (RestPostmanEndpoint) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "apicontextpath": + case "apiContextPath": target.getConfiguration().setApiContextPath(property(camelContext, java.lang.String.class, value)); return true; + case "basepath": + case "basePath": target.getConfiguration().setBasePath(property(camelContext, java.lang.String.class, value)); return true; + case "bridgeerrorhandler": + case "bridgeErrorHandler": target.setBridgeErrorHandler(property(camelContext, boolean.class, value)); return true; + case "clientrequestvalidation": + case "clientRequestValidation": target.getConfiguration().setClientRequestValidation(property(camelContext, boolean.class, value)); return true; + case "collectionauth": + case "collectionAuth": target.getConfiguration().setCollectionAuth(property(camelContext, java.lang.String.class, value)); return true; + case "collectioncachettl": + case "collectionCacheTtl": target.getConfiguration().setCollectionCacheTtl(property(camelContext, long.class, value)); return true; + case "collectionsourcetype": + case "collectionSourceType": target.getConfiguration().setCollectionSourceType(property(camelContext, java.lang.String.class, value)); return true; + case "componentname": + case "componentName": target.getConfiguration().setComponentName(property(camelContext, java.lang.String.class, value)); return true; + case "connecttimeout": + case "connectTimeout": target.getConfiguration().setConnectTimeout(property(camelContext, long.class, value)); return true; + case "consumercomponentname": + case "consumerComponentName": target.getConfiguration().setConsumerComponentName(property(camelContext, java.lang.String.class, value)); return true; + case "consumes": target.getConfiguration().setConsumes(property(camelContext, java.lang.String.class, value)); return true; + case "exceptionhandler": + case "exceptionHandler": target.setExceptionHandler(property(camelContext, org.apache.camel.spi.ExceptionHandler.class, value)); return true; + case "exchangepattern": + case "exchangePattern": target.setExchangePattern(property(camelContext, org.apache.camel.ExchangePattern.class, value)); return true; + case "failonunresolvedvariable": + case "failOnUnresolvedVariable": target.getConfiguration().setFailOnUnresolvedVariable(property(camelContext, boolean.class, value)); return true; + case "host": target.getConfiguration().setHost(property(camelContext, java.lang.String.class, value)); return true; + case "lazystartproducer": + case "lazyStartProducer": target.setLazyStartProducer(property(camelContext, boolean.class, value)); return true; + case "missingrequest": + case "missingRequest": target.getConfiguration().setMissingRequest(property(camelContext, java.lang.String.class, value)); return true; + case "mockincludepattern": + case "mockIncludePattern": target.getConfiguration().setMockIncludePattern(property(camelContext, java.lang.String.class, value)); return true; + case "oauthprofile": + case "oauthProfile": target.getConfiguration().setOauthProfile(property(camelContext, java.lang.String.class, value)); return true; + case "postmanapikey": + case "postmanApiKey": target.getConfiguration().setPostmanApiKey(property(camelContext, java.lang.String.class, value)); return true; + case "postmanapikeyheader": + case "postmanApiKeyHeader": target.getConfiguration().setPostmanApiKeyHeader(property(camelContext, java.lang.String.class, value)); return true; + case "postmanapiurl": + case "postmanApiUrl": target.getConfiguration().setPostmanApiUrl(property(camelContext, java.lang.String.class, value)); return true; + case "produces": target.getConfiguration().setProduces(property(camelContext, java.lang.String.class, value)); return true; + case "queryparametermode": + case "queryParameterMode": target.getConfiguration().setQueryParameterMode(property(camelContext, java.lang.String.class, value)); return true; + case "requestfilter": + case "requestFilter": target.getConfiguration().setRequestFilter(property(camelContext, java.lang.String.class, value)); return true; + case "requesttimeout": + case "requestTimeout": target.getConfiguration().setRequestTimeout(property(camelContext, long.class, value)); return true; + case "runfailfast": + case "runFailFast": target.getConfiguration().setRunFailFast(property(camelContext, boolean.class, value)); return true; + case "sslcontextparameters": + case "sslContextParameters": target.getConfiguration().setSslContextParameters(property(camelContext, org.apache.camel.support.jsse.SSLContextParameters.class, value)); return true; + case "useglobalsslcontextparameters": + case "useGlobalSslContextParameters": target.getConfiguration().setUseGlobalSslContextParameters(property(camelContext, boolean.class, value)); return true; + case "variables": target.getConfiguration().setVariables(property(camelContext, java.util.Map.class, value)); return true; + default: return false; + } + } + + @Override + public Class getOptionType(String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "apicontextpath": + case "apiContextPath": return java.lang.String.class; + case "basepath": + case "basePath": return java.lang.String.class; + case "bridgeerrorhandler": + case "bridgeErrorHandler": return boolean.class; + case "clientrequestvalidation": + case "clientRequestValidation": return boolean.class; + case "collectionauth": + case "collectionAuth": return java.lang.String.class; + case "collectioncachettl": + case "collectionCacheTtl": return long.class; + case "collectionsourcetype": + case "collectionSourceType": return java.lang.String.class; + case "componentname": + case "componentName": return java.lang.String.class; + case "connecttimeout": + case "connectTimeout": return long.class; + case "consumercomponentname": + case "consumerComponentName": return java.lang.String.class; + case "consumes": return java.lang.String.class; + case "exceptionhandler": + case "exceptionHandler": return org.apache.camel.spi.ExceptionHandler.class; + case "exchangepattern": + case "exchangePattern": return org.apache.camel.ExchangePattern.class; + case "failonunresolvedvariable": + case "failOnUnresolvedVariable": return boolean.class; + case "host": return java.lang.String.class; + case "lazystartproducer": + case "lazyStartProducer": return boolean.class; + case "missingrequest": + case "missingRequest": return java.lang.String.class; + case "mockincludepattern": + case "mockIncludePattern": return java.lang.String.class; + case "oauthprofile": + case "oauthProfile": return java.lang.String.class; + case "postmanapikey": + case "postmanApiKey": return java.lang.String.class; + case "postmanapikeyheader": + case "postmanApiKeyHeader": return java.lang.String.class; + case "postmanapiurl": + case "postmanApiUrl": return java.lang.String.class; + case "produces": return java.lang.String.class; + case "queryparametermode": + case "queryParameterMode": return java.lang.String.class; + case "requestfilter": + case "requestFilter": return java.lang.String.class; + case "requesttimeout": + case "requestTimeout": return long.class; + case "runfailfast": + case "runFailFast": return boolean.class; + case "sslcontextparameters": + case "sslContextParameters": return org.apache.camel.support.jsse.SSLContextParameters.class; + case "useglobalsslcontextparameters": + case "useGlobalSslContextParameters": return boolean.class; + case "variables": return java.util.Map.class; + default: return null; + } + } + + @Override + public Object getOptionValue(Object obj, String name, boolean ignoreCase) { + RestPostmanEndpoint target = (RestPostmanEndpoint) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "apicontextpath": + case "apiContextPath": return target.getConfiguration().getApiContextPath(); + case "basepath": + case "basePath": return target.getConfiguration().getBasePath(); + case "bridgeerrorhandler": + case "bridgeErrorHandler": return target.isBridgeErrorHandler(); + case "clientrequestvalidation": + case "clientRequestValidation": return target.getConfiguration().isClientRequestValidation(); + case "collectionauth": + case "collectionAuth": return target.getConfiguration().getCollectionAuth(); + case "collectioncachettl": + case "collectionCacheTtl": return target.getConfiguration().getCollectionCacheTtl(); + case "collectionsourcetype": + case "collectionSourceType": return target.getConfiguration().getCollectionSourceType(); + case "componentname": + case "componentName": return target.getConfiguration().getComponentName(); + case "connecttimeout": + case "connectTimeout": return target.getConfiguration().getConnectTimeout(); + case "consumercomponentname": + case "consumerComponentName": return target.getConfiguration().getConsumerComponentName(); + case "consumes": return target.getConfiguration().getConsumes(); + case "exceptionhandler": + case "exceptionHandler": return target.getExceptionHandler(); + case "exchangepattern": + case "exchangePattern": return target.getExchangePattern(); + case "failonunresolvedvariable": + case "failOnUnresolvedVariable": return target.getConfiguration().isFailOnUnresolvedVariable(); + case "host": return target.getConfiguration().getHost(); + case "lazystartproducer": + case "lazyStartProducer": return target.isLazyStartProducer(); + case "missingrequest": + case "missingRequest": return target.getConfiguration().getMissingRequest(); + case "mockincludepattern": + case "mockIncludePattern": return target.getConfiguration().getMockIncludePattern(); + case "oauthprofile": + case "oauthProfile": return target.getConfiguration().getOauthProfile(); + case "postmanapikey": + case "postmanApiKey": return target.getConfiguration().getPostmanApiKey(); + case "postmanapikeyheader": + case "postmanApiKeyHeader": return target.getConfiguration().getPostmanApiKeyHeader(); + case "postmanapiurl": + case "postmanApiUrl": return target.getConfiguration().getPostmanApiUrl(); + case "produces": return target.getConfiguration().getProduces(); + case "queryparametermode": + case "queryParameterMode": return target.getConfiguration().getQueryParameterMode(); + case "requestfilter": + case "requestFilter": return target.getConfiguration().getRequestFilter(); + case "requesttimeout": + case "requestTimeout": return target.getConfiguration().getRequestTimeout(); + case "runfailfast": + case "runFailFast": return target.getConfiguration().isRunFailFast(); + case "sslcontextparameters": + case "sslContextParameters": return target.getConfiguration().getSslContextParameters(); + case "useglobalsslcontextparameters": + case "useGlobalSslContextParameters": return target.getConfiguration().isUseGlobalSslContextParameters(); + case "variables": return target.getConfiguration().getVariables(); + default: return null; + } + } + + @Override + public Object getCollectionValueType(Object target, String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "variables": return java.lang.Object.class; + default: return null; + } + } +} + diff --git a/components/camel-rest-postman/src/generated/java/org/apache/camel/component/rest/postman/RestPostmanEndpointUriFactory.java b/components/camel-rest-postman/src/generated/java/org/apache/camel/component/rest/postman/RestPostmanEndpointUriFactory.java new file mode 100644 index 0000000000000..d2bee70eec09d --- /dev/null +++ b/components/camel-rest-postman/src/generated/java/org/apache/camel/component/rest/postman/RestPostmanEndpointUriFactory.java @@ -0,0 +1,113 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.rest.postman; + +import javax.annotation.processing.Generated; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.apache.camel.spi.EndpointUriFactory; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.GenerateEndpointUriFactoryMojo") +public class RestPostmanEndpointUriFactory extends org.apache.camel.support.component.EndpointUriFactorySupport implements EndpointUriFactory { + + private static final String BASE = ":collectionSource#requestId"; + + private static final Set PROPERTY_NAMES; + private static final Set SECRET_PROPERTY_NAMES; + private static final Set ENDPOINT_IDENTITY_PROPERTY_NAMES; + private static final Map MULTI_VALUE_PREFIXES; + static { + Set props = new HashSet<>(32); + props.add("apiContextPath"); + props.add("basePath"); + props.add("bridgeErrorHandler"); + props.add("clientRequestValidation"); + props.add("collectionAuth"); + props.add("collectionCacheTtl"); + props.add("collectionSource"); + props.add("collectionSourceType"); + props.add("componentName"); + props.add("connectTimeout"); + props.add("consumerComponentName"); + props.add("consumes"); + props.add("exceptionHandler"); + props.add("exchangePattern"); + props.add("failOnUnresolvedVariable"); + props.add("host"); + props.add("lazyStartProducer"); + props.add("missingRequest"); + props.add("mockIncludePattern"); + props.add("oauthProfile"); + props.add("postmanApiKey"); + props.add("postmanApiKeyHeader"); + props.add("postmanApiUrl"); + props.add("produces"); + props.add("queryParameterMode"); + props.add("requestFilter"); + props.add("requestId"); + props.add("requestTimeout"); + props.add("runFailFast"); + props.add("sslContextParameters"); + props.add("useGlobalSslContextParameters"); + props.add("variables"); + PROPERTY_NAMES = Collections.unmodifiableSet(props); + Set secretProps = new HashSet<>(1); + secretProps.add("postmanApiKey"); + SECRET_PROPERTY_NAMES = Collections.unmodifiableSet(secretProps); + ENDPOINT_IDENTITY_PROPERTY_NAMES = Collections.emptySet(); + Map prefixes = new HashMap<>(1); + prefixes.put("variables", "variable."); + MULTI_VALUE_PREFIXES = Collections.unmodifiableMap(prefixes); + } + + @Override + public boolean isEnabled(String scheme) { + return "rest-postman".equals(scheme); + } + + @Override + public String buildUri(String scheme, Map properties, boolean encode) throws URISyntaxException { + String syntax = scheme + BASE; + String uri = syntax; + + Map copy = new HashMap<>(properties); + + uri = buildPathParameter(syntax, uri, "collectionSource", "postman-collection.json", false, copy); + uri = buildPathParameter(syntax, uri, "requestId", null, false, copy); + uri = buildQueryParameters(uri, copy, encode); + return uri; + } + + @Override + public Set propertyNames() { + return PROPERTY_NAMES; + } + + @Override + public Set secretPropertyNames() { + return SECRET_PROPERTY_NAMES; + } + + @Override + public Set endpointIdentityPropertyNames() { + return ENDPOINT_IDENTITY_PROPERTY_NAMES; + } + + @Override + public Map multiValuePrefixes() { + return MULTI_VALUE_PREFIXES; + } + + @Override + public boolean isLenientProperties() { + return false; + } +} + diff --git a/components/camel-rest-postman/src/generated/resources/META-INF/org/apache/camel/component/rest/postman/rest-postman.json b/components/camel-rest-postman/src/generated/resources/META-INF/org/apache/camel/component/rest/postman/rest-postman.json new file mode 100644 index 0000000000000..8b2c3ae3d0686 --- /dev/null +++ b/components/camel-rest-postman/src/generated/resources/META-INF/org/apache/camel/component/rest/postman/rest-postman.json @@ -0,0 +1,101 @@ +{ + "component": { + "kind": "component", + "name": "rest-postman", + "title": "REST Postman", + "description": "To call and expose REST services using a Postman Collection as contract.", + "deprecated": false, + "firstVersion": "4.22.0", + "label": "rest,api", + "javaType": "org.apache.camel.component.rest.postman.RestPostmanComponent", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-rest-postman", + "version": "4.22.0-SNAPSHOT", + "scheme": "rest-postman", + "extendsScheme": "", + "syntax": "rest-postman:collectionSource#requestId", + "async": false, + "api": false, + "consumerOnly": false, + "producerOnly": false, + "lenientProperties": false, + "browsable": false, + "remote": true + }, + "componentProperties": { + "basePath": { "index": 0, "kind": "property", "displayName": "Base Path", "group": "common", "label": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "API basePath, for example \/v2. Default is unset, if set overrides the value derived from the request URL in the collection." }, + "collectionSource": { "index": 1, "kind": "property", "displayName": "Collection Source", "group": "common", "label": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The Postman Collection to use, when it is not given on the endpoint. Either a resource URI of a Collection v2.1 JSON document (classpath:, file: or http:), or the uid of a collection to fetch from the Postman cloud." }, + "collectionSourceType": { "index": 2, "kind": "property", "displayName": "Collection Source Type", "group": "common", "label": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "auto", "resource", "cloud" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "auto", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "How to interpret collectionSource. With auto, a bare collection UUID or {ownerId}-{uuid} is fetched from the Postman cloud and anything else is resolved as a resource (classpath:, file:, http:). Use resource or cloud to decide explicitly." }, + "variables": { "index": 3, "kind": "property", "displayName": "Variables", "group": "common", "label": "common", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "variable.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Values for the {{variable}} placeholders used in the collection. These override the variables declared by the collection and its folders. This is a multi-value option with prefix: variable." }, + "failOnUnresolvedVariable": { "index": 4, "kind": "property", "displayName": "Fail On Unresolved Variable", "group": "common (advanced)", "label": "common,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Whether to fail if a {{variable}} placeholder used by the selected request cannot be resolved. When false the placeholder is left as-is." }, + "apiContextPath": { "index": 5, "kind": "property", "displayName": "Api Context Path", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Sets the context-path to use for servicing the Postman collection document. The document is served with all auth blocks and all secret variables removed." }, + "bridgeErrorHandler": { "index": 6, "kind": "property", "displayName": "Bridge Error Handler", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "clientRequestValidation": { "index": 7, "kind": "property", "displayName": "Client Request Validation", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Whether to enable validation of the client request. A Postman collection has no schemas, so this is a best-effort check of required headers, query parameters and body presence only." }, + "missingRequest": { "index": 8, "kind": "property", "displayName": "Missing Request", "group": "consumer", "label": "consumer", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "fail", "ignore", "mock" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "fail", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Whether the consumer should fail, ignore or return a mock response for requests in the collection that are not mapped to a corresponding route." }, + "consumerComponentName": { "index": 9, "kind": "property", "displayName": "Consumer Component Name", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Name of the Camel component that will service the requests. The component must be present in Camel registry and it must be able to service contract-first REST consumers, as platform-http does. If not set CLASSPATH is searched for a single component with that capability." }, + "mockIncludePattern": { "index": 10, "kind": "property", "displayName": "Mock Include Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "classpath:camel-mock\/**", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Used for inclusive filtering of mock data from directories. The pattern is using Ant-path style pattern. Multiple patterns can be specified separated by comma. Saved example responses in the collection are preferred over these files." }, + "requestFilter": { "index": 11, "kind": "property", "displayName": "Request Filter", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Filters which requests of the collection are used, as comma separated Ant-style patterns matched against the folder qualified request id. Prefix a pattern with ! to exclude." }, + "restPostmanProcessorStrategy": { "index": 12, "kind": "property", "displayName": "Rest Postman Processor Strategy", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "object", "javaType": "org.apache.camel.component.rest.postman.RestPostmanProcessorStrategy", "deprecated": false, "autowired": false, "secret": false, "description": "To use a custom strategy for how to service the requests of the collection." }, + "host": { "index": 13, "kind": "property", "displayName": "Host", "group": "producer", "label": "producer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Scheme hostname and port to direct the HTTP requests to in the form of https:\/\/hostname:port. If set overrides any value derived from the collection." }, + "lazyStartProducer": { "index": 14, "kind": "property", "displayName": "Lazy Start Producer", "group": "producer", "label": "producer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, + "runFailFast": { "index": 15, "kind": "property", "displayName": "Run Fail Fast", "group": "producer", "label": "producer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": true, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "When the endpoint runs more than one request, that is when it selects a folder or the whole collection, whether to stop and fail on the first request that fails. When false every request is attempted and the failure is recorded in its result." }, + "componentName": { "index": 16, "kind": "property", "displayName": "Component Name", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Name of the Camel component that will perform the requests. The component must be present in Camel registry and it must implement RestProducerFactory service provider interface. If not set CLASSPATH is searched for single component that implements RestProducerFactory SPI." }, + "consumes": { "index": 17, "kind": "property", "displayName": "Consumes", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "What payload type this component is capable of consuming. This equates to the value of the Accept HTTP header. A Postman collection does not describe responses, so unlike an OpenAPI specification there is nothing to infer this from and it is unset by default." }, + "produces": { "index": 18, "kind": "property", "displayName": "Produces", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "What payload type this component is producing. This equates to the value of the Content-Type HTTP header. If not set it is inferred from the body mode of the request in the collection." }, + "queryParameterMode": { "index": 19, "kind": "property", "displayName": "Query Parameter Mode", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "placeholder", "literal" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "placeholder", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "How to treat the query parameters declared in the collection. With placeholder the parameter names are bound to message headers and the values in the collection are ignored as sample data. With literal the values in the collection are sent as-is." }, + "autowiredEnabled": { "index": 20, "kind": "property", "displayName": "Autowired Enabled", "group": "advanced", "label": "advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Whether autowiring is enabled. This is used for automatic autowiring options (the option must be marked as autowired) by looking up in the registry to find if there is a single instance of matching type, which then gets configured on the component. This can be used for automatic configuring JDBC data sources, JMS connection factories, AWS Clients, etc." }, + "collectionCacheTtl": { "index": 21, "kind": "property", "displayName": "Collection Cache Ttl", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": -1, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "How long a loaded collection is cached, in milliseconds. Use -1 to cache for the lifetime of the component." }, + "configuration": { "index": 22, "kind": "property", "displayName": "Configuration", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "deprecated": false, "autowired": false, "secret": false, "description": "The shared configuration used as the template for every endpoint created by this component." }, + "connectTimeout": { "index": 23, "kind": "property", "displayName": "Connect Timeout", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 15000, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Connection timeout in milliseconds when fetching a collection from the Postman cloud." }, + "requestTimeout": { "index": 24, "kind": "property", "displayName": "Request Timeout", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 30000, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Request timeout in milliseconds when fetching a collection from the Postman cloud." }, + "collectionAuth": { "index": 25, "kind": "property", "displayName": "Collection Auth", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "ignore", "header", "fail" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "ignore", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "What to do with the auth block the collection declares for the target API. With ignore the block is not applied, and a warning names the type that was found. With header the basic, bearer and apikey types are applied as a static header or query parameter, and any other type fails at startup rather than silently sending no credential. With fail any auth block other than noauth is rejected." }, + "oauthProfile": { "index": 26, "kind": "property", "displayName": "OAuth Profile", "group": "security", "label": "consumer,security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The OAuth profile to use for authenticating the incoming requests. The profile is enforced by the consumer component servicing the requests." }, + "postmanApiKey": { "index": 27, "kind": "property", "displayName": "Postman Api Key", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The Postman API key used to fetch the collection from the Postman cloud. This credential authenticates against Postman itself and is never sent to the API the collection describes." }, + "postmanApiKeyHeader": { "index": 28, "kind": "property", "displayName": "Postman Api Key Header", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "X-Api-Key", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The HTTP header used to send the Postman API key when fetching a collection." }, + "sslContextParameters": { "index": 29, "kind": "property", "displayName": "Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.camel.support.jsse.SSLContextParameters", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Customize TLS parameters used by the component. If not set defaults to the TLS parameters set in the Camel context. These parameters are used both when fetching a collection from the Postman cloud and by the delegate producer." }, + "useGlobalSslContextParameters": { "index": 30, "kind": "property", "displayName": "Use Global Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Enable usage of global SSL context parameters." }, + "postmanApiUrl": { "index": 31, "kind": "property", "displayName": "Postman Api Url", "group": "security (advanced)", "label": "security,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "https:\/\/api.getpostman.com", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The base URL of the Postman API used to fetch collections. Must use https, except for localhost, because plain http would send the Postman API key in clear text." } + }, + "headers": { + "CamelRestPostmanRequestId": { "index": 0, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The id of the Postman request being invoked or serviced.", "constantName": "org.apache.camel.component.rest.postman.RestPostmanConstants#REQUEST_ID" }, + "CamelRestPostmanRequestName": { "index": 1, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The name of the Postman request, as written in the collection.", "constantName": "org.apache.camel.component.rest.postman.RestPostmanConstants#REQUEST_NAME" }, + "CamelRestPostmanFolderPath": { "index": 2, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The folder path of the Postman request, with folders separated by a slash.", "constantName": "org.apache.camel.component.rest.postman.RestPostmanConstants#FOLDER_PATH" }, + "CamelRestPostmanRequestCount": { "index": 3, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The number of requests executed when running a folder or a whole collection.", "constantName": "org.apache.camel.component.rest.postman.RestPostmanConstants#REQUEST_COUNT" }, + "CamelRestPostmanFailedCount": { "index": 4, "kind": "header", "displayName": "", "group": "common", "label": "", "required": false, "javaType": "Integer", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The number of requests that failed when running a folder or a whole collection with runFailFast disabled.", "constantName": "org.apache.camel.component.rest.postman.RestPostmanConstants#FAILED_COUNT" } + }, + "properties": { + "collectionSource": { "index": 0, "kind": "path", "displayName": "Collection Source", "group": "common", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "defaultValue": "postman-collection.json", "description": "The Postman Collection to use. Either a resource URI of a Collection v2.1 JSON document (classpath:, file: or http:), or the uid of a collection to fetch from the Postman cloud, which requires postmanApiKey. Default value notice: By default loads the postman-collection.json file" }, + "requestId": { "index": 1, "kind": "path", "displayName": "Request Id", "group": "producer", "label": "producer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The request to invoke, identified by its id in the collection or by its slugified name, for example getUserById. Use a folder id to run every request in that folder, and leave it out to run the whole collection. Append a slash to force a folder match when a request and a folder share a name." }, + "basePath": { "index": 2, "kind": "parameter", "displayName": "Base Path", "group": "common", "label": "common", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "API basePath, for example \/v2. Default is unset, if set overrides the value derived from the request URL in the collection." }, + "collectionSourceType": { "index": 3, "kind": "parameter", "displayName": "Collection Source Type", "group": "common", "label": "common", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "auto", "resource", "cloud" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "auto", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "How to interpret collectionSource. With auto, a bare collection UUID or {ownerId}-{uuid} is fetched from the Postman cloud and anything else is resolved as a resource (classpath:, file:, http:). Use resource or cloud to decide explicitly." }, + "variables": { "index": 4, "kind": "parameter", "displayName": "Variables", "group": "common", "label": "common", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "variable.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Values for the {{variable}} placeholders used in the collection. These override the variables declared by the collection and its folders. This is a multi-value option with prefix: variable." }, + "failOnUnresolvedVariable": { "index": 5, "kind": "parameter", "displayName": "Fail On Unresolved Variable", "group": "common (advanced)", "label": "common,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Whether to fail if a {{variable}} placeholder used by the selected request cannot be resolved. When false the placeholder is left as-is." }, + "apiContextPath": { "index": 6, "kind": "parameter", "displayName": "Api Context Path", "group": "consumer", "label": "consumer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Sets the context-path to use for servicing the Postman collection document. The document is served with all auth blocks and all secret variables removed." }, + "clientRequestValidation": { "index": 7, "kind": "parameter", "displayName": "Client Request Validation", "group": "consumer", "label": "consumer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Whether to enable validation of the client request. A Postman collection has no schemas, so this is a best-effort check of required headers, query parameters and body presence only." }, + "missingRequest": { "index": 8, "kind": "parameter", "displayName": "Missing Request", "group": "consumer", "label": "consumer", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "fail", "ignore", "mock" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "fail", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Whether the consumer should fail, ignore or return a mock response for requests in the collection that are not mapped to a corresponding route." }, + "bridgeErrorHandler": { "index": 9, "kind": "parameter", "displayName": "Bridge Error Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Allows for bridging the consumer to the Camel routing Error Handler, which mean any exceptions (if possible) occurred while the Camel consumer is trying to pickup incoming messages, or the likes, will now be processed as a message and handled by the routing Error Handler. Important: This is only possible if the 3rd party component allows Camel to be alerted if an exception was thrown. Some components handle this internally only, and therefore bridgeErrorHandler is not possible. In other situations we may improve the Camel component to hook into the 3rd party component and make this possible for future releases. By default the consumer will use the org.apache.camel.spi.ExceptionHandler to deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "consumerComponentName": { "index": 10, "kind": "parameter", "displayName": "Consumer Component Name", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Name of the Camel component that will service the requests. The component must be present in Camel registry and it must be able to service contract-first REST consumers, as platform-http does. If not set CLASSPATH is searched for a single component with that capability." }, + "exceptionHandler": { "index": 11, "kind": "parameter", "displayName": "Exception Handler", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "object", "javaType": "org.apache.camel.spi.ExceptionHandler", "optionalPrefix": "consumer.", "deprecated": false, "autowired": false, "secret": false, "description": "To let the consumer use a custom ExceptionHandler. Notice if the option bridgeErrorHandler is enabled then this option is not in use. By default the consumer will deal with exceptions, that will be logged at WARN or ERROR level and ignored." }, + "exchangePattern": { "index": 12, "kind": "parameter", "displayName": "Exchange Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "enum", "javaType": "org.apache.camel.ExchangePattern", "enum": [ "InOnly", "InOut" ], "deprecated": false, "autowired": false, "secret": false, "description": "Sets the exchange pattern when the consumer creates an exchange." }, + "mockIncludePattern": { "index": 13, "kind": "parameter", "displayName": "Mock Include Pattern", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "classpath:camel-mock\/**", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Used for inclusive filtering of mock data from directories. The pattern is using Ant-path style pattern. Multiple patterns can be specified separated by comma. Saved example responses in the collection are preferred over these files." }, + "requestFilter": { "index": 14, "kind": "parameter", "displayName": "Request Filter", "group": "consumer (advanced)", "label": "consumer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Filters which requests of the collection are used, as comma separated Ant-style patterns matched against the folder qualified request id. Prefix a pattern with ! to exclude." }, + "host": { "index": 15, "kind": "parameter", "displayName": "Host", "group": "producer", "label": "producer", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Scheme hostname and port to direct the HTTP requests to in the form of https:\/\/hostname:port. If set overrides any value derived from the collection." }, + "runFailFast": { "index": 16, "kind": "parameter", "displayName": "Run Fail Fast", "group": "producer", "label": "producer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": true, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "When the endpoint runs more than one request, that is when it selects a folder or the whole collection, whether to stop and fail on the first request that fails. When false every request is attempted and the failure is recorded in its result." }, + "componentName": { "index": 17, "kind": "parameter", "displayName": "Component Name", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Name of the Camel component that will perform the requests. The component must be present in Camel registry and it must implement RestProducerFactory service provider interface. If not set CLASSPATH is searched for single component that implements RestProducerFactory SPI." }, + "consumes": { "index": 18, "kind": "parameter", "displayName": "Consumes", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "What payload type this component is capable of consuming. This equates to the value of the Accept HTTP header. A Postman collection does not describe responses, so unlike an OpenAPI specification there is nothing to infer this from and it is unset by default." }, + "lazyStartProducer": { "index": 19, "kind": "parameter", "displayName": "Lazy Start Producer", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "Whether the producer should be started lazy (on the first message). By starting lazy you can use this to allow CamelContext and routes to startup in situations where a producer may otherwise fail during starting and cause the route to fail being started. By deferring this startup to be lazy then the startup failure can be handled during routing messages via Camel's routing error handlers. Beware that when the first message is processed then creating and starting the producer may take a little time and prolong the total processing time of the processing." }, + "produces": { "index": 20, "kind": "parameter", "displayName": "Produces", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "What payload type this component is producing. This equates to the value of the Content-Type HTTP header. If not set it is inferred from the body mode of the request in the collection." }, + "queryParameterMode": { "index": 21, "kind": "parameter", "displayName": "Query Parameter Mode", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "placeholder", "literal" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "placeholder", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "How to treat the query parameters declared in the collection. With placeholder the parameter names are bound to message headers and the values in the collection are ignored as sample data. With literal the values in the collection are sent as-is." }, + "collectionCacheTtl": { "index": 22, "kind": "parameter", "displayName": "Collection Cache Ttl", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": -1, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "How long a loaded collection is cached, in milliseconds. Use -1 to cache for the lifetime of the component." }, + "connectTimeout": { "index": 23, "kind": "parameter", "displayName": "Connect Timeout", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 15000, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Connection timeout in milliseconds when fetching a collection from the Postman cloud." }, + "requestTimeout": { "index": 24, "kind": "parameter", "displayName": "Request Timeout", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "long", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 30000, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Request timeout in milliseconds when fetching a collection from the Postman cloud." }, + "collectionAuth": { "index": 25, "kind": "parameter", "displayName": "Collection Auth", "group": "security", "label": "security", "required": false, "type": "enum", "javaType": "java.lang.String", "enum": [ "ignore", "header", "fail" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "ignore", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "What to do with the auth block the collection declares for the target API. With ignore the block is not applied, and a warning names the type that was found. With header the basic, bearer and apikey types are applied as a static header or query parameter, and any other type fails at startup rather than silently sending no credential. With fail any auth block other than noauth is rejected." }, + "oauthProfile": { "index": 26, "kind": "parameter", "displayName": "OAuth Profile", "group": "security", "label": "consumer,security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The OAuth profile to use for authenticating the incoming requests. The profile is enforced by the consumer component servicing the requests." }, + "postmanApiKey": { "index": 27, "kind": "parameter", "displayName": "Postman Api Key", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": true, "security": "secret", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The Postman API key used to fetch the collection from the Postman cloud. This credential authenticates against Postman itself and is never sent to the API the collection describes." }, + "postmanApiKeyHeader": { "index": 28, "kind": "parameter", "displayName": "Postman Api Key Header", "group": "security", "label": "security", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "X-Api-Key", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The HTTP header used to send the Postman API key when fetching a collection." }, + "sslContextParameters": { "index": 29, "kind": "parameter", "displayName": "Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "object", "javaType": "org.apache.camel.support.jsse.SSLContextParameters", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Customize TLS parameters used by the component. If not set defaults to the TLS parameters set in the Camel context. These parameters are used both when fetching a collection from the Postman cloud and by the delegate producer." }, + "useGlobalSslContextParameters": { "index": 30, "kind": "parameter", "displayName": "Use Global Ssl Context Parameters", "group": "security", "label": "security", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": false, "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "Enable usage of global SSL context parameters." }, + "postmanApiUrl": { "index": 31, "kind": "parameter", "displayName": "Postman Api Url", "group": "security (advanced)", "label": "security,advanced", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": "https:\/\/api.getpostman.com", "configurationClass": "org.apache.camel.component.rest.postman.RestPostmanConfiguration", "configurationField": "configuration", "description": "The base URL of the Postman API used to fetch collections. Must use https, except for localhost, because plain http would send the Postman API key in clear text." } + } +} diff --git a/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/component.properties b/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/component.properties new file mode 100644 index 0000000000000..156afbeb817a1 --- /dev/null +++ b/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/component.properties @@ -0,0 +1,7 @@ +# Generated by camel build tools - do NOT edit this file! +components=rest-postman +groupId=org.apache.camel +artifactId=camel-rest-postman +version=4.22.0-SNAPSHOT +projectName=Camel :: REST Postman +projectDescription=Camel REST support using Postman Collections diff --git a/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/component/rest-postman b/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/component/rest-postman new file mode 100644 index 0000000000000..d06dc4a0ec6b0 --- /dev/null +++ b/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/component/rest-postman @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.rest.postman.RestPostmanComponent diff --git a/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.rest.postman.RestPostmanConfiguration b/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.rest.postman.RestPostmanConfiguration new file mode 100644 index 0000000000000..e048cf549a337 --- /dev/null +++ b/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.rest.postman.RestPostmanConfiguration @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.rest.postman.RestPostmanConfigurationConfigurer diff --git a/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/configurer/rest-postman-component b/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/configurer/rest-postman-component new file mode 100644 index 0000000000000..c3187f73a0003 --- /dev/null +++ b/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/configurer/rest-postman-component @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.rest.postman.RestPostmanComponentConfigurer diff --git a/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/configurer/rest-postman-endpoint b/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/configurer/rest-postman-endpoint new file mode 100644 index 0000000000000..82d0e82a66b44 --- /dev/null +++ b/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/configurer/rest-postman-endpoint @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.rest.postman.RestPostmanEndpointConfigurer diff --git a/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/urifactory/rest-postman-endpoint b/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/urifactory/rest-postman-endpoint new file mode 100644 index 0000000000000..d1050d07eef8c --- /dev/null +++ b/components/camel-rest-postman/src/generated/resources/META-INF/services/org/apache/camel/urifactory/rest-postman-endpoint @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.rest.postman.RestPostmanEndpointUriFactory diff --git a/components/camel-rest-postman/src/main/docs/rest-postman-component.adoc b/components/camel-rest-postman/src/main/docs/rest-postman-component.adoc new file mode 100644 index 0000000000000..43513d2229ce3 --- /dev/null +++ b/components/camel-rest-postman/src/main/docs/rest-postman-component.adoc @@ -0,0 +1,232 @@ += REST Postman Component +:doctitle: REST Postman +:shortname: rest-postman +:artifactid: camel-rest-postman +:description: To call and expose REST services using a Postman Collection as contract. +:since: 4.22 +:supportlevel: Preview +:tabs-sync-option: +:component-header: Both producer and consumer are supported + +*Since Camel {since}* + +*{component-header}* + +The REST Postman component configures rest producers and contract-first rest consumers from a +https://learning.postman.com/docs/collections/collections-overview/[Postman Collection], and delegates to a component +implementing the _RestProducerFactory_ interface. Currently, known working components are: + +* xref:http-component.adoc[http] +* xref:netty-http-component.adoc[netty-http] +* xref:undertow-component.adoc[undertow] +* xref:vertx-http-component.adoc[vertx-http] + +It is the Postman equivalent of xref:rest-openapi-component.adoc[rest-openapi]. Use it when a Postman Collection is +the description of the API you have, rather than an OpenAPI specification. + +IMPORTANT: Only the Postman Collection Format v2.1 is supported. + +Maven users will need to add the following dependency to their `pom.xml` for this component: + +[source,xml] +---- + + org.apache.camel + camel-rest-postman + x.x.x + + +---- + +== URI format + +---- +rest-postman:[collectionSource#]requestId +---- + +`collectionSource` is either a resource URI of a Collection v2.1 JSON document (`classpath:`, `file:` or `http:`), or +the uid of a collection to fetch from the Postman cloud. It defaults to `postman-collection.json` on the classpath. + +`requestId` selects what to invoke: + +[cols="1,3", options="header"] +|=== +| Fragment | Selects +| `getPetById` | the single request whose name slugifies to `getPetById` +| `pets/addPet` | the request `Add Pet` inside the folder `Pets`, used when a name is not unique +| `3f2504e0-4f89-11d3-9a0c-0305e82c3301` | the request with that `id`, which only collections fetched from the cloud carry +| `pets` | every request in the folder `Pets` +| `pets/` | the folder `Pets`, forced, for when a request and a folder share a name +| _omitted_ | every request in the collection +|=== + +This component's endpoint URI is lenient, which means that in addition to message headers you can specify a request's +parameters as endpoint parameters. These will be constant for all subsequent invocations, so it makes sense to use +this feature only for parameters that are indeed constant for all invocations. + +// component options: START +include::partial$component-configure-options.adoc[] +include::partial$component-endpoint-options.adoc[] +include::partial$component-endpoint-headers.adoc[] +// component options: END + +== Usage + +=== Identifying requests + +A Postman item has a human readable name rather than an operation id, so this component slugifies it: +`Get Pet By Id` becomes `getPetById`. When two requests slugify to the same thing, both are addressed by their folder +qualified id instead, such as `pets/get` and `users/get`, and using the bare `get` is an error that lists the +alternatives. + +`item.id` is also accepted when the collection records one. Note that it is optional in the v2.1 schema, and Postman's +exporter strips auto-generated item ids, so an exported `collection.json` usually has none. Collections fetched from +the Postman cloud do. + +IMPORTANT: Because the common case is to address a request by its slugified name, renaming a request in the Postman +UI changes its id and will break routes bound to it. + +=== Invoking a single request + +[source,java] +---- +from("direct:start") + .to("rest-postman:petstore.json#getPetById"); +---- + +The message body and headers of the exchange are what is sent. The collection supplies the method, the URL, and any +headers the message does not already carry; the body written in the collection is treated as sample data and is not +sent. + +Path parameters written as `:petId` become `+{petId}+` placeholders resolved per exchange from the message header of the +same name, falling back to the value declared in `url.variable`. Query parameters are bound to message headers in the +same way and are dropped when unresolved. Set `queryParameterMode=literal` to send the values written in the +collection instead. + +=== Running a folder or a whole collection + +Naming a folder, or naming nothing at all, runs every request in turn, in the manner of Postman's collection runner: + +[source,java] +---- +from("direct:smokeTest") + .to("rest-postman:petstore.json#pets") // every request in the Pets folder + .to("rest-postman:petstore.json"); // every request in the collection +---- + +Because one exchange body cannot stand in for many different requests, each request sends the body and headers written +in the collection. `raw`, `graphql` and `urlencoded` bodies are reconstructed; `formdata` and `file` bodies cannot be, +and are skipped with a warning. The `file` body mode is never read from disk, as it records a path on the machine of +whoever authored the collection. + +The message body becomes a `List` of `PostmanRunResult`, one per request, each carrying the request id, method, URI, +status code, response body, headers and any failure. The headers `CamelRestPostmanRequestCount` and +`CamelRestPostmanFailedCount` summarise the run. + +By default the run stops and fails on the first request that fails. Set `runFailFast=false` to attempt every request +and record the failures in their results instead: + +[source,java] +---- +from("timer:smoke?period=60000") + .to("rest-postman:petstore.json?runFailFast=false") + .split(body()) + .filter(simple("${body.success} == false")) + .to("log:failures"); +---- + +=== Contract-first consumer + +Pointing a route's `from` at a collection serves its requests over HTTP, dispatching each one to a route consuming +from `direct:`: + +[source,java] +---- +from("rest-postman:petstore.json") + .to("direct:dummy"); + +from("direct:getPetById") + .setBody(constant("{ \"id\": 42 }")); +---- + +Use a folder id to serve only part of a collection, and `requestFilter` to include or exclude requests by Ant-style +patterns over their folder qualified ids. + +Because a collection does not describe a base path, the split between base path and route path is inferred from what +`+{{baseUrl}}+` expands to. Set `basePath` explicitly to control the context path the consumer serves on. + +`missingRequest` decides what happens when a request has no corresponding route: `fail` (the default) refuses to +start, `ignore` warns, and `mock` returns a mocked response. Mock responses are taken from the collection's own saved +example responses where there are any, which is the one place a collection is richer than an OpenAPI specification, +and fall back to files matched by `mockIncludePattern`. + +If two requests share an HTTP method and path, which is common when a collection keeps a success and an error variant +of the same call, the consumer fails at startup rather than letting one silently shadow the other. Use `requestFilter` +to choose between them. + +=== Variables + +`+{{variable}}+` placeholders are resolved from the collection's own `variable` arrays, with folder scopes overriding +the collection scope, then from the endpoint's `variables` option, then from Camel property placeholders: + +[source,java] +---- +from("direct:start") + .to("rest-postman:petstore.json#getPetById?variable.baseUrl=https://staging.example.com/v3"); +---- + +Postman environment files are not supported. Unresolved placeholders are left as they are unless +`failOnUnresolvedVariable=true`. + +A placeholder name written in the `prefix:value` form of a Camel property placeholder function -- `+{{env:HOME}}+`, +`+{{sys:user.home}}+`, `+{{bean:foo}}+` and the vault functions among them -- is deliberately *not* resolved from +Camel properties. A collection is route-author configuration, but a cloud-hosted one is editable by anyone with +access to the Postman workspace, and resolving those would let its content pull an environment variable into an +outgoing request. Supply such values through the `variables` option instead. + +NOTE: Pre-request and test scripts in the collection's `event` blocks are never parsed or executed. + +== Security + +=== Two different credentials + +There are two unrelated credentials in play, and the option names keep them apart: + +`postmanApiKey`:: authenticates against *Postman itself*, in order to download a collection from the Postman cloud. It +is sent only to `postmanApiUrl`, and never to the API that the collection describes. + +the collection's own `auth` block:: authenticates against *the API the collection describes*. It is governed by the +`collectionAuth` option. + +=== Fetching a collection from the Postman cloud + +[source,java] +---- +from("direct:start") + .to("rest-postman:12ece9e1-2abf-4edc-8e34-de66e74114d2#getPetById?postmanApiKey=PMAK-xxxx"); +---- + +Prefer resolving the key from a vault or a property placeholder over writing it in the URI. Redirects from +`postmanApiUrl` are rejected rather than followed, because following one would send the key to the redirect target, +and `postmanApiUrl` must use HTTPS unless it names a loopback host. + +=== Applying the collection's auth block + +`collectionAuth` defaults to `ignore`: the block is not applied, and a warning names the type that was found. This is +deliberate, because the values in a collection's auth block are usually unresolved `+{{placeholders}}+`, and silently +attaching a credential found in a configuration file to outbound requests is surprising. + +Set `collectionAuth=header` to apply it. The `basic`, `bearer` and `apikey` types are reproduced as a static header or +query parameter. The types that require per-request signing or a token exchange -- `awsv4`, `digest`, `hawk`, +`edgegrid`, `ntlm`, `oauth1` and `oauth2` -- fail at startup rather than silently sending no credential; configure +those on the delegate HTTP component instead. `collectionAuth=fail` rejects any auth block at all. + +On the consumer side the collection's auth block describes what a client must present, and is *not* enforced. Use +`oauthProfile`, or the delegate consumer component's own authentication, for that. + +=== Serving the collection document + +When `apiContextPath` is set, the collection is served on that path with every `auth` block removed and the value of +every variable of type `secret` replaced. This redaction is unconditional. + +include::spring-boot:partial$starter.adoc[] diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/DefaultRestPostmanProcessorStrategy.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/DefaultRestPostmanProcessorStrategy.java new file mode 100644 index 0000000000000..77f0b33a230e5 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/DefaultRestPostmanProcessorStrategy.java @@ -0,0 +1,428 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.camel.AsyncCallback; +import org.apache.camel.AsyncProducer; +import org.apache.camel.CamelContext; +import org.apache.camel.CamelContextAware; +import org.apache.camel.Endpoint; +import org.apache.camel.Exchange; +import org.apache.camel.NonManagedService; +import org.apache.camel.Route; +import org.apache.camel.component.platform.http.PlatformHttpComponent; +import org.apache.camel.component.platform.http.spi.PlatformHttpConsumerAware; +import org.apache.camel.component.rest.postman.model.PostmanKeyValue; +import org.apache.camel.component.rest.postman.model.PostmanResponse; +import org.apache.camel.component.rest.postman.support.PostmanRequestBinding; +import org.apache.camel.spi.PackageScanResourceResolver; +import org.apache.camel.spi.ProducerCache; +import org.apache.camel.spi.Resource; +import org.apache.camel.support.ExchangeHelper; +import org.apache.camel.support.PluginHelper; +import org.apache.camel.support.cache.DefaultProducerCache; +import org.apache.camel.support.processor.RestBindingAdvice; +import org.apache.camel.support.service.ServiceHelper; +import org.apache.camel.support.service.ServiceSupport; +import org.apache.camel.util.FileUtil; +import org.apache.camel.util.IOHelper; +import org.apache.camel.util.json.JsonObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default {@link RestPostmanProcessorStrategy}, which links each request of the collection to a route consuming from + * {@code direct:}. + */ +public class DefaultRestPostmanProcessorStrategy extends ServiceSupport + implements RestPostmanProcessorStrategy, CamelContextAware, NonManagedService { + + private static final Logger LOG = LoggerFactory.getLogger(DefaultRestPostmanProcessorStrategy.class); + + private static final String BODY_VERBS = "DELETE,PUT,POST,PATCH"; + + private CamelContext camelContext; + private ProducerCache producerCache; + private String component = "direct"; + private String missingRequest; + private String mockIncludePattern; + private final List uris = new ArrayList<>(); + + @Override + public String resolveDispatchId(PostmanRequestBinding binding) { + Set existing = existingDirectEndpoints(); + // more than one spelling is accepted so that both an exported collection, routed by slug, and a + // cloud-fetched one, routed by request id, work without the author having to know which they have + for (String candidate : candidateIds(binding)) { + if (existing.contains(component + "://" + candidate)) { + return candidate; + } + } + return binding.id(); + } + + private static List candidateIds(PostmanRequestBinding binding) { + List candidates = new ArrayList<>(3); + candidates.add(binding.id()); + String qualified = binding.item().getQualifiedSlug(); + if (!candidates.contains(qualified)) { + candidates.add(qualified); + } + String id = binding.item().getId(); + if (id != null && !candidates.contains(id)) { + candidates.add(id); + } + return candidates; + } + + /** + * The base URIs of every route currently in the context. + *

+ * Comparing base URIs is used rather than {@code hasEndpoint} because looking an endpoint up would create it. + */ + private Set existingDirectEndpoints() { + Set answer = new LinkedHashSet<>(); + for (Route route : camelContext.getRoutes()) { + answer.add(route.getEndpoint().getEndpointBaseUri()); + } + return answer; + } + + @Override + public void validateCollection( + List bindings, String basePath, PlatformHttpConsumerAware platformHttpConsumer) + throws Exception { + + failOnShadowedRequests(bindings); + + Set existing = existingDirectEndpoints(); + List missing = new ArrayList<>(); + for (PostmanRequestBinding binding : bindings) { + boolean found = candidateIds(binding).stream() + .anyMatch(candidate -> existing.contains(component + "://" + candidate)); + if (!found) { + missing.add(component + ":" + binding.id()); + } + } + + if (!missing.isEmpty()) { + String message = String.format( + "Postman collection has %d request(s) not mapped to a corresponding route:%n\t%s", + missing.size(), String.join("\n\t", missing.stream().sorted().toList())); + if ("fail".equalsIgnoreCase(missingRequest)) { + throw new IllegalArgumentException(message); + } else if ("ignore".equalsIgnoreCase(missingRequest)) { + LOG.warn("{}\nThis validation error is ignored.", message); + } else { + LOG.debug("{}\nThis validation error is ignored (a mocked response will be returned).", message); + } + } + + registerHttpEndpoints(bindings, basePath, platformHttpConsumer); + } + + /** + * Rejects a collection where two requests share a verb and path. + *

+ * This is common in real collections, which often keep a success and an error variant of the same call. The matcher + * would pick one of them non-deterministically, so failing loudly at startup is better than silently shadowing a + * route. + */ + private void failOnShadowedRequests(List bindings) { + Map> byRoute = new LinkedHashMap<>(); + for (PostmanRequestBinding binding : bindings) { + byRoute.computeIfAbsent(binding.method() + " " + binding.fullPath(), k -> new ArrayList<>()).add(binding); + } + List clashes = byRoute.entrySet().stream() + .filter(e -> e.getValue().size() > 1) + .map(e -> e.getKey() + " is declared by " + + e.getValue().stream().map(PostmanRequestBinding::id).collect(Collectors.joining(", "))) + .toList(); + if (!clashes.isEmpty()) { + throw new IllegalArgumentException( + "Postman collection has requests that would shadow each other because they share an HTTP method" + + " and path:\n\t" + String.join("\n\t", clashes) + + "\nUse the requestFilter option to select which of them to serve."); + } + } + + private void registerHttpEndpoints( + List bindings, String basePath, PlatformHttpConsumerAware platformHttpConsumer) { + if (platformHttpConsumer == null) { + return; + } + // hasComponent rather than getComponent: looking it up would auto-create the component, which fails + // outside a runtime that provides an HTTP engine + if (!(camelContext.hasComponent("platform-http") instanceof PlatformHttpComponent phc)) { + return; + } + String prefix = basePath == null || basePath.isEmpty() || "/".equals(basePath) ? "" : basePath; + + Map> verbsByUri = new LinkedHashMap<>(); + Map consumesByUri = new LinkedHashMap<>(); + Map producesByUri = new LinkedHashMap<>(); + for (PostmanRequestBinding binding : bindings) { + String uri = prefix + binding.uriTemplate(); + verbsByUri.computeIfAbsent(uri, k -> new LinkedHashSet<>()).add(binding.method()); + if (binding.produces() != null) { + consumesByUri.putIfAbsent(uri, binding.produces()); + } + if (binding.consumes() != null) { + producesByUri.putIfAbsent(uri, binding.consumes()); + } + } + verbsByUri.forEach((uri, verbs) -> { + phc.addHttpEndpoint(uri, String.join(",", verbs.stream().sorted().toList()), + consumesByUri.get(uri), producesByUri.get(uri), platformHttpConsumer.getPlatformHttpConsumer()); + uris.add(uri); + }); + } + + @Override + public boolean process( + PostmanRequestBinding binding, String dispatchId, String verb, String path, + RestBindingAdvice advice, Exchange exchange, AsyncCallback callback) { + + exchange.getMessage().setHeader(RestPostmanConstants.REQUEST_ID, binding.id()); + exchange.getMessage().setHeader(RestPostmanConstants.REQUEST_NAME, binding.item().getName()); + + if ("mock".equalsIgnoreCase(missingRequest) || "ignore".equalsIgnoreCase(missingRequest)) { + Endpoint existing = camelContext.hasEndpoint(component + ":" + dispatchId); + if (existing == null) { + try { + var requestError = advice.doClientRequestValidation(exchange); + if (requestError != null) { + exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, requestError.statusCode()); + exchange.getMessage().setBody(requestError.body()); + exchange.setRouteStop(true); + } else if ("mock".equalsIgnoreCase(missingRequest)) { + loadMockData(binding, path, exchange); + } + } catch (Exception e) { + exchange.setException(e); + } + callback.done(true); + return true; + } + } + + Map state; + try { + state = advice.before(exchange); + } catch (Exception e) { + exchange.setException(e); + callback.done(true); + return true; + } + + final Endpoint endpoint = camelContext.getEndpoint(component + ":" + dispatchId); + final AsyncProducer producer = producerCache.acquireProducer(endpoint); + return producer.process(exchange, doneSync -> { + try { + producerCache.releaseProducer(endpoint, producer); + advice.after(exchange, state); + } catch (Exception e) { + exchange.setException(e); + } finally { + callback.done(doneSync); + } + }); + } + + /** + * Produces a mock response. + *

+ * A saved example in the collection is preferred over a file on disk, because it is a real recorded response for + * exactly this request and needs no naming convention to find. + */ + private void loadMockData(PostmanRequestBinding binding, String path, Exchange exchange) { + PostmanResponse example = pickSavedResponse(binding, exchange); + if (example != null) { + exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, example.getCode()); + for (PostmanKeyValue header : example.getHeaders()) { + if (!header.disabled()) { + exchange.getMessage().setHeader(header.key(), header.value()); + } + } + exchange.getMessage().setBody(example.getBody()); + return; + } + loadMockDataFromFiles(path, exchange); + } + + /** + * Chooses the saved example that best matches what the caller asked for: a successful one, preferring a content + * type the caller said it accepts. + */ + private static PostmanResponse pickSavedResponse(PostmanRequestBinding binding, Exchange exchange) { + List saved = binding.item().getSavedResponses().stream() + .filter(PostmanResponse::isSuccess) + .toList(); + if (saved.isEmpty()) { + return null; + } + String accept = exchange.getMessage().getHeader("Accept", String.class); + if (accept != null) { + for (PostmanResponse candidate : saved) { + String contentType = candidate.getContentType(); + if (contentType != null && accept.contains(stripParameters(contentType))) { + return candidate; + } + } + } + return saved.get(0); + } + + private static String stripParameters(String contentType) { + int semicolon = contentType.indexOf(';'); + return semicolon > 0 ? contentType.substring(0, semicolon).trim() : contentType.trim(); + } + + private void loadMockDataFromFiles(String path, Exchange exchange) { + final PackageScanResourceResolver resolver = PluginHelper.getPackageScanResourceResolver(camelContext); + final String[] includes = mockIncludePattern != null ? mockIncludePattern.split(",") : null; + if (includes == null) { + return; + } + + Collection accepted = new ArrayList<>(); + for (String include : includes) { + try { + accepted.addAll(resolver.findResources(include)); + } catch (Exception e) { + LOG.trace("Mock data directory {} cannot be scanned", include, e); + } + } + + String contentType = ExchangeHelper.getContentType(exchange); + boolean json = contentType != null && contentType.contains("json"); + boolean xml = contentType != null && contentType.contains("xml"); + + String target = FileUtil.stripFirstLeadingSeparator(path); + for (Resource resource : accepted) { + String location = FileUtil.stripExt(FileUtil.compactPath(resource.getLocation(), '/')); + String extension = FileUtil.onlyExt(resource.getLocation()); + boolean matchExt = !json && !xml + || json && "json".equals(extension) + || xml && "xml".equals(extension); + if (location.endsWith(target) && matchExt) { + try (InputStream is = resource.getInputStream()) { + exchange.getMessage().setBody(IOHelper.loadText(is)); + if ("json".equals(extension)) { + exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "application/json"); + } else if ("xml".equals(extension)) { + exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "application/xml"); + } + return; + } catch (Exception e) { + exchange.setException(e); + return; + } + } + } + } + + @Override + public boolean processCollectionDocument(JsonObject redactedDocument, Exchange exchange, AsyncCallback callback) { + try { + exchange.getMessage().setHeader(Exchange.CONTENT_TYPE, "application/json"); + exchange.getMessage().setBody(redactedDocument.toJson()); + } catch (Exception e) { + exchange.setException(e); + } + callback.done(true); + return true; + } + + /** + * Whether the verb normally carries a request body, used to decide if one should be required. + */ + static boolean expectsBody(String verb) { + return BODY_VERBS.contains(verb.toUpperCase(Locale.ROOT)); + } + + @Override + public CamelContext getCamelContext() { + return camelContext; + } + + @Override + public void setCamelContext(CamelContext camelContext) { + this.camelContext = camelContext; + } + + @Override + public String getMissingRequest() { + return missingRequest; + } + + @Override + public void setMissingRequest(String missingRequest) { + this.missingRequest = missingRequest; + } + + @Override + public String getMockIncludePattern() { + return mockIncludePattern; + } + + @Override + public void setMockIncludePattern(String mockIncludePattern) { + this.mockIncludePattern = mockIncludePattern; + } + + @Override + protected void doInit() throws Exception { + producerCache = new DefaultProducerCache(this, getCamelContext(), 1000); + ServiceHelper.initService(producerCache); + + if (missingRequest == null) { + // in developer mode an unmapped request is far more likely to be work in progress than a mistake + boolean dev = "dev".equalsIgnoreCase(camelContext.getCamelContextExtension().getProfile()); + missingRequest = dev ? "mock" : "fail"; + } + } + + @Override + protected void doStart() throws Exception { + ServiceHelper.startService(producerCache); + } + + @Override + protected void doStop() throws Exception { + ServiceHelper.stopService(producerCache); + + if (camelContext != null) { + PlatformHttpComponent phc = (PlatformHttpComponent) camelContext.hasComponent("platform-http"); + if (phc != null) { + uris.forEach(phc::removeHttpEndpoint); + uris.clear(); + } + } + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/PostmanRunResult.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/PostmanRunResult.java new file mode 100644 index 0000000000000..ed0afd316145b --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/PostmanRunResult.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.util.Map; + +/** + * The outcome of one request when a folder or a whole collection is run. + *

+ * A list of these becomes the message body of the exchange, so a route can split over them, filter the failures, or + * report on them. + * + * @param requestId the id of the request within the collection + * @param name the request name as written in the collection + * @param folderPath the enclosing folders, separated by a slash, or {@code null} at the top level + * @param method the HTTP method used + * @param uri the URI called + * @param httpStatus the HTTP status code, or {@code null} when the call did not complete + * @param body the response body, or {@code null} + * @param headers the response headers, never {@code null} + * @param failure the failure message when the call did not succeed, otherwise {@code null} + */ +public record PostmanRunResult( + String requestId, + String name, + String folderPath, + String method, + String uri, + Integer httpStatus, + Object body, + Map headers, + String failure) { + + /** + * Whether this request completed without an exception. + */ + public boolean isSuccess() { + return failure == null; + } + + @Override + public String toString() { + return "PostmanRunResult[" + requestId + " " + method + " " + uri + + (failure != null ? " FAILED: " + failure : " -> " + httpStatus) + "]"; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanComponent.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanComponent.java new file mode 100644 index 0000000000000..08e323bf3676c --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanComponent.java @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.camel.CamelContext; +import org.apache.camel.CamelContextAware; +import org.apache.camel.Endpoint; +import org.apache.camel.SSLContextParametersAware; +import org.apache.camel.component.rest.postman.collection.PostmanCollectionCache; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.annotations.Component; +import org.apache.camel.support.DefaultComponent; +import org.apache.camel.support.jsse.SSLContextParameters; +import org.apache.camel.util.PropertiesHelper; + +/** + * A REST component backed by Postman Collections. + *

+ * Endpoints created by this component connect to the REST APIs described by a Postman Collection, either invoking one + * request of the collection, running a whole folder or collection in the manner of Postman's collection runner, or + * servicing the collection's requests as a contract-first HTTP server. + *

+ * Example usage in the Java DSL: + * + *

+ * from(...).to("rest-postman:petstore.json#getPetById")
+ * 
+ *

+ * The collection can also be fetched from the Postman cloud by its uid, which needs a Postman API key. Note that this + * key authenticates against Postman in order to download the collection; it is never sent to the API that the + * collection describes: + * + *

+ * from(...).to("rest-postman:12ece9e1-2abf-4edc-8e34-de66e74114d2#getPetById?postmanApiKey=PMAK-...")
+ * 
+ */ +@Component("rest-postman") +public class RestPostmanComponent extends DefaultComponent implements SSLContextParametersAware { + + @Metadata(description = "The Postman Collection to use, when it is not given on the endpoint. Either a resource" + + " URI of a Collection v2.1 JSON document (classpath:, file: or http:), or the uid of a" + + " collection to fetch from the Postman cloud.", + label = "common") + private String collectionSource; + + @Metadata(label = "advanced") + private RestPostmanConfiguration configuration = new RestPostmanConfiguration(); + + @Metadata(description = "To use a custom strategy for how to service the requests of the collection.", + label = "consumer,advanced") + private RestPostmanProcessorStrategy restPostmanProcessorStrategy; + + private final PostmanCollectionCache collectionCache = new PostmanCollectionCache(); + + public RestPostmanComponent() { + } + + public RestPostmanComponent(final CamelContext context) { + super(context); + } + + @Override + protected Endpoint createEndpoint(String uri, String remaining, Map parameters) throws Exception { + // the endpoint is lenient, so anything left in parameters becomes a literal path or query value. The + // multi-value variable. prefix therefore has to be taken out explicitly, or every variable would also be + // sent as a bogus query parameter + Map variables = PropertiesHelper.extractProperties(parameters, "variable."); + + RestPostmanEndpoint endpoint = new RestPostmanEndpoint(uri, remaining, this, parameters); + endpoint.setConfiguration(configuration.copy()); + setProperties(endpoint, parameters); + + if (!variables.isEmpty()) { + Map merged = new LinkedHashMap<>(); + if (endpoint.getConfiguration().getVariables() != null) { + merged.putAll(endpoint.getConfiguration().getVariables()); + } + merged.putAll(variables); + endpoint.getConfiguration().setVariables(merged); + } + return endpoint; + } + + /** + * Creates the strategy an endpoint's consumer should use: the configured one when given, otherwise a fresh default + * per endpoint, since the strategy holds per-endpoint state. + */ + RestPostmanProcessorStrategy createProcessorStrategy() { + if (restPostmanProcessorStrategy != null) { + return restPostmanProcessorStrategy; + } + DefaultRestPostmanProcessorStrategy strategy = new DefaultRestPostmanProcessorStrategy(); + CamelContextAware.trySetCamelContext(strategy, getCamelContext()); + strategy.setMissingRequest(configuration.getMissingRequest()); + strategy.setMockIncludePattern(configuration.getMockIncludePattern()); + return strategy; + } + + PostmanCollectionCache getCollectionCache() { + return collectionCache; + } + + @Override + protected void doStop() throws Exception { + super.doStop(); + collectionCache.clear(); + } + + public String getCollectionSource() { + return collectionSource; + } + + public void setCollectionSource(String collectionSource) { + this.collectionSource = collectionSource; + } + + public RestPostmanConfiguration getConfiguration() { + return configuration; + } + + /** + * The shared configuration used as the template for every endpoint created by this component. + */ + public void setConfiguration(RestPostmanConfiguration configuration) { + this.configuration = configuration; + } + + public RestPostmanProcessorStrategy getRestPostmanProcessorStrategy() { + return restPostmanProcessorStrategy; + } + + public void setRestPostmanProcessorStrategy(RestPostmanProcessorStrategy restPostmanProcessorStrategy) { + this.restPostmanProcessorStrategy = restPostmanProcessorStrategy; + } + + @Override + public boolean isUseGlobalSslContextParameters() { + return configuration.isUseGlobalSslContextParameters(); + } + + @Override + public void setUseGlobalSslContextParameters(boolean useGlobalSslContextParameters) { + configuration.setUseGlobalSslContextParameters(useGlobalSslContextParameters); + } + + public SSLContextParameters getSslContextParameters() { + return configuration.getSslContextParameters(); + } + + public void setSslContextParameters(SSLContextParameters sslContextParameters) { + configuration.setSslContextParameters(sslContextParameters); + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanConfiguration.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanConfiguration.java new file mode 100644 index 0000000000000..9b53c0dcdea05 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanConfiguration.java @@ -0,0 +1,424 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.camel.RuntimeCamelException; +import org.apache.camel.spi.Configurer; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.UriParam; +import org.apache.camel.spi.UriParams; +import org.apache.camel.support.jsse.SSLContextParameters; + +/** + * Configuration shared by the {@code rest-postman} component and its endpoints. + *

+ * This class deliberately does not generate a {@code toString}, because it holds the Postman API key. + */ +@Configurer(extended = true) +@UriParams +public class RestPostmanConfiguration implements Cloneable { + + public static final String DEFAULT_COLLECTION_SOURCE = "postman-collection.json"; + public static final String DEFAULT_POSTMAN_API_URL = "https://api.getpostman.com"; + public static final String DEFAULT_POSTMAN_API_KEY_HEADER = "X-Api-Key"; + public static final String DEFAULT_BASE_PATH = "/"; + + @UriParam(label = "common", enums = "auto,resource,cloud", defaultValue = "auto") + @Metadata(description = "How to interpret collectionSource. With auto, a bare collection UUID or" + + " {ownerId}-{uuid} is fetched from the Postman cloud and anything else is resolved as a" + + " resource (classpath:, file:, http:). Use resource or cloud to decide explicitly.", + defaultValue = "auto") + private String collectionSourceType = "auto"; + + @UriParam(label = "common") + @Metadata(description = "API basePath, for example \"`/v2`\". Default is unset, if set overrides the value" + + " derived from the request URL in the collection.") + private String basePath = ""; + + @UriParam(label = "common", prefix = "variable.", multiValue = true) + @Metadata(description = "Values for the {{variable}} placeholders used in the collection. These override the" + + " variables declared by the collection and its folders.") + private Map variables; + + @UriParam(label = "common,advanced") + @Metadata(description = "Whether to fail if a {{variable}} placeholder used by the selected request cannot be" + + " resolved. When false the placeholder is left as-is.") + private boolean failOnUnresolvedVariable; + + @UriParam(label = "producer") + @Metadata(description = "Scheme hostname and port to direct the HTTP requests to in the form of" + + " `http[s]://hostname[:port]`. If set overrides any value derived from the collection.") + private String host; + + @UriParam(label = "producer,advanced") + @Metadata(description = "Name of the Camel component that will perform the requests. The component must be" + + " present in Camel registry and it must implement RestProducerFactory service provider" + + " interface. If not set CLASSPATH is searched for single component that implements" + + " RestProducerFactory SPI.") + private String componentName; + + @UriParam(label = "producer,advanced") + @Metadata(description = "What payload type this component is capable of consuming. This equates to the value of" + + " the `Accept` HTTP header. A Postman collection does not describe responses, so unlike" + + " an OpenAPI specification there is nothing to infer this from and it is unset by" + + " default.") + private String consumes; + + @UriParam(label = "producer,advanced") + @Metadata(description = "What payload type this component is producing. This equates to the value of the" + + " `Content-Type` HTTP header. If not set it is inferred from the body mode of the" + + " request in the collection.") + private String produces; + + @UriParam(label = "producer,advanced", enums = "placeholder,literal", defaultValue = "placeholder") + @Metadata(description = "How to treat the query parameters declared in the collection. With placeholder the" + + " parameter names are bound to message headers and the values in the collection are" + + " ignored as sample data. With literal the values in the collection are sent as-is.", + defaultValue = "placeholder") + private String queryParameterMode = "placeholder"; + + @UriParam(label = "producer", defaultValue = "true") + @Metadata(description = "When the endpoint runs more than one request, that is when it selects a folder or the" + + " whole collection, whether to stop and fail on the first request that fails. When" + + " false every request is attempted and the failure is recorded in its result.", + defaultValue = "true") + private boolean runFailFast = true; + + @UriParam(label = "consumer,advanced") + @Metadata(description = "Name of the Camel component that will service the requests. The component must be" + + " present in Camel registry and it must be able to service contract-first REST" + + " consumers, as platform-http does. If not set CLASSPATH is searched for a single" + + " component with that capability.") + private String consumerComponentName; + + @UriParam(label = "consumer", enums = "fail,ignore,mock", defaultValue = "fail") + @Metadata(description = "Whether the consumer should fail, ignore or return a mock response for requests in the" + + " collection that are not mapped to a corresponding route.", + defaultValue = "fail") + private String missingRequest; + + @UriParam(label = "consumer,advanced", defaultValue = "classpath:camel-mock/**") + @Metadata(description = "Used for inclusive filtering of mock data from directories. The pattern is using" + + " Ant-path style pattern. Multiple patterns can be specified separated by comma." + + " Saved example responses in the collection are preferred over these files.", + defaultValue = "classpath:camel-mock/**") + private String mockIncludePattern = "classpath:camel-mock/**"; + + @UriParam(label = "consumer") + @Metadata(description = "Whether to enable validation of the client request. A Postman collection has no schemas," + + " so this is a best-effort check of required headers, query parameters and body" + + " presence only.") + private boolean clientRequestValidation; + + @UriParam(label = "consumer") + @Metadata(description = "Sets the context-path to use for servicing the Postman collection document. The document" + + " is served with all auth blocks and all secret variables removed.") + private String apiContextPath; + + @UriParam(label = "consumer,advanced") + @Metadata(description = "Filters which requests of the collection are used, as comma separated Ant-style patterns" + + " matched against the folder qualified request id. Prefix a pattern with ! to exclude.") + private String requestFilter; + + @UriParam(label = "consumer,security", displayName = "OAuth Profile") + @Metadata(description = "The OAuth profile to use for authenticating the incoming requests. The profile is" + + " enforced by the consumer component servicing the requests.") + private String oauthProfile; + + @UriParam(label = "security", security = "secret") + @Metadata(description = "The Postman API key used to fetch the collection from the Postman cloud. This" + + " credential authenticates against Postman itself and is never sent to the API the" + + " collection describes.", + security = "secret") + private String postmanApiKey; + + @UriParam(label = "security", defaultValue = DEFAULT_POSTMAN_API_KEY_HEADER) + @Metadata(description = "The HTTP header used to send the Postman API key when fetching a collection.", + defaultValue = DEFAULT_POSTMAN_API_KEY_HEADER) + private String postmanApiKeyHeader = DEFAULT_POSTMAN_API_KEY_HEADER; + + @UriParam(label = "security,advanced", defaultValue = DEFAULT_POSTMAN_API_URL) + @Metadata(description = "The base URL of the Postman API used to fetch collections. Must use https, except for" + + " localhost, because plain http would send the Postman API key in clear text.", + defaultValue = DEFAULT_POSTMAN_API_URL) + private String postmanApiUrl = DEFAULT_POSTMAN_API_URL; + + @UriParam(label = "security", enums = "ignore,header,fail", defaultValue = "ignore") + @Metadata(description = "What to do with the auth block the collection declares for the target API. With ignore" + + " the block is not applied, and a warning names the type that was found. With header" + + " the basic, bearer and apikey types are applied as a static header or query parameter," + + " and any other type fails at startup rather than silently sending no credential. With" + + " fail any auth block other than noauth is rejected.", + defaultValue = "ignore") + private String collectionAuth = "ignore"; + + @UriParam(label = "advanced", defaultValue = "15000") + @Metadata(description = "Connection timeout in milliseconds when fetching a collection from the Postman cloud.", + defaultValue = "15000") + private long connectTimeout = 15000; + + @UriParam(label = "advanced", defaultValue = "30000") + @Metadata(description = "Request timeout in milliseconds when fetching a collection from the Postman cloud.", + defaultValue = "30000") + private long requestTimeout = 30000; + + @UriParam(label = "advanced", defaultValue = "-1") + @Metadata(description = "How long a loaded collection is cached, in milliseconds. Use -1 to cache for the" + + " lifetime of the component.", + defaultValue = "-1") + private long collectionCacheTtl = -1; + + @UriParam(label = "security") + @Metadata(description = "Customize TLS parameters used by the component. If not set defaults to the TLS" + + " parameters set in the Camel context. These parameters are used both when fetching a" + + " collection from the Postman cloud and by the delegate producer.") + private SSLContextParameters sslContextParameters; + + @UriParam(label = "security", defaultValue = "false") + @Metadata(description = "Enable usage of global SSL context parameters.", defaultValue = "false") + private boolean useGlobalSslContextParameters; + + public String getCollectionSourceType() { + return collectionSourceType; + } + + public void setCollectionSourceType(String collectionSourceType) { + this.collectionSourceType = collectionSourceType; + } + + public String getBasePath() { + return basePath; + } + + public void setBasePath(String basePath) { + this.basePath = basePath; + } + + public Map getVariables() { + return variables; + } + + public void setVariables(Map variables) { + this.variables = variables; + } + + public boolean isFailOnUnresolvedVariable() { + return failOnUnresolvedVariable; + } + + public void setFailOnUnresolvedVariable(boolean failOnUnresolvedVariable) { + this.failOnUnresolvedVariable = failOnUnresolvedVariable; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = RestPostmanHelper.isHostParam(host); + } + + public String getComponentName() { + return componentName; + } + + public void setComponentName(String componentName) { + this.componentName = componentName; + } + + public String getConsumes() { + return consumes; + } + + public void setConsumes(String consumes) { + this.consumes = RestPostmanHelper.isMediaRange(consumes, "consumes"); + } + + public String getProduces() { + return produces; + } + + public void setProduces(String produces) { + this.produces = RestPostmanHelper.isMediaRange(produces, "produces"); + } + + public String getQueryParameterMode() { + return queryParameterMode; + } + + public void setQueryParameterMode(String queryParameterMode) { + this.queryParameterMode = queryParameterMode; + } + + public boolean isRunFailFast() { + return runFailFast; + } + + public void setRunFailFast(boolean runFailFast) { + this.runFailFast = runFailFast; + } + + public String getConsumerComponentName() { + return consumerComponentName; + } + + public void setConsumerComponentName(String consumerComponentName) { + this.consumerComponentName = consumerComponentName; + } + + public String getMissingRequest() { + return missingRequest; + } + + public void setMissingRequest(String missingRequest) { + this.missingRequest = missingRequest; + } + + public String getMockIncludePattern() { + return mockIncludePattern; + } + + public void setMockIncludePattern(String mockIncludePattern) { + this.mockIncludePattern = mockIncludePattern; + } + + public boolean isClientRequestValidation() { + return clientRequestValidation; + } + + public void setClientRequestValidation(boolean clientRequestValidation) { + this.clientRequestValidation = clientRequestValidation; + } + + public String getApiContextPath() { + return apiContextPath; + } + + public void setApiContextPath(String apiContextPath) { + this.apiContextPath = apiContextPath; + } + + public String getRequestFilter() { + return requestFilter; + } + + public void setRequestFilter(String requestFilter) { + this.requestFilter = requestFilter; + } + + public String getOauthProfile() { + return oauthProfile; + } + + public void setOauthProfile(String oauthProfile) { + this.oauthProfile = oauthProfile; + } + + public String getPostmanApiKey() { + return postmanApiKey; + } + + public void setPostmanApiKey(String postmanApiKey) { + this.postmanApiKey = postmanApiKey; + } + + public String getPostmanApiKeyHeader() { + return postmanApiKeyHeader; + } + + public void setPostmanApiKeyHeader(String postmanApiKeyHeader) { + this.postmanApiKeyHeader = postmanApiKeyHeader; + } + + public String getPostmanApiUrl() { + return postmanApiUrl; + } + + public void setPostmanApiUrl(String postmanApiUrl) { + this.postmanApiUrl = postmanApiUrl; + } + + public String getCollectionAuth() { + return collectionAuth; + } + + public void setCollectionAuth(String collectionAuth) { + this.collectionAuth = collectionAuth; + } + + public long getConnectTimeout() { + return connectTimeout; + } + + public void setConnectTimeout(long connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public long getRequestTimeout() { + return requestTimeout; + } + + public void setRequestTimeout(long requestTimeout) { + this.requestTimeout = requestTimeout; + } + + public long getCollectionCacheTtl() { + return collectionCacheTtl; + } + + public void setCollectionCacheTtl(long collectionCacheTtl) { + this.collectionCacheTtl = collectionCacheTtl; + } + + public SSLContextParameters getSslContextParameters() { + return sslContextParameters; + } + + public void setSslContextParameters(SSLContextParameters sslContextParameters) { + this.sslContextParameters = sslContextParameters; + } + + public boolean isUseGlobalSslContextParameters() { + return useGlobalSslContextParameters; + } + + public void setUseGlobalSslContextParameters(boolean useGlobalSslContextParameters) { + this.useGlobalSslContextParameters = useGlobalSslContextParameters; + } + + /** + * The endpoint level variable overrides, as plain strings. + */ + public Map variablesAsStrings() { + Map answer = new LinkedHashMap<>(); + if (variables != null) { + variables.forEach((key, value) -> answer.put(key, value != null ? value.toString() : "")); + } + return answer; + } + + public RestPostmanConfiguration copy() { + try { + return (RestPostmanConfiguration) clone(); + } catch (CloneNotSupportedException e) { + throw new RuntimeCamelException(e); + } + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanConstants.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanConstants.java new file mode 100644 index 0000000000000..04b647939f08e --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanConstants.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import org.apache.camel.spi.Metadata; + +/** + * Headers set by the {@code rest-postman} component. + */ +public final class RestPostmanConstants { + + @Metadata(description = "The id of the Postman request being invoked or serviced.", javaType = "String") + public static final String REQUEST_ID = "CamelRestPostmanRequestId"; + + @Metadata(description = "The name of the Postman request, as written in the collection.", javaType = "String") + public static final String REQUEST_NAME = "CamelRestPostmanRequestName"; + + @Metadata(description = "The folder path of the Postman request, with folders separated by a slash.", + javaType = "String") + public static final String FOLDER_PATH = "CamelRestPostmanFolderPath"; + + @Metadata(description = "The number of requests executed when running a folder or a whole collection.", + javaType = "Integer") + public static final String REQUEST_COUNT = "CamelRestPostmanRequestCount"; + + @Metadata(description = "The number of requests that failed when running a folder or a whole collection with" + + " runFailFast disabled.", + javaType = "Integer") + public static final String FAILED_COUNT = "CamelRestPostmanFailedCount"; + + private RestPostmanConstants() { + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanConsumerPath.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanConsumerPath.java new file mode 100644 index 0000000000000..2c4a1139fc044 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanConsumerPath.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import org.apache.camel.component.rest.postman.support.PostmanRequestBinding; +import org.apache.camel.support.RestConsumerContextPathMatcher; +import org.apache.camel.support.processor.RestBindingAdvice; + +/** + * One servable request of the collection, as the context path matcher sees it. + */ +class RestPostmanConsumerPath implements RestConsumerContextPathMatcher.ConsumerPath { + + private final String verb; + private final String path; + private final PostmanRequestBinding consumer; + private final RestBindingAdvice binding; + private final String dispatchId; + + RestPostmanConsumerPath(String verb, String path, PostmanRequestBinding consumer, RestBindingAdvice binding, + String dispatchId) { + this.verb = verb; + this.path = path; + this.consumer = consumer; + this.binding = binding; + this.dispatchId = dispatchId; + } + + @Override + public String getRestrictMethod() { + return verb; + } + + @Override + public String getConsumerPath() { + return path; + } + + @Override + public PostmanRequestBinding getConsumer() { + return consumer; + } + + @Override + public boolean isMatchOnUriPrefix() { + return false; + } + + public RestBindingAdvice getBinding() { + return binding; + } + + /** + * The {@code direct} endpoint name this request routes to, resolved at startup against the routes that exist. + */ + public String getDispatchId() { + return dispatchId; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanEndpoint.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanEndpoint.java new file mode 100644 index 0000000000000..388aa8364120d --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanEndpoint.java @@ -0,0 +1,539 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.net.ssl.SSLContext; + +import org.apache.camel.CamelContextAware; +import org.apache.camel.Category; +import org.apache.camel.Component; +import org.apache.camel.Consumer; +import org.apache.camel.Endpoint; +import org.apache.camel.ExchangePattern; +import org.apache.camel.NoSuchBeanException; +import org.apache.camel.Processor; +import org.apache.camel.Producer; +import org.apache.camel.RuntimeCamelException; +import org.apache.camel.component.platform.http.spi.PlatformHttpConsumerAware; +import org.apache.camel.component.rest.postman.collection.PostmanCloudClient; +import org.apache.camel.component.rest.postman.collection.PostmanCollectionLoader; +import org.apache.camel.component.rest.postman.model.PostmanCollection; +import org.apache.camel.component.rest.postman.model.PostmanItem; +import org.apache.camel.component.rest.postman.support.PostmanRedactor; +import org.apache.camel.component.rest.postman.support.PostmanRequestBinding; +import org.apache.camel.component.rest.postman.support.PostmanRequestIndex; +import org.apache.camel.component.rest.postman.support.PostmanRequestMapper; +import org.apache.camel.spi.InternalProcessor; +import org.apache.camel.spi.RestConfiguration; +import org.apache.camel.spi.RestOpenApiConsumerFactory; +import org.apache.camel.spi.UriEndpoint; +import org.apache.camel.spi.UriParam; +import org.apache.camel.spi.UriPath; +import org.apache.camel.support.CamelContextHelper; +import org.apache.camel.support.DefaultEndpoint; +import org.apache.camel.support.processor.RestBindingAdvice; +import org.apache.camel.util.UnsafeUriCharactersEncoder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.apache.camel.util.ObjectHelper.isNotEmpty; +import static org.apache.camel.util.StringHelper.after; +import static org.apache.camel.util.StringHelper.before; + +/** + * To call and expose REST services using a Postman Collection as contract. + */ +@UriEndpoint(firstVersion = "4.22.0", scheme = "rest-postman", title = "REST Postman", + syntax = "rest-postman:collectionSource#requestId", category = { Category.REST, Category.API }, + headersClass = RestPostmanConstants.class) +public class RestPostmanEndpoint extends DefaultEndpoint { + + private static final Logger LOG = LoggerFactory.getLogger(RestPostmanEndpoint.class); + + public static final String[] DEFAULT_REST_POSTMAN_CONSUMER_COMPONENTS = new String[] { "platform-http" }; + + /** + * Parameters of the endpoint URI that were not consumed as options, kept so that they can be used as literal path + * or query values. + */ + Map parameters = Collections.emptyMap(); + + @UriPath(description = "The Postman Collection to use. Either a resource URI of a Collection v2.1 JSON document" + + " (classpath:, file: or http:), or the uid of a collection to fetch from the Postman" + + " cloud, which requires postmanApiKey.", + defaultValue = RestPostmanConfiguration.DEFAULT_COLLECTION_SOURCE, + defaultValueNote = "By default loads the postman-collection.json file") + private String collectionSource; + + @UriPath(description = "The request to invoke, identified by its id in the collection or by its slugified name," + + " for example getUserById. Use a folder id to run every request in that folder, and" + + " leave it out to run the whole collection. Append a slash to force a folder match when" + + " a request and a folder share a name.", + label = "producer") + private String requestId; + + @UriParam + private RestPostmanConfiguration configuration = new RestPostmanConfiguration(); + + private RestPostmanProcessor postmanProcessor; + + public RestPostmanEndpoint(String uri, String remaining, RestPostmanComponent component, + Map parameters) { + super(uri, component); + + if (remaining != null && remaining.contains("#")) { + String fragment = after(remaining, "#"); + // an empty fragment means the same as no fragment: the whole collection + requestId = fragment != null && !fragment.isEmpty() ? fragment : null; + String source = before(remaining, "#"); + if (source != null && !source.isEmpty()) { + collectionSource = source; + } + } else if (remaining != null && !remaining.isEmpty()) { + if (looksLikeCollectionSource(remaining)) { + collectionSource = remaining; + } else { + requestId = remaining; + } + } + + if (collectionSource == null) { + collectionSource = component.getCollectionSource(); + } + if (collectionSource == null) { + collectionSource = RestPostmanConfiguration.DEFAULT_COLLECTION_SOURCE; + } + + this.parameters = parameters; + setExchangePattern(ExchangePattern.InOut); + } + + /** + * Distinguishes {@code rest-postman:my-api.json} from {@code rest-postman:getUserById} when no {@code #} was given. + */ + private static boolean looksLikeCollectionSource(String remaining) { + return remaining.endsWith(".json") + || remaining.contains(":") + || PostmanCollectionLoader.isCloudSource(remaining, PostmanCollectionLoader.SOURCE_TYPE_AUTO); + } + + @Override + public RestPostmanComponent getComponent() { + return (RestPostmanComponent) super.getComponent(); + } + + @Override + public boolean isLenientProperties() { + // unknown URI parameters are literal path or query values rather than mistakes + return true; + } + + @Override + public Producer createProducer() throws Exception { + PostmanRequestIndex index = buildIndex(); + PostmanRequestIndex.Selection selection = index.resolve(requestId); + + List bindings = mapAll(selection.items()); + + if (selection.single()) { + PostmanRequestBinding binding = bindings.get(0); + Endpoint delegate = createDelegateEndpoint(binding); + return new RestPostmanProducer(delegate.createProducer(), binding.host() != null, binding); + } + + // a folder or the whole collection: every request is run in turn, like Postman's collection runner + List prepared = new ArrayList<>(bindings.size()); + for (PostmanRequestBinding binding : bindings) { + prepared.add(new RestPostmanRunnerProducer.PreparedRequest( + binding, createDelegateEndpoint(binding).createProducer())); + } + LOG.debug("Postman endpoint {} will run {} request(s) for {}", getEndpointUri(), prepared.size(), + selection.description()); + return new RestPostmanRunnerProducer(this, prepared, configuration.isRunFailFast(), selection.description()); + } + + @Override + public Consumer createConsumer(Processor processor) throws Exception { + PostmanCollection collection = loadCollection(); + PostmanRequestIndex index = PostmanRequestIndex.build(collection, configuration.getRequestFilter()); + PostmanRequestIndex.Selection selection = index.resolve(requestId); + List bindings = mapAll(selection.items()); + + String path = determineConsumerBasePath(bindings); + + RestPostmanProcessorStrategy strategy = getComponent().createProcessorStrategy(); + // these are endpoint level options, so they have to come from this endpoint's configuration rather than + // from the component wide template the strategy was built with + if (configuration.getMissingRequest() != null) { + strategy.setMissingRequest(configuration.getMissingRequest()); + } + if (configuration.getMockIncludePattern() != null) { + strategy.setMockIncludePattern(configuration.getMockIncludePattern()); + } + + RestPostmanProcessor restPostmanProcessor = new RestPostmanProcessor( + bindings, PostmanRedactor.redact(collection.getJson()), collectionSource, path, + configuration.getApiContextPath(), configuration.isClientRequestValidation(), strategy); + CamelContextAware.trySetCamelContext(restPostmanProcessor, getCamelContext()); + this.postmanProcessor = restPostmanProcessor; + + // the per-request binding advice replaces the stock one, exactly as the OpenAPI equivalent does + if (processor instanceof InternalProcessor ip) { + RestBindingAdvice advice = ip.getAdvice(RestBindingAdvice.class); + if (advice != null) { + ip.removeAdvice(advice); + } + ip.addAdvice(new RestPostmanProcessorAdvice(restPostmanProcessor)); + } + + Consumer consumer = createConsumerFor(path, restPostmanProcessor, processor); + restPostmanProcessor.setConsumer(consumer); + if (consumer instanceof PlatformHttpConsumerAware phca) { + phca.registerAfterConfigured(restPostmanProcessor); + } + return consumer; + } + + /** + * A consumer serves one context path, but a collection can produce a different base path per request when its + * folders use different base URLs. The first one wins, and the rest are reported, because silently serving them on + * someone else's context path would be a confusing way to fail. + */ + private String determineConsumerBasePath(List bindings) { + if (bindings.isEmpty()) { + return RestPostmanConfiguration.DEFAULT_BASE_PATH; + } + String path = bindings.get(0).basePath(); + Set others = bindings.stream() + .map(PostmanRequestBinding::basePath) + .filter(other -> !path.equals(other)) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (!others.isEmpty()) { + LOG.warn("Postman collection {} yields more than one base path ({} and {}). Serving everything under" + + " {}; set the basePath option to choose explicitly.", + collectionSource, path, String.join(", ", others), path); + } + return path; + } + + private Consumer createConsumerFor(String basePath, RestPostmanProcessor restPostmanProcessor, Processor processor) + throws Exception { + RestOpenApiConsumerFactory factory = null; + String cname = null; + + if (configuration.getConsumerComponentName() != null) { + Object comp = getCamelContext().getRegistry().lookupByName(configuration.getConsumerComponentName()); + if (comp instanceof RestOpenApiConsumerFactory rcf) { + factory = rcf; + } else { + comp = getCamelContext().getComponent(configuration.getConsumerComponentName()); + if (comp instanceof RestOpenApiConsumerFactory rcf) { + factory = rcf; + } + } + if (factory == null) { + if (comp != null) { + throw new IllegalArgumentException( + "Component " + configuration.getConsumerComponentName() + + " is not a RestOpenApiConsumerFactory"); + } + throw new NoSuchBeanException( + configuration.getConsumerComponentName(), RestOpenApiConsumerFactory.class.getName()); + } + cname = configuration.getConsumerComponentName(); + } + + if (factory == null) { + for (String name : getCamelContext().getComponentNames()) { + Component comp = getCamelContext().getComponent(name); + if (comp instanceof RestOpenApiConsumerFactory rcf) { + factory = rcf; + cname = name; + break; + } + } + } + + if (factory == null) { + for (String name : DEFAULT_REST_POSTMAN_CONSUMER_COMPONENTS) { + Object comp = getCamelContext().getComponent(name, true); + if (comp instanceof RestOpenApiConsumerFactory rcf) { + LOG.debug("Auto discovered {} as RestOpenApiConsumerFactory", name); + factory = rcf; + cname = name; + break; + } + } + } + + if (factory == null) { + Set factories + = getCamelContext().getRegistry().findByType(RestOpenApiConsumerFactory.class); + if (factories != null && factories.size() == 1) { + factory = factories.iterator().next(); + } + } + + if (factory == null) { + throw new IllegalStateException( + "Cannot find RestOpenApiConsumerFactory in Registry or as a Component to use"); + } + + // fail closed: never start an unprotected consumer when oauthProfile is configured but the delegate + // factory does not declare that its consumers enforce it + if (isNotEmpty(configuration.getOauthProfile()) && !factory.supportsOAuthProfile()) { + throw new IllegalArgumentException( + "The oauthProfile option is not supported by the resolved RestOpenApiConsumerFactory (" + + factory.getClass().getName() + + "); select a consumer component that enforces oauthProfile"); + } + + RestConfiguration config = CamelContextHelper.getRestConfiguration(getCamelContext(), cname); + Map copy = new HashMap<>(parameters); + if (isNotEmpty(configuration.getOauthProfile())) { + copy.put("oauthProfile", configuration.getOauthProfile()); + } + String contextPath = basePath; + if (contextPath.equals(config.getContextPath())) { + contextPath = ""; + } + + Consumer consumer = factory.createConsumer(getCamelContext(), processor, contextPath, config, copy); + if (consumer instanceof PlatformHttpConsumerAware phca) { + restPostmanProcessor.setPlatformHttpConsumer(phca); + } + configureConsumer(consumer); + return consumer; + } + + /** + * Builds the {@code rest} endpoint that actually performs the call. + *

+ * Every option that distinguishes one request from another goes into the URI string, because endpoints are cached + * by URI and two requests differing only in, say, host must not share one (see CAMEL-24113). + */ + private Endpoint createDelegateEndpoint(PostmanRequestBinding binding) { + Endpoint delegate = getCamelContext().getEndpoint(buildDelegateUri(binding)); + delegate.configureProperties(determineEndpointParameters(binding)); + return delegate; + } + + /** + * Builds the URI of the delegate {@code rest} endpoint. + *

+ * Every option that distinguishes one request from another is part of the URI, because endpoints are cached by URI + * and two requests differing only in, say, host must not end up sharing one (CAMEL-24113). + */ + String buildDelegateUri(PostmanRequestBinding binding) { + String uri = "rest:" + binding.method() + ":" + binding.basePath() + ":" + binding.uriTemplate(); + + StringBuilder query = new StringBuilder(); + appendQuery(query, "host", binding.host()); + appendQuery(query, "producerComponentName", configuration.getComponentName()); + appendQuery(query, "consumes", binding.consumes()); + appendQuery(query, "produces", binding.produces()); + if (binding.queryParameters() != null) { + appendQuery(query, "queryParameters", UnsafeUriCharactersEncoder.encode(binding.queryParameters())); + } + if (!query.isEmpty()) { + uri = uri + "?" + query; + } + return uri; + } + + private static void appendQuery(StringBuilder query, String name, String value) { + if (value == null) { + return; + } + if (!query.isEmpty()) { + query.append('&'); + } + query.append(name).append('=').append(value); + } + + private Map determineEndpointParameters(PostmanRequestBinding binding) { + Map answer = new LinkedHashMap<>(); + if (binding.host() != null) { + answer.put("host", binding.host()); + } + if (configuration.getComponentName() != null) { + answer.put("producerComponentName", configuration.getComponentName()); + } + if (binding.consumes() != null) { + answer.put("consumes", binding.consumes()); + } + if (binding.produces() != null) { + answer.put("produces", binding.produces()); + } + if (binding.queryParameters() != null) { + answer.put("queryParameters", binding.queryParameters()); + } + + Map nested = new LinkedHashMap<>(); + Map componentOptions = new LinkedHashMap<>(); + componentOptions.put("useGlobalSslContextParameters", configuration.isUseGlobalSslContextParameters()); + if (configuration.getSslContextParameters() != null) { + componentOptions.put("sslContextParameters", configuration.getSslContextParameters()); + } + nested.put("component", componentOptions); + + // leftover URI parameters are literal values, except where they name a path parameter, which would then be + // sent both in the path and as a query parameter + for (Map.Entry entry : parameters.entrySet()) { + if (!binding.defaultPathValues().containsKey(entry.getKey()) + && !binding.uriTemplate().contains("{" + entry.getKey() + "}")) { + nested.put(entry.getKey(), entry.getValue()); + } + } + answer.put("parameters", nested); + return answer; + } + + private PostmanRequestIndex buildIndex() { + return PostmanRequestIndex.build(loadCollection(), configuration.getRequestFilter()); + } + + /** + * Resolves the URI fragment and maps every selected request, without creating any producer. Exposed so that the + * mapping can be asserted without an HTTP backend. + */ + List resolveBindings() { + return mapAll(buildIndex().resolve(requestId).items()); + } + + private List mapAll(List items) { + PostmanRequestMapper mapper = new PostmanRequestMapper( + getCamelContext(), configuration, configuration.variablesAsStrings(), resourceOrigin()); + List answer = new ArrayList<>(items.size()); + for (PostmanItem item : items) { + answer.add(mapper.map(item)); + } + return answer; + } + + /** + * Loads the collection, through the component wide cache so that many endpoints over one collection cause one read, + * and for cloud sources one API call. + */ + PostmanCollection loadCollection() { + boolean cloud = PostmanCollectionLoader.isCloudSource(collectionSource, configuration.getCollectionSourceType()); + return getComponent().getCollectionCache().get( + collectionSource, configuration.getPostmanApiKey(), configuration.getCollectionCacheTtl(), + () -> cloud + ? PostmanCollectionLoader.loadFromCloud(createCloudClient(), collectionSource) + : PostmanCollectionLoader.loadFromResource(getCamelContext(), collectionSource)); + } + + private PostmanCloudClient createCloudClient() { + PostmanCloudClient.validateApiUrl(configuration.getPostmanApiUrl()); + if (configuration.getPostmanApiKey() == null || configuration.getPostmanApiKey().isEmpty()) { + throw new IllegalArgumentException( + "postmanApiKey is required to fetch collection " + collectionSource + " from the Postman cloud." + + " Set collectionSourceType=resource if this is meant to be a local" + + " file rather than a collection uid."); + } + return new PostmanCloudClient( + configuration.getPostmanApiUrl(), + configuration.getPostmanApiKey(), + configuration.getPostmanApiKeyHeader(), + Duration.ofMillis(configuration.getConnectTimeout()), + Duration.ofMillis(configuration.getRequestTimeout()), + resolveSslContext()); + } + + private SSLContext resolveSslContext() { + try { + if (configuration.getSslContextParameters() != null) { + return configuration.getSslContextParameters().createSSLContext(getCamelContext()); + } + if (configuration.isUseGlobalSslContextParameters() + && getCamelContext().getSSLContextParameters() != null) { + return getCamelContext().getSSLContextParameters().createSSLContext(getCamelContext()); + } + } catch (Exception e) { + throw new RuntimeCamelException("Cannot create SSLContext for fetching the Postman collection", e); + } + return null; + } + + /** + * The scheme and authority of the collection resource when it was loaded over HTTP, usable as a last resort for the + * target host. + *

+ * Deliberately {@code null} for cloud sources: their origin is {@code api.getpostman.com}, which is emphatically + * not the API the collection describes. + */ + private String resourceOrigin() { + if (PostmanCollectionLoader.isCloudSource(collectionSource, configuration.getCollectionSourceType())) { + return null; + } + if (!collectionSource.startsWith("http://") && !collectionSource.startsWith("https://")) { + return null; + } + try { + URI uri = new URI(collectionSource); + StringBuilder answer = new StringBuilder(uri.getScheme()).append("://").append(uri.getHost()); + if (uri.getPort() > 0) { + answer.append(':').append(uri.getPort()); + } + return answer.toString(); + } catch (Exception e) { + LOG.debug("Cannot derive an origin from collection source {}", collectionSource, e); + return null; + } + } + + public String getCollectionSource() { + return collectionSource; + } + + public void setCollectionSource(String collectionSource) { + this.collectionSource = collectionSource; + } + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + public RestPostmanConfiguration getConfiguration() { + return configuration; + } + + public void setConfiguration(RestPostmanConfiguration configuration) { + this.configuration = configuration; + } + + RestPostmanProcessor getPostmanProcessor() { + return postmanProcessor; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanHelper.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanHelper.java new file mode 100644 index 0000000000000..d9fc1336bc54d --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanHelper.java @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.text.Normalizer; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.camel.spi.RestConfiguration; +import org.apache.camel.util.StringHelper; + +/** + * Static helpers shared by the component, endpoint and consumer. + */ +public final class RestPostmanHelper { + + private static final Pattern HOST_PATTERN = Pattern.compile("https?://[^:/]+(:\\d+)?", Pattern.CASE_INSENSITIVE); + + private static final Pattern UUID_PATTERN = Pattern.compile( + "(?:[0-9]+-)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"); + + /** + * An HTTP method, constrained so that a hostile or malformed collection cannot inject extra segments into the colon + * delimited {@code rest:} URI that is built from it. + */ + private static final Pattern METHOD_PATTERN = Pattern.compile("[A-Za-z]{1,20}"); + + /** + * Characters that would break out of a path segment and corrupt the delegated endpoint URI. + */ + private static final Pattern UNSAFE_SEGMENT = Pattern.compile("[?#&:]"); + + private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^a-zA-Z0-9]+"); + private static final Pattern DIACRITICS = Pattern.compile("\\p{InCombiningDiacriticalMarks}+"); + + private RestPostmanHelper() { + } + + public static String isMediaRange(final String given, final String name) { + return StringHelper.notEmpty(given, name); + } + + /** + * Validates that a host option is an absolute URI naming only a scheme, host and optional port. + */ + public static String isHostParam(final String given) { + final String hostUri = StringHelper.notEmpty(given, "host"); + + final Matcher matcher = HOST_PATTERN.matcher(given); + if (!matcher.matches()) { + throw new IllegalArgumentException( + "host must be an absolute URI (e.g. http://api.example.com), given: `" + hostUri + "`"); + } + return hostUri; + } + + /** + * Whether the text is a Postman UUID, or the {@code {ownerId}-{uuid}} uid form. + */ + public static boolean isUuid(String text) { + return text != null && UUID_PATTERN.matcher(text).matches(); + } + + /** + * Validates an HTTP method read from a collection. + */ + public static String validateMethod(String method, String itemDescription) { + if (method == null || !METHOD_PATTERN.matcher(method).matches()) { + throw new IllegalArgumentException( + "Postman request " + itemDescription + " declares an invalid HTTP method: " + method); + } + return method.toUpperCase(Locale.ROOT); + } + + /** + * Validates a path segment after variable substitution. + *

+ * Substituted values can originate outside the collection, by way of the {@code variables} option and Camel + * property placeholders, so they are checked before being concatenated into the delegated endpoint URI. + */ + public static String validatePathSegment(String segment, String itemDescription) { + if (UNSAFE_SEGMENT.matcher(segment).find()) { + throw new IllegalArgumentException( + "Postman request " + itemDescription + " has a URL path segment containing one of ? # & :" + + " after variable substitution, which cannot be expressed as a REST" + + " endpoint: `" + segment + "`"); + } + return segment; + } + + /** + * Converts a Postman item name into a camel case identifier usable in an endpoint URI, for example + * {@code "Get User By Id"} becomes {@code getUserById}. + * + * @param name the item name, may be {@code null} or empty + * @param fallback used when the name yields no usable characters at all + * @return the slug + */ + public static String slugify(String name, String fallback) { + if (name == null || name.isBlank()) { + return fallback; + } + // fold accented characters down to ASCII so that "Créer Utilisateur" yields "creerUtilisateur" + String normalized = Normalizer.normalize(name, Normalizer.Form.NFD); + normalized = DIACRITICS.matcher(normalized).replaceAll(""); + + String[] tokens = NON_ALPHANUMERIC.split(normalized); + StringBuilder answer = new StringBuilder(); + for (String token : tokens) { + if (token.isEmpty()) { + continue; + } + if (answer.isEmpty()) { + answer.append(token.toLowerCase(Locale.ROOT)); + } else { + answer.append(Character.toUpperCase(token.charAt(0))); + if (token.length() > 1) { + answer.append(token.substring(1).toLowerCase(Locale.ROOT)); + } + } + } + if (answer.isEmpty()) { + return fallback; + } + // an identifier must not start with a digit, so that it stays usable as a bean or route id + if (Character.isDigit(answer.charAt(0))) { + answer.insert(0, 'r'); + } + return answer.toString(); + } + + /** + * Builds the placeholder expression that {@code camel-rest} resolves per exchange, from a message header falling + * back to an exchange variable. + * + * @param name the parameter name + * @param required when {@code false} the parameter is dropped if it cannot be resolved + */ + public static String queryParameterExpression(String name, boolean required) { + return name + "={" + name + (required ? "" : "?") + "}"; + } + + /** + * Derives a host from the REST configuration, skipping the default port for the scheme. + * + * @return the host, or {@code null} when the configuration does not define one + */ + public static String hostFrom(final RestConfiguration restConfiguration) { + if (restConfiguration == null) { + return null; + } + + final String scheme = restConfiguration.getScheme(); + final String host = restConfiguration.getHost(); + final int port = restConfiguration.getPort(); + + if (scheme == null || host == null) { + return null; + } + + final StringBuilder answer = new StringBuilder(scheme).append("://").append(host); + if (port > 0 && !("http".equalsIgnoreCase(scheme) && port == 80) + && !("https".equalsIgnoreCase(scheme) && port == 443)) { + answer.append(':').append(port); + } + return answer.toString(); + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessor.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessor.java new file mode 100644 index 0000000000000..23aa3c49692f8 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessor.java @@ -0,0 +1,298 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.apache.camel.AfterPropertiesConfigured; +import org.apache.camel.AsyncCallback; +import org.apache.camel.CamelContext; +import org.apache.camel.CamelContextAware; +import org.apache.camel.Consumer; +import org.apache.camel.Exchange; +import org.apache.camel.RouteAware; +import org.apache.camel.RuntimeCamelException; +import org.apache.camel.component.platform.http.spi.PlatformHttpConsumerAware; +import org.apache.camel.component.rest.postman.model.PostmanKeyValue; +import org.apache.camel.component.rest.postman.model.PostmanResponse; +import org.apache.camel.component.rest.postman.support.PostmanRequestBinding; +import org.apache.camel.http.base.HttpHelper; +import org.apache.camel.spi.RestConfiguration; +import org.apache.camel.spi.RestRegistry; +import org.apache.camel.support.AsyncProcessorSupport; +import org.apache.camel.support.PluginHelper; +import org.apache.camel.support.RestConsumerContextPathMatcher; +import org.apache.camel.support.processor.RestBindingAdvice; +import org.apache.camel.support.processor.RestBindingAdviceFactory; +import org.apache.camel.support.processor.RestBindingConfiguration; +import org.apache.camel.support.service.ServiceHelper; +import org.apache.camel.util.json.JsonObject; + +/** + * Routes incoming HTTP requests to the route that implements the matching request of a Postman collection. + */ +public class RestPostmanProcessor extends AsyncProcessorSupport implements CamelContextAware, AfterPropertiesConfigured { + + private static final List METHODS = Arrays.asList("GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"); + + private CamelContext camelContext; + private final List bindings; + private final JsonObject redactedDocument; + private final String collectionSource; + private final String basePath; + private final String apiContextPath; + private final boolean clientRequestValidation; + private final RestPostmanProcessorStrategy strategy; + private final List> paths = new ArrayList<>(); + private PlatformHttpConsumerAware platformHttpConsumer; + private Consumer consumer; + private RestRegistry restRegistry; + + public RestPostmanProcessor(List bindings, JsonObject redactedDocument, + String collectionSource, String basePath, String apiContextPath, + boolean clientRequestValidation, RestPostmanProcessorStrategy strategy) { + this.bindings = List.copyOf(bindings); + this.redactedDocument = redactedDocument; + this.collectionSource = collectionSource; + this.basePath = basePath; + this.apiContextPath + = apiContextPath != null && !apiContextPath.startsWith("/") ? "/" + apiContextPath : apiContextPath; + this.clientRequestValidation = clientRequestValidation; + this.strategy = strategy; + } + + @Override + public boolean process(Exchange exchange, AsyncCallback callback) { + String path = exchange.getMessage().getHeader(Exchange.HTTP_PATH, String.class); + if (path != null && path.startsWith(basePath)) { + path = path.substring(basePath.length()); + } + String verb = exchange.getMessage().getHeader(Exchange.HTTP_METHOD, String.class); + + RestConsumerContextPathMatcher.ConsumerPath match + = RestConsumerContextPathMatcher.matchBestPath(verb, path, paths); + if (match instanceof RestPostmanConsumerPath rcp) { + PostmanRequestBinding binding = rcp.getConsumer(); + String consumerPath = rcp.getConsumerPath(); + if (consumerPath.startsWith("/") && path != null && !path.startsWith("/")) { + consumerPath = consumerPath.substring(1); + } + + // turn the {name} markers of the matched template into message headers + HttpHelper.evalPlaceholders(exchange.getMessage().getHeaders(), path, consumerPath); + + if (restRegistry != null) { + restRegistry.hit(verb, basePath, consumerPath); + } + return strategy.process(binding, rcp.getDispatchId(), verb, path, rcp.getBinding(), exchange, callback); + } + + if (path != null && path.equals(apiContextPath)) { + return strategy.processCollectionDocument(redactedDocument, exchange, callback); + } + + // neither a known request nor the api context path: distinguish "no such path" from "wrong method" + final String contextPath = path; + List allow = METHODS.stream() + .filter(v -> RestConsumerContextPathMatcher.matchBestPath(v, contextPath, paths) != null).toList(); + if (allow.isEmpty()) { + exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 404); + } else { + exchange.getMessage().setHeader(Exchange.HTTP_RESPONSE_CODE, 405); + exchange.getMessage().setHeader("Allow", String.join(", ", allow)); + } + exchange.setRouteStop(true); + callback.done(true); + return true; + } + + @Override + protected void doInit() throws Exception { + super.doInit(); + CamelContextAware.trySetCamelContext(strategy, getCamelContext()); + } + + @Override + public void afterPropertiesConfigured(CamelContext camelContext) { + this.restRegistry = PluginHelper.getRestRegistry(camelContext); + + String routeId = consumer instanceof RouteAware ra ? ra.getRoute().getRouteId() : null; + + for (PostmanRequestBinding binding : bindings) { + RestBindingConfiguration bc = createRestBindingConfiguration(binding); + + String url = basePath + binding.uriTemplate(); + if (platformHttpConsumer != null) { + url = platformHttpConsumer.getPlatformHttpConsumer().getEndpoint().getServiceUrl() + url; + } + restRegistry.addRestService(consumer, true, url, binding.uriTemplate(), basePath, null, + binding.method(), bc.getConsumes(), bc.getProduces(), null, null, routeId, + binding.id(), collectionSource, binding.item().getRequest().getDescription()); + + try { + RestBindingAdvice advice = RestBindingAdviceFactory.build(camelContext, bc); + ServiceHelper.buildService(advice); + paths.add(new RestPostmanConsumerPath( + binding.method(), binding.uriTemplate(), binding, advice, + strategy.resolveDispatchId(binding))); + } catch (Exception e) { + throw RuntimeCamelException.wrapRuntimeCamelException(e); + } + } + + if (apiContextPath != null) { + restRegistry.addRestSpecification(consumer, true, basePath + apiContextPath, apiContextPath, basePath, + "GET", "application/json", null); + } + + for (var p : paths) { + if (p instanceof RestPostmanConsumerPath rcp) { + ServiceHelper.startService(rcp.getBinding()); + } + } + + ServiceHelper.initService(strategy); + try { + strategy.validateCollection(bindings, basePath, platformHttpConsumer); + } catch (Exception e) { + throw RuntimeCamelException.wrapRuntimeCamelException(e); + } + ServiceHelper.startService(strategy); + } + + /** + * Builds the binding configuration for one request. + *

+ * A Postman collection carries no schemas, so unlike the OpenAPI equivalent there are no Java types to bind to and + * {@code type}/{@code outType} are deliberately left unset. What remains is a best-effort notion of which headers, + * query parameters and body are required, inferred from what the collection actually declares. + */ + private RestBindingConfiguration createRestBindingConfiguration(PostmanRequestBinding binding) { + RestConfiguration config = camelContext.getRestConfiguration(); + + RestBindingConfiguration bc = new RestBindingConfiguration(); + bc.setBindingMode(config.getBindingMode().name()); + bc.setEnableCORS(config.isEnableCORS()); + bc.setCorsHeaders(config.getCorsHeaders()); + bc.setClientRequestValidation(config.isClientRequestValidation() || clientRequestValidation); + bc.setEnableNoContentResponse(config.isEnableNoContentResponse()); + bc.setSkipBindingOnErrorCode(config.isSkipBindingOnErrorCode()); + bc.setConsumes(binding.produces()); + bc.setProduces(producesOf(binding)); + bc.setRequiredBody(binding.collectionBody() != null + && DefaultRestPostmanProcessorStrategy.expectsBody(binding.method())); + bc.setRequiredQueryParameters(requiredQueryParameters(binding)); + bc.setRequiredHeaders(requiredHeaders(binding)); + bc.setQueryDefaultValues(queryDefaultValues(binding)); + return bc; + } + + /** + * What the server would return, taken from the first saved example, since the collection describes no schemas. + */ + private static String producesOf(PostmanRequestBinding binding) { + return binding.item().getSavedResponses().stream() + .map(PostmanResponse::getContentType) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + + private static Set requiredQueryParameters(PostmanRequestBinding binding) { + // Postman has no "required" flag, so a parameter the author bothered to give a value to is the closest signal + Set answer = new LinkedHashSet<>(); + for (PostmanKeyValue param : binding.item().getRequest().getUrl().getQueryParams()) { + if (param.hasValue()) { + answer.add(param.key()); + } + } + return answer.isEmpty() ? null : answer; + } + + private static Set requiredHeaders(PostmanRequestBinding binding) { + Set answer = new LinkedHashSet<>(); + for (PostmanKeyValue header : binding.item().getRequest().getHeaders()) { + if (header.disabled()) { + continue; + } + String key = header.key(); + // Content-Type and Accept are negotiated rather than required, and an auth header is supplied by the + // caller's own credentials rather than by the collection + if ("Content-Type".equalsIgnoreCase(key) || "Accept".equalsIgnoreCase(key) + || "Authorization".equalsIgnoreCase(key) || "Host".equalsIgnoreCase(key)) { + continue; + } + answer.add(key); + } + return answer.isEmpty() ? null : answer; + } + + private static Map queryDefaultValues(PostmanRequestBinding binding) { + Map answer = new LinkedHashMap<>(); + for (PostmanKeyValue param : binding.item().getRequest().getUrl().getQueryParams()) { + if (param.hasValue()) { + answer.put(param.key(), param.value()); + } + } + return answer.isEmpty() ? null : answer; + } + + @Override + protected void doStop() throws Exception { + super.doStop(); + ServiceHelper.stopService(strategy); + for (var p : paths) { + if (p instanceof RestPostmanConsumerPath rcp) { + ServiceHelper.stopService(rcp.getBinding()); + } + } + paths.clear(); + } + + @Override + public CamelContext getCamelContext() { + return camelContext; + } + + @Override + public void setCamelContext(CamelContext camelContext) { + this.camelContext = camelContext; + } + + public PlatformHttpConsumerAware getPlatformHttpConsumer() { + return platformHttpConsumer; + } + + public void setPlatformHttpConsumer(PlatformHttpConsumerAware platformHttpConsumer) { + this.platformHttpConsumer = platformHttpConsumer; + } + + public Consumer getConsumer() { + return consumer; + } + + public void setConsumer(Consumer consumer) { + this.consumer = consumer; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessorAdvice.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessorAdvice.java new file mode 100644 index 0000000000000..9bb8139789a6a --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessorAdvice.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import org.apache.camel.Exchange; +import org.apache.camel.Ordered; +import org.apache.camel.spi.CamelInternalProcessorAdvice; + +/** + * Runs the Postman request dispatcher after the rest of the internal processing, replacing the stock REST binding + * advice, which is not used because binding is decided per request rather than per route. + */ +class RestPostmanProcessorAdvice implements CamelInternalProcessorAdvice, Ordered { + + private final RestPostmanProcessor postmanProcessor; + + RestPostmanProcessorAdvice(RestPostmanProcessor postmanProcessor) { + this.postmanProcessor = postmanProcessor; + } + + @Override + public boolean hasState() { + return false; + } + + @Override + public Object before(Exchange exchange) throws Exception { + try { + postmanProcessor.process(exchange); + } catch (Exception e) { + exchange.setException(e); + } + return null; + } + + @Override + public void after(Exchange exchange, Object data) throws Exception { + // noop + } + + @Override + public int getOrder() { + // lowest so that all existing advices are triggered first + return Ordered.LOWEST; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessorStrategy.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessorStrategy.java new file mode 100644 index 0000000000000..74b996ed3ba2e --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProcessorStrategy.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.util.List; + +import org.apache.camel.AsyncCallback; +import org.apache.camel.Exchange; +import org.apache.camel.component.platform.http.spi.PlatformHttpConsumerAware; +import org.apache.camel.component.rest.postman.support.PostmanRequestBinding; +import org.apache.camel.support.processor.RestBindingAdvice; +import org.apache.camel.util.json.JsonObject; + +/** + * Strategy for servicing the requests of a Postman collection. + */ +public interface RestPostmanProcessorStrategy { + + /** + * Whether the consumer should fail, ignore or return a mock response for requests that are not mapped to a + * corresponding route. + */ + void setMissingRequest(String missingRequest); + + /** + * Whether the consumer should fail, ignore or return a mock response for requests that are not mapped to a + * corresponding route. + */ + String getMissingRequest(); + + /** + * Used for inclusive filtering of mock data from directories, as Ant-path style patterns separated by comma. + */ + void setMockIncludePattern(String mockIncludePattern); + + /** + * Used for inclusive filtering of mock data from directories, as Ant-path style patterns separated by comma. + */ + String getMockIncludePattern(); + + /** + * Decides which {@code direct} endpoint name a request dispatches to. + *

+ * More than one spelling is accepted, so that a collection fetched from the cloud can be routed by its request id + * while an exported one is routed by slug. The first candidate with a matching route wins. + * + * @param binding the request + * @return the endpoint name, without the {@code direct:} prefix + */ + String resolveDispatchId(PostmanRequestBinding binding); + + /** + * Validates the collection on startup. + * + * @param bindings every request being serviced + * @param basePath the base path they are served under + * @param platformHttpConsumer the platform http consumer, when one is in use + * @throws Exception if a request has no corresponding route and {@code missingRequest} is {@code fail}, + * or if two requests would shadow each other + */ + default void validateCollection( + List bindings, String basePath, PlatformHttpConsumerAware platformHttpConsumer) + throws Exception { + // noop + } + + /** + * Services one matched request. + * + * @param binding the request being serviced + * @param dispatchId the {@code direct} endpoint name to route to + * @param verb the HTTP verb + * @param path the context path + * @param advice the binding advice + * @param exchange the exchange + * @param callback invoked when processing completes + * @return (doneSync) true when processing completed synchronously + */ + boolean process( + PostmanRequestBinding binding, String dispatchId, String verb, String path, + RestBindingAdvice advice, Exchange exchange, AsyncCallback callback); + + /** + * Serves the collection document itself, with credentials already removed. + * + * @param redactedDocument the document to return + * @param exchange the exchange + * @param callback invoked when processing completes + * @return (doneSync) true when processing completed synchronously + */ + boolean processCollectionDocument(JsonObject redactedDocument, Exchange exchange, AsyncCallback callback); +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProducer.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProducer.java new file mode 100644 index 0000000000000..b0eb768ebc2c4 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanProducer.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.util.Map; + +import org.apache.camel.AsyncCallback; +import org.apache.camel.AsyncProducer; +import org.apache.camel.Endpoint; +import org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.apache.camel.Producer; +import org.apache.camel.component.rest.postman.support.PostmanRequestBinding; +import org.apache.camel.support.processor.DelegateAsyncProcessor; + +/** + * Invokes a single request of a Postman collection, by delegating to a {@code rest} endpoint. + *

+ * The message body and headers of the exchange are what actually go on the wire; the collection only supplies the + * method, URL and any headers the message does not already carry. + */ +public class RestPostmanProducer extends DelegateAsyncProcessor implements AsyncProducer { + + private final Producer delegate; + private final boolean removeHostHeader; + private final PostmanRequestBinding binding; + + public RestPostmanProducer(Producer delegate, boolean removeHostHeader, PostmanRequestBinding binding) { + super(delegate); + this.delegate = delegate; + this.removeHostHeader = removeHostHeader; + this.binding = binding; + } + + @Override + public boolean process(Exchange exchange, AsyncCallback callback) { + Message message = exchange.getMessage(); + + if (removeHostHeader) { + // the target host is fixed by the collection or by configuration, so a Host header carried over from + // some other HTTP input must not override it + message.removeHeader("Host"); + } + + applyDefaults(message, binding); + + message.setHeader(RestPostmanConstants.REQUEST_ID, binding.id()); + message.setHeader(RestPostmanConstants.REQUEST_NAME, binding.item().getName()); + if (!binding.item().getFolderPath().isEmpty()) { + message.setHeader(RestPostmanConstants.FOLDER_PATH, String.join("/", binding.item().getFolderPath())); + } + + return super.process(exchange, callback); + } + + /** + * Applies the headers and path values declared in the collection, without overwriting anything the caller set. + */ + static void applyDefaults(Message message, PostmanRequestBinding binding) { + for (Map.Entry header : binding.staticHeaders().entrySet()) { + if (header.getValue() != null && message.getHeader(header.getKey()) == null) { + message.setHeader(header.getKey(), header.getValue()); + } + } + for (Map.Entry value : binding.defaultPathValues().entrySet()) { + if (message.getHeader(value.getKey()) == null) { + message.setHeader(value.getKey(), value.getValue()); + } + } + } + + @Override + public Endpoint getEndpoint() { + return delegate.getEndpoint(); + } + + @Override + public boolean isSingleton() { + return delegate.isSingleton(); + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanRunnerProducer.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanRunnerProducer.java new file mode 100644 index 0000000000000..de78f56b20e10 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/RestPostmanRunnerProducer.java @@ -0,0 +1,189 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.camel.Endpoint; +import org.apache.camel.Exchange; +import org.apache.camel.ExchangePattern; +import org.apache.camel.Message; +import org.apache.camel.Producer; +import org.apache.camel.RuntimeCamelException; +import org.apache.camel.component.rest.postman.support.PostmanRequestBinding; +import org.apache.camel.spi.UnitOfWork; +import org.apache.camel.support.DefaultProducer; +import org.apache.camel.support.UnitOfWorkHelper; +import org.apache.camel.support.service.ServiceHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Runs every request of a folder or of a whole collection, in the order they appear in the collection. + *

+ * This is the equivalent of Postman's collection runner. Unlike the single request producer, the body and headers sent + * are the ones written in the collection, because one exchange body cannot stand in for many different requests. The + * message body of the exchange becomes a {@code List} of {@link PostmanRunResult}. + */ +public class RestPostmanRunnerProducer extends DefaultProducer { + + private static final Logger LOG = LoggerFactory.getLogger(RestPostmanRunnerProducer.class); + + private final List requests; + private final boolean failFast; + private final String description; + + public RestPostmanRunnerProducer(Endpoint endpoint, List requests, boolean failFast, + String description) { + super(endpoint); + this.requests = List.copyOf(requests); + this.failFast = failFast; + this.description = description; + } + + @Override + public void process(Exchange exchange) throws Exception { + List results = new ArrayList<>(requests.size()); + int failed = 0; + + for (PreparedRequest prepared : requests) { + Exchange sub = prepared.producer().getEndpoint().createExchange(ExchangePattern.InOut); + prepareRequest(sub, prepared.binding()); + + Exception failure = null; + try { + prepared.producer().process(sub); + failure = sub.getException(); + } catch (Exception e) { + failure = e; + } + + results.add(toResult(prepared.binding(), sub, failure)); + releaseUnitOfWork(sub); + + if (failure != null) { + failed++; + if (failFast) { + throw new RuntimeCamelException( + "Postman request " + prepared.binding().item().describe() + " failed while running " + + description + ". Set runFailFast=false to run the remaining " + + (requests.size() - results.size()) + " request(s) anyway.", + failure); + } + LOG.debug("Postman request {} failed while running {}", prepared.binding().id(), description, failure); + } + } + + Message out = exchange.getMessage(); + out.setBody(results); + out.setHeader(RestPostmanConstants.REQUEST_COUNT, results.size()); + out.setHeader(RestPostmanConstants.FAILED_COUNT, failed); + } + + /** + * Completes the unit of work of a sub-exchange, if it has one. + *

+ * A sub-exchange created straight from the delegate endpoint and handed to a producer normally carries no unit of + * work, because nothing in that path starts one. Releasing it when present keeps the cleanup deterministic rather + * than leaving any registered synchronization to garbage collection, which matters when a run covers a large + * collection. + */ + private static void releaseUnitOfWork(Exchange sub) { + UnitOfWork uow = sub.getUnitOfWork(); + if (uow != null) { + UnitOfWorkHelper.doneUow(uow, sub); + } + } + + /** + * Populates a sub-exchange with what the collection says this request should send. + */ + private static void prepareRequest(Exchange sub, PostmanRequestBinding binding) { + Message in = sub.getMessage(); + in.setBody(binding.collectionBody()); + + binding.staticHeaders().forEach((key, value) -> { + if (value != null) { + in.setHeader(key, value); + } + }); + binding.defaultPathValues().forEach(in::setHeader); + + if (binding.produces() != null && binding.collectionBody() != null) { + in.setHeader(Exchange.CONTENT_TYPE, binding.produces()); + } + if (binding.consumes() != null) { + in.setHeader("Accept", binding.consumes()); + } + + in.setHeader(RestPostmanConstants.REQUEST_ID, binding.id()); + in.setHeader(RestPostmanConstants.REQUEST_NAME, binding.item().getName()); + if (!binding.item().getFolderPath().isEmpty()) { + in.setHeader(RestPostmanConstants.FOLDER_PATH, String.join("/", binding.item().getFolderPath())); + } + } + + private static PostmanRunResult toResult(PostmanRequestBinding binding, Exchange sub, Exception failure) { + Message out = sub.getMessage(); + Map headers = new LinkedHashMap<>(out.getHeaders()); + + Integer status = null; + Object code = headers.get(Exchange.HTTP_RESPONSE_CODE); + if (code instanceof Number n) { + status = n.intValue(); + } + + String folderPath = binding.item().getFolderPath().isEmpty() + ? null : String.join("/", binding.item().getFolderPath()); + + return new PostmanRunResult( + binding.id(), + binding.item().getName(), + folderPath, + binding.method(), + (binding.host() != null ? binding.host() : "") + binding.fullPath(), + status, + failure == null ? out.getBody() : null, + headers, + failure != null ? failure.getMessage() : null); + } + + @Override + protected void doStart() throws Exception { + super.doStart(); + for (PreparedRequest prepared : requests) { + ServiceHelper.startService(prepared.producer()); + } + } + + @Override + protected void doStop() throws Exception { + for (PreparedRequest prepared : requests) { + ServiceHelper.stopService(prepared.producer()); + } + super.doStop(); + } + + /** + * A request of the collection together with the delegate producer that calls it. + */ + public record PreparedRequest(PostmanRequestBinding binding, Producer producer) { + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/collection/BoundedInputStreamReader.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/collection/BoundedInputStreamReader.java new file mode 100644 index 0000000000000..b5905ab2938c9 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/collection/BoundedInputStreamReader.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.collection; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Reads a response body with an explicit byte cap, so that a remote peer cannot exhaust the heap by returning an + * unbounded document. + */ +public final class BoundedInputStreamReader { + + private static final int BUFFER_SIZE = 8192; + + private BoundedInputStreamReader() { + } + + /** + * Reads the stream fully, failing as soon as more than {@code maxBytes} have been seen. + * + * @param input the stream to read + * @param maxBytes the inclusive maximum number of bytes to accept + * @param description what is being read, used in the failure message + * @return the bytes read + * @throws IOException if the stream fails, or yields more than {@code maxBytes} + */ + public static byte[] readAtMost(InputStream input, long maxBytes, String description) throws IOException { + if (maxBytes < 0) { + throw new IllegalArgumentException("maxBytes must be non-negative"); + } + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[BUFFER_SIZE]; + long total = 0; + while (true) { + long remaining = maxBytes - total; + int allowed = remaining >= buffer.length ? buffer.length : (int) remaining + 1; + int read = input.read(buffer, 0, allowed); + if (read == -1) { + return output.toByteArray(); + } + total += read; + if (total > maxBytes) { + throw new IOException(description + " exceeds maximum size: " + maxBytes + " bytes"); + } + output.write(buffer, 0, read); + } + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/collection/PostmanCloudClient.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/collection/PostmanCloudClient.java new file mode 100644 index 0000000000000..e21bfbd782489 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/collection/PostmanCloudClient.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.collection; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.Set; + +import javax.net.ssl.SSLContext; + +/** + * Fetches a collection document from the Postman cloud API. + *

+ * This is the only place in the component that sends the Postman API key anywhere, and it is deliberately strict about + * where that key can go. Redirects are rejected outright rather than followed, because a redirect from a mistyped or + * hijacked API URL would replay the credential to whatever host the {@code Location} names. + */ +public final class PostmanCloudClient { + + /** + * The largest collection document that will be accepted, so a remote peer cannot exhaust the heap. + */ + public static final long MAX_COLLECTION_BYTES = 8L * 1024 * 1024; + + private static final Set LOCAL_HOSTS = Set.of("localhost", "127.0.0.1", "::1", "[::1]"); + + private final String apiUrl; + private final String apiKey; + private final String apiKeyHeader; + private final Duration requestTimeout; + private final HttpClient client; + + public PostmanCloudClient(String apiUrl, String apiKey, String apiKeyHeader, + Duration connectTimeout, Duration requestTimeout, SSLContext sslContext) { + this.apiUrl = stripTrailingSlash(apiUrl); + this.apiKey = apiKey; + this.apiKeyHeader = apiKeyHeader; + this.requestTimeout = requestTimeout; + + // built once: every HttpClient allocates its own selector and executor threads, so creating one per + // fetch would leak threads across repeated cache misses + HttpClient.Builder builder = HttpClient.newBuilder() + // never follow a redirect: doing so would replay the API key to the redirect target + .followRedirects(HttpClient.Redirect.NEVER) + .connectTimeout(connectTimeout); + if (sslContext != null) { + builder.sslContext(sslContext); + } + this.client = builder.build(); + } + + /** + * Fetches the collection with the given uid. + *

+ * Both the bare collection UUID and the {@code {ownerId}-{uuid}} form are accepted by the Postman API, so the uid + * is passed through unchanged apart from path encoding. + * + * @param uid the collection uid + * @return the raw JSON document, which the caller is expected to unwrap and parse + */ + public String fetchCollection(String uid) throws IOException, InterruptedException, URISyntaxException { + URI uri = new URI(apiUrl + "/collections/" + encodePathSegment(uid)); + + HttpRequest.Builder request = HttpRequest.newBuilder() + .uri(uri) + .header("Accept", "application/json") + .timeout(requestTimeout) + .GET(); + if (apiKey != null && !apiKey.isEmpty()) { + request.header(apiKeyHeader, apiKey); + } + + HttpResponse response = client.send(request.build(), HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream body = response.body()) { + int status = response.statusCode(); + if (status >= 300 && status < 400) { + String location = response.headers().firstValue("Location").orElse("an unspecified location"); + throw new IOException( + "Fetching Postman collection " + uid + " redirected to " + location + + " - redirects are blocked because following one would send the Postman API key" + + " to the redirect target. Configure postmanApiUrl with the final URL instead."); + } + if (status != 200) { + // the message deliberately carries only the uid and the status, never the key or the response body + throw new IOException( + "Failed to fetch Postman collection " + uid + " from " + apiUrl + ": HTTP " + status); + } + byte[] content = BoundedInputStreamReader.readAtMost(body, MAX_COLLECTION_BYTES, "Postman collection " + uid); + return new String(content, StandardCharsets.UTF_8); + } + } + + /** + * Rejects an API URL that would send the key over an unencrypted connection. + *

+ * Plain HTTP is tolerated only for loopback hosts, which is what makes it possible to point the component at a + * local stub server in tests. + * + * @throws IllegalArgumentException when the URL is malformed or is plain HTTP to a remote host + */ + public static void validateApiUrl(String apiUrl) { + URI uri; + try { + uri = new URI(apiUrl); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("postmanApiUrl is not a valid URI: " + apiUrl, e); + } + String scheme = uri.getScheme(); + if (scheme == null) { + throw new IllegalArgumentException("postmanApiUrl must be an absolute URL, was: " + apiUrl); + } + scheme = scheme.toLowerCase(Locale.ROOT); + if ("https".equals(scheme)) { + return; + } + if (!"http".equals(scheme)) { + throw new IllegalArgumentException("postmanApiUrl must use http or https, was: " + apiUrl); + } + String host = uri.getHost(); + if (host == null || !LOCAL_HOSTS.contains(host.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException( + "postmanApiUrl must use https, because plain http would send the Postman API key in clear text." + + " Plain http is allowed only for localhost. Was: " + apiUrl); + } + } + + private static String encodePathSegment(String segment) throws URISyntaxException { + // a uid is expected to be alphanumeric with dashes, so reject anything that could escape the path + if (!segment.matches("[A-Za-z0-9._~-]+")) { + throw new URISyntaxException(segment, "Postman collection uid contains illegal characters"); + } + return segment; + } + + private static String stripTrailingSlash(String url) { + String answer = url; + while (answer.endsWith("/")) { + answer = answer.substring(0, answer.length() - 1); + } + return answer; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/collection/PostmanCollectionCache.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/collection/PostmanCollectionCache.java new file mode 100644 index 0000000000000..81b00f2fadeb9 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/collection/PostmanCollectionCache.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.collection; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +import org.apache.camel.RuntimeCamelException; +import org.apache.camel.component.rest.postman.model.PostmanCollection; + +/** + * Caches parsed collections for the lifetime of the component. + *

+ * Without this, every endpoint built against the same collection would re-read it, and for cloud sources that means one + * API call per endpoint at startup. + */ +public final class PostmanCollectionCache { + + private final Map entries = new ConcurrentHashMap<>(); + + /** + * Returns the cached collection for the given source, loading it if absent or stale. + * + * @param source the collection source, used as part of the cache key + * @param apiKey the API key in play, so that two endpoints using different credentials never share an entry + * @param ttl how long an entry stays fresh in milliseconds, or a negative value to cache forever + * @param loader supplies the collection on a miss + * @return the collection + */ + public PostmanCollection get(String source, String apiKey, long ttl, Supplier loader) { + String key = cacheKey(source, apiKey); + long now = System.currentTimeMillis(); + + Entry entry = entries.get(key); + if (entry != null && !entry.isStale(now, ttl)) { + return entry.collection(); + } + // a concurrent miss may load twice, which is harmless and cheaper than holding a lock across the load + PostmanCollection loaded = loader.get(); + entries.put(key, new Entry(loaded, now)); + return loaded; + } + + public void clear() { + entries.clear(); + } + + /** + * Builds the cache key. The API key is hashed rather than stored, so that a heap dump or a debugger view of the + * cache cannot reveal the credential. + */ + private static String cacheKey(String source, String apiKey) { + if (apiKey == null || apiKey.isEmpty()) { + return source + "|"; + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(apiKey.getBytes(StandardCharsets.UTF_8)); + return source + "|" + HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeCamelException("SHA-256 is not available", e); + } + } + + private record Entry(PostmanCollection collection, long loadedAt) { + + boolean isStale(long now, long ttl) { + return ttl >= 0 && now - loadedAt > ttl; + } + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/collection/PostmanCollectionLoader.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/collection/PostmanCollectionLoader.java new file mode 100644 index 0000000000000..54a8ba216aa8e --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/collection/PostmanCollectionLoader.java @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.collection; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +import org.apache.camel.CamelContext; +import org.apache.camel.RuntimeCamelException; +import org.apache.camel.component.rest.postman.model.PostmanCollection; +import org.apache.camel.spi.Resource; +import org.apache.camel.support.ResourceHelper; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Resolves a {@code collectionSource} into a parsed {@link PostmanCollection}. + *

+ * A source is either a resource URI, resolved through Camel's {@link ResourceHelper} so that {@code classpath:}, + * {@code file:} and {@code http:} all work, or a collection uid to be fetched from the Postman cloud. + */ +public final class PostmanCollectionLoader { + + public static final String SOURCE_TYPE_AUTO = "auto"; + public static final String SOURCE_TYPE_RESOURCE = "resource"; + public static final String SOURCE_TYPE_CLOUD = "cloud"; + + private static final Logger LOG = LoggerFactory.getLogger(PostmanCollectionLoader.class); + + /** + * A bare collection UUID, or the {@code {ownerId}-{uuid}} uid form the Postman API also accepts. + */ + private static final Pattern UID_PATTERN = Pattern.compile( + "(?:[0-9]+-)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"); + + private static final String EXPECTED_SCHEMA_VERSION = "v2.1"; + + private PostmanCollectionLoader() { + } + + /** + * Whether the source should be fetched from the Postman cloud rather than read as a resource. + * + * @param source the collection source + * @param sourceType {@code auto}, {@code resource} or {@code cloud} + */ + public static boolean isCloudSource(String source, String sourceType) { + if (SOURCE_TYPE_CLOUD.equals(sourceType)) { + return true; + } + if (SOURCE_TYPE_RESOURCE.equals(sourceType)) { + return false; + } + // auto: only a bare uid is treated as a cloud reference, so a file merely named after a uuid, + // which would carry an extension or a scheme, still resolves as a resource + return UID_PATTERN.matcher(source).matches(); + } + + /** + * Loads a collection from a resource URI. + */ + public static PostmanCollection loadFromResource(CamelContext camelContext, String uri) { + try { + Resource resource = ResourceHelper.resolveMandatoryResource(camelContext, uri); + if (!resource.exists()) { + throw new RuntimeCamelException("Postman collection not found: " + uri); + } + try (InputStream is = resource.getInputStream()) { + if (is == null) { + throw new RuntimeCamelException("Postman collection not found: " + uri); + } + byte[] content = BoundedInputStreamReader.readAtMost( + is, PostmanCloudClient.MAX_COLLECTION_BYTES, "Postman collection " + uri); + return parse(new String(content, StandardCharsets.UTF_8), uri); + } + } catch (RuntimeCamelException e) { + throw e; + } catch (Exception e) { + throw new RuntimeCamelException("Cannot load Postman collection from: " + uri, e); + } + } + + /** + * Fetches a collection from the Postman cloud. + */ + public static PostmanCollection loadFromCloud(PostmanCloudClient client, String uid) { + try { + return parse(client.fetchCollection(uid), uid); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeCamelException("Interrupted while fetching Postman collection: " + uid, e); + } catch (RuntimeCamelException e) { + throw e; + } catch (Exception e) { + throw new RuntimeCamelException("Cannot fetch Postman collection from the Postman cloud: " + uid, e); + } + } + + /** + * Parses a collection document and checks that it looks like the format we support. + * + * @param content the raw JSON + * @param source the source, used only for error messages + */ + public static PostmanCollection parse(String content, String source) { + JsonObject root; + try { + root = (JsonObject) Jsoner.deserialize(content); + } catch (Exception e) { + throw new RuntimeCamelException("Postman collection is not valid JSON: " + source, e); + } + if (root == null) { + throw new RuntimeCamelException("Postman collection is empty: " + source); + } + + PostmanCollection collection = PostmanCollection.parse(root); + if (collection.getInfo() == null) { + throw new RuntimeCamelException( + "Not a Postman collection, the info object is missing: " + source); + } + + String schema = collection.getSchema(); + if (schema == null) { + LOG.warn("Postman collection {} does not declare a schema. Assuming Collection Format {}.", + source, EXPECTED_SCHEMA_VERSION); + } else if (!schema.contains(EXPECTED_SCHEMA_VERSION)) { + LOG.warn("Postman collection {} declares schema {}, but only Collection Format {} is supported." + + " Parsing will continue and may fail or produce unexpected results.", + source, schema, EXPECTED_SCHEMA_VERSION); + } + return collection; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanAuth.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanAuth.java new file mode 100644 index 0000000000000..c78b3a60b139c --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanAuth.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.model; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import org.apache.camel.util.json.JsonObject; + +/** + * The {@code auth} block of a collection, folder or request. + *

+ * Postman stores the parameters of each auth type as an array of {@code {key, value, type}} tuples under a property + * named after the type, for example {@code auth.bearer[0] = {key: "token", value: "..."}}. That array is flattened into + * a map here. + */ +public final class PostmanAuth { + + public static final String TYPE_NOAUTH = "noauth"; + public static final String TYPE_BASIC = "basic"; + public static final String TYPE_BEARER = "bearer"; + public static final String TYPE_APIKEY = "apikey"; + + /** + * The auth types this component can reproduce as a static header or query parameter. + */ + public static final Set SUPPORTED_TYPES = Set.of(TYPE_NOAUTH, TYPE_BASIC, TYPE_BEARER, TYPE_APIKEY); + + private final String type; + private final Map parameters; + + private PostmanAuth(String type, Map parameters) { + this.type = type; + this.parameters = Map.copyOf(parameters); + } + + /** + * @param node the {@code auth} node, may be {@code null} + * @return the auth block, or {@code null} when there is none + */ + public static PostmanAuth parse(Object node) { + JsonObject auth = PostmanJson.asObject(node); + if (auth == null) { + return null; + } + String type = PostmanJson.asString(auth.get("type")); + if (type == null || type.isEmpty()) { + return null; + } + + Map parameters = new LinkedHashMap<>(); + for (Object element : PostmanJson.asList(auth.get(type))) { + if (element instanceof JsonObject entry) { + String key = PostmanJson.asString(entry.get("key")); + if (key != null) { + parameters.put(key, PostmanJson.asString(entry.get("value"))); + } + } + } + return new PostmanAuth(type, parameters); + } + + public String getType() { + return type; + } + + public String getParameter(String key) { + return parameters.get(key); + } + + public String getParameterOrDefault(String key, String defaultValue) { + String value = parameters.get(key); + return value != null ? value : defaultValue; + } + + /** + * Whether this component can turn the auth block into a static header or query parameter. The remaining types + * ({@code awsv4}, {@code digest}, {@code hawk}, {@code edgegrid}, {@code ntlm}, {@code oauth1}, {@code oauth2}) + * need per-request signing or a token exchange, which belongs to the delegate HTTP component. + */ + public boolean isSupported() { + return SUPPORTED_TYPES.contains(type); + } + + /** + * Whether this block explicitly disables authentication, which is how a request opts out of an inherited block. + */ + public boolean isNoAuth() { + return TYPE_NOAUTH.equals(type); + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanBody.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanBody.java new file mode 100644 index 0000000000000..afc02be42c852 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanBody.java @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.model; + +import java.util.List; + +import org.apache.camel.util.json.JsonObject; + +/** + * The {@code body} of a Postman request. + *

+ * Only enough of the body is modelled to infer a {@code Content-Type}. The body content itself is never sent: the + * collection's body is sample data, and the message body of the exchange is what actually goes on the wire. + */ +public final class PostmanBody { + + public static final String MODE_RAW = "raw"; + public static final String MODE_URLENCODED = "urlencoded"; + public static final String MODE_FORMDATA = "formdata"; + public static final String MODE_FILE = "file"; + public static final String MODE_GRAPHQL = "graphql"; + + private final JsonObject json; + + private PostmanBody(JsonObject json) { + this.json = json; + } + + /** + * @param node the {@code body} node, may be {@code null} + * @return the body, or {@code null} when there is none or it is disabled + */ + public static PostmanBody parse(Object node) { + JsonObject body = PostmanJson.asObject(node); + if (body == null || body.isEmpty() || Boolean.TRUE.equals(body.get("disabled"))) { + return null; + } + return new PostmanBody(body); + } + + public String getMode() { + return PostmanJson.asString(json.get("mode")); + } + + public String getRaw() { + return PostmanJson.asString(json.get("raw")); + } + + /** + * The field names declared for a {@code urlencoded} or {@code formdata} body, in document order. + */ + public List getFormFields() { + String mode = getMode(); + if (MODE_URLENCODED.equals(mode) || MODE_FORMDATA.equals(mode)) { + return PostmanKeyValue.listFrom(json, mode); + } + return List.of(); + } + + /** + * Infers the {@code Content-Type} this request would send. + *

+ * For a {@code raw} body the language recorded in {@code options.raw.language} is what Postman itself uses to + * decide the content type, so it is honoured here too. + * + * @return the media type, or {@code null} when it cannot be inferred + */ + public String inferContentType() { + String mode = getMode(); + if (mode == null) { + return null; + } + return switch (mode) { + case MODE_RAW -> rawContentType(); + case MODE_GRAPHQL -> "application/json"; + case MODE_URLENCODED -> "application/x-www-form-urlencoded"; + case MODE_FORMDATA -> "multipart/form-data"; + case MODE_FILE -> "application/octet-stream"; + default -> null; + }; + } + + private String rawContentType() { + JsonObject options = PostmanJson.asObject(json.get("options")); + JsonObject raw = options != null ? PostmanJson.asObject(options.get("raw")) : null; + String language = raw != null ? PostmanJson.asString(raw.get("language")) : null; + if (language == null) { + return "text/plain"; + } + return switch (language.toLowerCase()) { + case "json" -> "application/json"; + case "xml" -> "application/xml"; + case "html" -> "text/html"; + case "javascript" -> "application/javascript"; + default -> "text/plain"; + }; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanCollection.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanCollection.java new file mode 100644 index 0000000000000..b9b705ee32e2f --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanCollection.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.model; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.camel.util.json.JsonObject; + +/** + * The root of a Postman Collection v2.1 document. + */ +public final class PostmanCollection { + + private final JsonObject json; + + private PostmanCollection(JsonObject json) { + this.json = json; + } + + /** + * Wraps a parsed collection document. + *

+ * The Postman cloud API returns the collection nested under a {@code collection} property, and some exports do the + * same, so that envelope is unwrapped here to give callers one shape. + * + * @param root the parsed document + * @return the collection + */ + public static PostmanCollection parse(JsonObject root) { + JsonObject nested = PostmanJson.asObject(root.get("collection")); + return new PostmanCollection(nested != null ? nested : root); + } + + public JsonObject getInfo() { + return PostmanJson.asObject(json.get("info")); + } + + public String getName() { + JsonObject info = getInfo(); + return info != null ? PostmanJson.asString(info.get("name")) : null; + } + + /** + * The declared schema URL, for example + * {@code https://schema.getpostman.com/json/collection/v2.1.0/collection.json}. + */ + public String getSchema() { + JsonObject info = getInfo(); + return info != null ? PostmanJson.asString(info.get("schema")) : null; + } + + /** + * The collection level variables, with disabled entries removed. + */ + public Map getVariables() { + return variablesOf(json); + } + + public PostmanAuth getAuth() { + return PostmanAuth.parse(json.get("auth")); + } + + /** + * The top level items, each of which may be a request or a folder. + */ + public List getItems() { + return PostmanJson.asList(json.get("item")); + } + + public JsonObject getJson() { + return json; + } + + /** + * Reads the {@code variable} array of a collection or folder node into an ordered map, skipping disabled entries. + */ + public static Map variablesOf(JsonObject node) { + Map answer = new LinkedHashMap<>(); + for (PostmanKeyValue variable : PostmanKeyValue.listFrom(node, "variable")) { + if (!variable.disabled()) { + answer.put(variable.key(), variable.value() != null ? variable.value() : ""); + } + } + return answer; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanItem.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanItem.java new file mode 100644 index 0000000000000..b8f212dc8fd17 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanItem.java @@ -0,0 +1,152 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.model; + +import java.util.List; +import java.util.Map; + +import org.apache.camel.util.json.JsonObject; + +/** + * A leaf item of a Postman collection, that is one request, together with everything it inherited from the folders + * enclosing it. + *

+ * Inheritance is resolved eagerly while the item tree is flattened, so an item carries its own effective auth block and + * its own variable scope. That keeps lookups at routing time to a field read. + */ +public final class PostmanItem { + + private final JsonObject json; + private final String id; + private final String name; + private final List folderPath; + private final String slug; + private final String qualifiedSlug; + private final String canonicalId; + private final PostmanRequest request; + private final PostmanAuth effectiveAuth; + private final Map scopeVariables; + + public PostmanItem(JsonObject json, String id, String name, List folderPath, String slug, + String qualifiedSlug, String canonicalId, PostmanRequest request, PostmanAuth effectiveAuth, + Map scopeVariables) { + this.json = json; + this.id = id; + this.name = name; + this.folderPath = List.copyOf(folderPath); + this.slug = slug; + this.qualifiedSlug = qualifiedSlug; + this.canonicalId = canonicalId; + this.request = request; + this.effectiveAuth = effectiveAuth; + this.scopeVariables = Map.copyOf(scopeVariables); + } + + /** + * The {@code item.id} recorded in the collection. + *

+ * This is optional in the v2.1 schema, and Postman's exporter strips auto-generated ids, so it is normally present + * only on collections fetched from the Postman cloud. + * + * @return the id, or {@code null} when the collection does not record one + */ + public String getId() { + return id; + } + + public String getName() { + return name; + } + + /** + * The names of the enclosing folders, outermost first. + */ + public List getFolderPath() { + return folderPath; + } + + /** + * The slugified item name, for example {@code getUserById}. + */ + public String getSlug() { + return slug; + } + + /** + * The slug prefixed with the slugified folder path, for example {@code users/getUserById}. Used to disambiguate + * items whose names collide. + */ + public String getQualifiedSlug() { + return qualifiedSlug; + } + + /** + * The identifier a route author uses for this request: the plain slug when it is unique across the collection, + * otherwise the folder qualified slug. This is both the URI fragment that selects the request and the + * {@code direct:} name the contract-first consumer dispatches to, so that there is only ever one spelling. + */ + public String getCanonicalId() { + return canonicalId; + } + + public PostmanRequest getRequest() { + return request; + } + + /** + * The auth block that applies to this request: its own, or failing that the nearest enclosing folder's, or failing + * that the collection's. + * + * @return the auth block, or {@code null} when none applies + */ + public PostmanAuth getEffectiveAuth() { + return effectiveAuth; + } + + /** + * The variables visible to this request, with the innermost scope winning. + */ + public Map getScopeVariables() { + return scopeVariables; + } + + /** + * The saved example responses recorded against this item, used to serve mock responses. + */ + public List getSavedResponses() { + return PostmanResponse.listFrom(json.get("response")); + } + + public JsonObject getJson() { + return json; + } + + /** + * A human readable description of this item for use in error messages, naming the folder path when there is one. + */ + public String describe() { + if (folderPath.isEmpty()) { + return "'" + name + "'"; + } + return "'" + String.join(" / ", folderPath) + " / " + name + "'"; + } + + @Override + public String toString() { + return "PostmanItem[" + canonicalId + "]"; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanJson.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanJson.java new file mode 100644 index 0000000000000..889ca355b089c --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanJson.java @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.model; + +import java.util.List; +import java.util.regex.Pattern; + +import org.apache.camel.util.json.JsonObject; + +/** + * Coercion helpers for the union types that pervade the Postman Collection v2.1 format. + *

+ * The schema declares many properties as "one of" several shapes: {@code request} is an object or a string, + * {@code url.host} is a string or an array of labels, {@code url.path} is a string or an array of segments where each + * segment is itself a string or an object. Rather than push that ambiguity into every caller, it is resolved once here. + */ +public final class PostmanJson { + + private PostmanJson() { + } + + /** + * Coerces a node to a string, tolerating numbers and booleans, which do occur in real collections where the schema + * says string. + * + * @return the string form, or {@code null} when the node is absent or is a container + */ + public static String asString(Object node) { + if (node == null) { + return null; + } + if (node instanceof String s) { + return s; + } + if (node instanceof Number || node instanceof Boolean) { + return node.toString(); + } + return null; + } + + /** + * Returns a node as a {@link JsonObject}, or {@code null} when it is absent or is not an object. + */ + public static JsonObject asObject(Object node) { + return node instanceof JsonObject o ? o : null; + } + + /** + * Returns a node as a list, or an empty list when it is absent or is not an array. + */ + public static List asList(Object node) { + return node instanceof List l ? l : List.of(); + } + + /** + * Flattens a "string or array of strings" node into a list of strings. + *

+ * Array elements that are objects are read via their {@code value} property, which is how Postman expresses a path + * segment that carries extra metadata. + * + * @param node the node, may be {@code null} + * @param separator when the node is a plain string, the separator to split it on + * @return the parts with empty entries removed, never {@code null} + */ + public static List asStringList(Object node, String separator) { + if (node == null) { + return List.of(); + } + if (node instanceof String s) { + return List.of(s.split(Pattern.quote(separator))).stream() + .filter(p -> !p.isEmpty()) + .toList(); + } + if (node instanceof List list) { + return list.stream() + .map(element -> { + if (element instanceof JsonObject o) { + return asString(o.get("value")); + } + return asString(element); + }) + .filter(p -> p != null && !p.isEmpty()) + .toList(); + } + return List.of(); + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanKeyValue.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanKeyValue.java new file mode 100644 index 0000000000000..4ec72306e7c94 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanKeyValue.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.model; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.camel.util.json.JsonObject; + +/** + * A {@code {key, value, disabled, description}} tuple, which is how the Postman Collection format expresses headers, + * query parameters and variables. + */ +public record PostmanKeyValue(String key, String value, boolean disabled, String description) { + + /** + * Reads a list of key/value tuples from a collection node. + *

+ * The value is tolerated as a non-string (numbers appear in the wild) and coerced via {@code toString}. Entries + * without a key are skipped, as they carry no usable information. + * + * @param node the enclosing node, may be {@code null} + * @param key the property holding the array, for example {@code header} or {@code query} + * @return the tuples in document order, never {@code null} + */ + public static List listFrom(JsonObject node, String key) { + List answer = new ArrayList<>(); + if (node == null) { + return answer; + } + Object raw = node.get(key); + if (!(raw instanceof List list)) { + return answer; + } + for (Object element : list) { + if (element instanceof JsonObject entry) { + String name = PostmanJson.asString(entry.get("key")); + if (name == null || name.isEmpty()) { + continue; + } + answer.add(new PostmanKeyValue( + name, + PostmanJson.asString(entry.get("value")), + Boolean.TRUE.equals(entry.get("disabled")), + PostmanJson.asString(entry.get("description")))); + } + } + return answer; + } + + /** + * Whether this entry contributes a value, that is it is enabled and carries a non-empty value. + */ + public boolean hasValue() { + return !disabled && value != null && !value.isEmpty(); + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanRequest.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanRequest.java new file mode 100644 index 0000000000000..40e69ebde795e --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanRequest.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.model; + +import java.util.List; +import java.util.Locale; + +import org.apache.camel.util.json.JsonObject; + +/** + * The {@code request} of a Postman item. + */ +public final class PostmanRequest { + + private static final String DEFAULT_METHOD = "GET"; + + private final JsonObject json; + private final PostmanUrl url; + + private PostmanRequest(JsonObject json) { + this.json = json; + this.url = PostmanUrl.parse(json.get("url")); + } + + /** + * Reads a {@code request} node. + *

+ * The schema permits the shorthand form where the whole request is a bare URL string, which implies a GET. That + * form is expanded here so the rest of the component only ever sees the object form. + * + * @param node the {@code request} node, may be {@code null} + * @return the request, or {@code null} when the node is absent, which marks the item as a folder + */ + public static PostmanRequest parse(Object node) { + if (node instanceof String s) { + JsonObject synthetic = new JsonObject(); + synthetic.put("method", DEFAULT_METHOD); + synthetic.put("url", s); + return new PostmanRequest(synthetic); + } + JsonObject request = PostmanJson.asObject(node); + return request != null ? new PostmanRequest(request) : null; + } + + /** + * The HTTP method, upper-cased, defaulting to {@code GET} when the collection omits it. + */ + public String getMethod() { + String method = PostmanJson.asString(json.get("method")); + if (method == null || method.isBlank()) { + return DEFAULT_METHOD; + } + return method.trim().toUpperCase(Locale.ROOT); + } + + public PostmanUrl getUrl() { + return url; + } + + /** + * The declared request headers, including disabled ones, which callers are expected to filter out. + */ + public List getHeaders() { + return PostmanKeyValue.listFrom(json, "header"); + } + + /** + * Looks up a declared header by name, case-insensitively, ignoring disabled entries. + * + * @return the value, or {@code null} when the header is not declared + */ + public String getHeader(String name) { + for (PostmanKeyValue header : getHeaders()) { + if (!header.disabled() && header.key().equalsIgnoreCase(name)) { + return header.value(); + } + } + return null; + } + + public PostmanBody getBody() { + return PostmanBody.parse(json.get("body")); + } + + public PostmanAuth getAuth() { + return PostmanAuth.parse(json.get("auth")); + } + + public String getDescription() { + Object description = json.get("description"); + if (description instanceof JsonObject o) { + return PostmanJson.asString(o.get("content")); + } + return PostmanJson.asString(description); + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanResponse.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanResponse.java new file mode 100644 index 0000000000000..713e57c43b7b0 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanResponse.java @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.model; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.camel.util.json.JsonObject; + +/** + * A saved example response stored against a Postman item. + *

+ * These are the one place where a collection is richer than an OpenAPI specification: they carry a concrete status + * code, headers and body rather than a schema, which makes them usable directly as mock responses. + */ +public final class PostmanResponse { + + private final JsonObject json; + + private PostmanResponse(JsonObject json) { + this.json = json; + } + + /** + * Reads the {@code response} array of an item. + * + * @param node the {@code response} node, may be {@code null} + * @return the saved examples in document order, never {@code null} + */ + public static List listFrom(Object node) { + List answer = new ArrayList<>(); + for (Object element : PostmanJson.asList(node)) { + if (element instanceof JsonObject response) { + answer.add(new PostmanResponse(response)); + } + } + return answer; + } + + public String getName() { + return PostmanJson.asString(json.get("name")); + } + + /** + * The HTTP status code, defaulting to 200 when the example does not record one. + */ + public int getCode() { + Object code = json.get("code"); + if (code instanceof Number n) { + return n.intValue(); + } + String text = PostmanJson.asString(code); + if (text != null) { + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + // fall through to the default + } + } + return 200; + } + + public List getHeaders() { + return PostmanKeyValue.listFrom(json, "header"); + } + + /** + * Looks up a response header by name, case-insensitively, ignoring disabled entries. + */ + public String getHeader(String name) { + for (PostmanKeyValue header : getHeaders()) { + if (!header.disabled() && header.key().equalsIgnoreCase(name)) { + return header.value(); + } + } + return null; + } + + public String getBody() { + return PostmanJson.asString(json.get("body")); + } + + public String getContentType() { + return getHeader("Content-Type"); + } + + /** + * Whether this example records a successful response, which is what a mock should replay by default. + */ + public boolean isSuccess() { + int code = getCode(); + return code >= 200 && code < 300; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanUrl.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanUrl.java new file mode 100644 index 0000000000000..d580ee2e6c286 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/model/PostmanUrl.java @@ -0,0 +1,224 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.model; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.camel.util.json.JsonObject; + +/** + * The {@code url} of a Postman request. + *

+ * The accessors deliberately return the values as written in the collection, so they may still contain + * {@code {{variable}}} placeholders and {@code :pathParam} markers. Resolving those is the caller's job, because + * substitution needs the variable scope of the enclosing folders, which a URL does not know about. + */ +public final class PostmanUrl { + + private final String protocol; + private final String host; + private final String port; + private final List pathSegments; + private final List queryParams; + private final List pathVariables; + private final String raw; + + private PostmanUrl(String protocol, String host, String port, List pathSegments, + List queryParams, List pathVariables, String raw) { + this.protocol = protocol; + this.host = host; + this.port = port; + this.pathSegments = List.copyOf(pathSegments); + this.queryParams = List.copyOf(queryParams); + this.pathVariables = List.copyOf(pathVariables); + this.raw = raw; + } + + /** + * Reads a {@code url} node, which the schema allows to be either a string or an object. + *

+ * When the node is an object that carries only {@code raw}, the raw form is parsed, so that a collection written in + * either style yields the same structure. + * + * @param node the {@code url} node, may be {@code null} + * @return the parsed URL, never {@code null} + */ + public static PostmanUrl parse(Object node) { + if (node == null) { + return new PostmanUrl(null, null, null, List.of(), List.of(), List.of(), null); + } + if (node instanceof String s) { + return parseRaw(s, List.of()); + } + JsonObject url = PostmanJson.asObject(node); + if (url == null) { + return new PostmanUrl(null, null, null, List.of(), List.of(), List.of(), null); + } + + List variables = PostmanKeyValue.listFrom(url, "variable"); + String rawValue = PostmanJson.asString(url.get("raw")); + + boolean structured = url.get("host") != null || url.get("path") != null; + if (!structured && rawValue != null) { + // only the raw form was given, so recover the structure from it but keep any declared path variables + return parseRaw(rawValue, variables); + } + + String hostValue = String.join(".", PostmanJson.asStringList(url.get("host"), ".")); + List segments = PostmanJson.asStringList(url.get("path"), "/"); + + return new PostmanUrl( + PostmanJson.asString(url.get("protocol")), + hostValue.isEmpty() ? null : hostValue, + PostmanJson.asString(url.get("port")), + segments, + PostmanKeyValue.listFrom(url, "query"), + variables, + rawValue); + } + + /** + * Parses a raw URL string leniently. + *

+ * {@link java.net.URI} cannot be used here: a raw Postman URL is routinely not a valid URI, because it starts with + * a {@code {{baseUrl}}} placeholder. So the string is split structurally instead of being validated. + */ + private static PostmanUrl parseRaw(String rawUrl, List declaredVariables) { + String remainder = rawUrl.trim(); + + // a fragment is never sent over the wire, so it is recorded only as part of the raw form + int hash = remainder.indexOf('#'); + if (hash >= 0) { + remainder = remainder.substring(0, hash); + } + + String query = null; + int questionMark = remainder.indexOf('?'); + if (questionMark >= 0) { + query = remainder.substring(questionMark + 1); + remainder = remainder.substring(0, questionMark); + } + + String protocol = null; + int schemeSeparator = remainder.indexOf("://"); + if (schemeSeparator > 0) { + protocol = remainder.substring(0, schemeSeparator); + remainder = remainder.substring(schemeSeparator + 3); + } + + String authority; + String path; + if (remainder.startsWith("/")) { + // a host-relative URL, so there is no authority to split off + authority = ""; + path = remainder; + } else { + int slash = remainder.indexOf('/'); + authority = slash >= 0 ? remainder.substring(0, slash) : remainder; + path = slash >= 0 ? remainder.substring(slash) : ""; + } + + String host = authority; + String port = null; + int colon = authority.lastIndexOf(':'); + if (colon >= 0 && isAllDigits(authority.substring(colon + 1))) { + host = authority.substring(0, colon); + port = authority.substring(colon + 1); + } + + List segments = new ArrayList<>(); + for (String segment : path.split("/")) { + if (!segment.isEmpty()) { + segments.add(segment); + } + } + + return new PostmanUrl( + protocol, host.isEmpty() ? null : host, port, segments, + parseRawQuery(query), declaredVariables, rawUrl); + } + + private static List parseRawQuery(String query) { + List answer = new ArrayList<>(); + if (query == null || query.isEmpty()) { + return answer; + } + for (String pair : query.split("&")) { + if (pair.isEmpty()) { + continue; + } + int equals = pair.indexOf('='); + String key = equals >= 0 ? pair.substring(0, equals) : pair; + String value = equals >= 0 ? pair.substring(equals + 1) : null; + if (!key.isEmpty()) { + answer.add(new PostmanKeyValue(key, value, false, null)); + } + } + return answer; + } + + private static boolean isAllDigits(String text) { + if (text.isEmpty()) { + return false; + } + for (int i = 0; i < text.length(); i++) { + if (!Character.isDigit(text.charAt(i))) { + return false; + } + } + return true; + } + + public String getProtocol() { + return protocol; + } + + /** + * The host, with an array of labels already joined by {@code .}. May be a bare {@code {{baseUrl}}} placeholder that + * expands to a complete URL, which callers must re-parse after substitution. + */ + public String getHost() { + return host; + } + + public String getPort() { + return port; + } + + /** + * The path segments, without separators, still carrying {@code :name} markers for path parameters. + */ + public List getPathSegments() { + return pathSegments; + } + + public List getQueryParams() { + return queryParams; + } + + /** + * The values declared in {@code url.variable}, which supply defaults for the {@code :name} path markers. + */ + public List getPathVariables() { + return pathVariables; + } + + public String getRaw() { + return raw; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanFilters.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanFilters.java new file mode 100644 index 0000000000000..2ac1b048fae66 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanFilters.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.support; + +import org.apache.camel.util.AntPathMatcher; + +/** + * Matches request ids against the {@code requestFilter} option. + */ +public final class PostmanFilters { + + private PostmanFilters() { + } + + /** + * Evaluates a comma separated list of Ant style patterns against a qualified slug. + *

+ * A pattern prefixed with {@code !} excludes, and exclusions win over inclusions. When the filter contains only + * exclusions, everything not excluded is kept, which is what makes {@code !users/deleteUser} usable on its own. + * + * @param qualifiedSlug the folder qualified slug of a request, for example {@code users/getUserById} + * @param filter the filter expression + * @return whether the request should be kept + */ + public static boolean matches(String qualifiedSlug, String filter) { + boolean hasInclude = false; + boolean included = false; + + for (String token : filter.split(",")) { + String pattern = token.trim(); + if (pattern.isEmpty()) { + continue; + } + if (pattern.startsWith("!")) { + if (AntPathMatcher.INSTANCE.match(pattern.substring(1), qualifiedSlug)) { + return false; + } + } else { + hasInclude = true; + if (AntPathMatcher.INSTANCE.match(pattern, qualifiedSlug)) { + included = true; + } + } + } + return hasInclude ? included : true; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanRedactor.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanRedactor.java new file mode 100644 index 0000000000000..1426b9c22639b --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanRedactor.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.support; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +/** + * Removes credentials from a collection document before it is served over HTTP. + *

+ * Serving the collection publishes route-author configuration to whoever can reach the API context path, and a real + * collection routinely carries live tokens in its {@code auth} blocks and in variables marked as secret. Redaction is + * therefore unconditional rather than an option. + */ +public final class PostmanRedactor { + + private static final String REDACTED = "***"; + + private PostmanRedactor() { + } + + /** + * Returns a deep copy of the document with every {@code auth} block removed and the value of every variable of type + * {@code secret} replaced. + */ + public static JsonObject redact(JsonObject document) { + return (JsonObject) redactNode(document, null); + } + + private static Object redactNode(Object node, String key) { + if (node instanceof JsonObject object) { + JsonObject answer = new JsonObject(); + for (var entry : object.entrySet()) { + String name = entry.getKey(); + if ("auth".equals(name)) { + // drop the block entirely rather than blanking its values, so that neither the credential nor + // the fact that one is configured is disclosed + continue; + } + answer.put(name, redactNode(entry.getValue(), name)); + } + return answer; + } + if (node instanceof List list) { + List answer = new ArrayList<>(list.size()); + for (Object element : list) { + Object redacted = redactNode(element, null); + if ("variable".equals(key) && redacted instanceof JsonObject variable) { + redactSecretVariable(variable); + } + answer.add(redacted); + } + return new JsonArray(answer); + } + return node; + } + + private static void redactSecretVariable(JsonObject variable) { + Object type = variable.get("type"); + if ("secret".equals(type) && variable.containsKey("value")) { + variable.put("value", REDACTED); + } + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanRequestBinding.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanRequestBinding.java new file mode 100644 index 0000000000000..6f9fc2eb0331d --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanRequestBinding.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.support; + +import java.util.Map; + +import org.apache.camel.component.rest.postman.model.PostmanItem; + +/** + * One Postman request, reduced to everything the {@code rest} component needs in order to call it. + *

+ * Every {@code {{variable}}} has already been substituted, so the values here are final; the only placeholders left are + * the {@code {name}} markers that {@code camel-rest} resolves per exchange from message headers. + * + * @param item the request this was built from + * @param method the HTTP method, upper-cased + * @param host scheme, host and optional port, or {@code null} when it could not be derived + * @param basePath the context path, always starting with {@code /} + * @param uriTemplate the remaining path, with {@code :name} rewritten to {@code {name}} + * @param queryParameters the query string in the placeholder syntax understood by {@code camel-rest}, or {@code null} + * when the request declares none + * @param consumes the {@code Accept} header to send, or {@code null} to leave it to the caller + * @param produces the {@code Content-Type} header to send, or {@code null} when there is no body + * @param staticHeaders headers declared by the collection, applied only when the message does not already carry + * them + * @param defaultPathValues defaults for the {@code {name}} path markers, taken from {@code url.variable} + * @param collectionBody the body written in the collection, used when running a whole folder or collection where one + * exchange body cannot serve every request; {@code null} when there is none + */ +public record PostmanRequestBinding( + PostmanItem item, + String method, + String host, + String basePath, + String uriTemplate, + String queryParameters, + String consumes, + String produces, + Map staticHeaders, + Map defaultPathValues, + String collectionBody) { + + public PostmanRequestBinding { + staticHeaders = Map.copyOf(staticHeaders); + defaultPathValues = Map.copyOf(defaultPathValues); + } + + /** + * The id a route author uses to refer to this request. + */ + public String id() { + return item.getCanonicalId(); + } + + /** + * The full path this request is served on or sent to, that is the base path followed by the template. + */ + public String fullPath() { + if ("/".equals(basePath)) { + return uriTemplate; + } + return basePath + uriTemplate; + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanRequestIndex.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanRequestIndex.java new file mode 100644 index 0000000000000..28f0ada213d93 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanRequestIndex.java @@ -0,0 +1,331 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.support; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.camel.component.rest.postman.RestPostmanHelper; +import org.apache.camel.component.rest.postman.model.PostmanAuth; +import org.apache.camel.component.rest.postman.model.PostmanCollection; +import org.apache.camel.component.rest.postman.model.PostmanItem; +import org.apache.camel.component.rest.postman.model.PostmanJson; +import org.apache.camel.component.rest.postman.model.PostmanRequest; +import org.apache.camel.util.json.JsonObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Flattens the nested item tree of a collection and indexes it for lookup by the URI fragment. + *

+ * The fragment may select a single request, a folder, or nothing at all, which means the whole collection. Requests are + * matched before folders, so that the common case of naming a request costs no extra syntax; a trailing {@code /} + * forces a folder match when a request and a folder share a name. + */ +public final class PostmanRequestIndex { + + /** + * Folders nest recursively in the schema, so the walk is depth limited to keep a deep or hostile document from + * overflowing the stack. + */ + public static final int MAX_FOLDER_DEPTH = 64; + + /** + * An upper bound on how many requests one collection may contribute, as a denial-of-service guard. + */ + public static final int MAX_ITEMS = 5000; + + private static final Logger LOG = LoggerFactory.getLogger(PostmanRequestIndex.class); + + private final List items; + private final Map byId = new LinkedHashMap<>(); + private final Map byCanonicalId = new LinkedHashMap<>(); + private final Map> bySlug = new LinkedHashMap<>(); + private final Map> byFolder = new LinkedHashMap<>(); + + private PostmanRequestIndex(List items, Map> folders) { + this.items = List.copyOf(items); + this.byFolder.putAll(folders); + for (PostmanItem item : items) { + if (item.getId() != null) { + byId.putIfAbsent(item.getId(), item); + } + byCanonicalId.putIfAbsent(item.getCanonicalId(), item); + bySlug.computeIfAbsent(item.getSlug(), k -> new ArrayList<>()).add(item); + } + } + + /** + * Builds an index over a collection. + * + * @param collection the collection + * @param requestFilter comma separated Ant style patterns over qualified slugs, {@code !} prefixed to exclude, or + * {@code null} to keep everything + */ + public static PostmanRequestIndex build(PostmanCollection collection, String requestFilter) { + Walker walker = new Walker(collection.getVariables(), collection.getAuth()); + walker.walk(collection.getItems(), List.of(), 0); + + List kept = walker.raws; + if (requestFilter != null && !requestFilter.isBlank()) { + kept = kept.stream().filter(raw -> PostmanFilters.matches(raw.qualifiedSlug, requestFilter)).toList(); + } + + List items = assignCanonicalIds(kept); + + // a folder is addressable by its own qualified slug and by every prefix of it, so that selecting an outer + // folder also runs everything nested inside it + Map> folders = new LinkedHashMap<>(); + for (PostmanItem item : items) { + List folderSlugs = slugifyFolders(item.getFolderPath()); + StringBuilder prefix = new StringBuilder(); + for (String folderSlug : folderSlugs) { + if (!prefix.isEmpty()) { + prefix.append('/'); + } + prefix.append(folderSlug); + folders.computeIfAbsent(prefix.toString(), k -> new ArrayList<>()).add(item); + } + } + return new PostmanRequestIndex(items, folders); + } + + /** + * Resolves a URI fragment. + * + * @param selector the fragment, or {@code null}/empty for the whole collection + * @return what was selected, never empty + */ + public Selection resolve(String selector) { + if (selector == null || selector.isBlank()) { + if (items.isEmpty()) { + throw new IllegalArgumentException("The Postman collection contains no requests"); + } + return new Selection(items, "the whole collection", false); + } + + String trimmed = selector.trim(); + if (trimmed.endsWith("/")) { + // an explicit folder reference, which is how a folder is selected when a request shares its name + String folder = trimmed.substring(0, trimmed.length() - 1); + List found = byFolder.get(folder); + if (found == null) { + throw new IllegalArgumentException( + "The Postman collection has no folder `" + folder + "`." + describeFolders()); + } + return new Selection(found, "folder `" + folder + "`", false); + } + + // an id recorded by the Postman cloud always wins, as it is unambiguous + if (RestPostmanHelper.isUuid(trimmed)) { + PostmanItem found = byId.get(trimmed); + if (found != null) { + return new Selection(List.of(found), "request " + found.describe(), true); + } + } + + PostmanItem canonical = byCanonicalId.get(trimmed); + if (canonical != null) { + return new Selection(List.of(canonical), "request " + canonical.describe(), true); + } + + List slugMatches = bySlug.get(trimmed); + if (slugMatches != null && slugMatches.size() == 1) { + PostmanItem found = slugMatches.get(0); + return new Selection(List.of(found), "request " + found.describe(), true); + } + if (slugMatches != null && slugMatches.size() > 1) { + String candidates = slugMatches.stream() + .map(PostmanItem::getCanonicalId) + .collect(Collectors.joining("\n\t")); + throw new IllegalArgumentException( + "The Postman collection has " + slugMatches.size() + " requests named `" + trimmed + + "`. Use one of the folder qualified ids instead:\n\t" + candidates); + } + + List folderMatch = byFolder.get(trimmed); + if (folderMatch != null) { + return new Selection(folderMatch, "folder `" + trimmed + "`", false); + } + + throw new IllegalArgumentException( + "The Postman collection has no request or folder `" + trimmed + "`." + describeAll()); + } + + /** + * All requests, in document order. + */ + public List getItems() { + return items; + } + + /** + * Lists every request id, for use in a failure message. + */ + public String describeAll() { + if (items.isEmpty()) { + return " The collection contains no requests."; + } + return " Requests defined in the collection are:\n\t" + + items.stream().map(PostmanItem::getCanonicalId).collect(Collectors.joining("\n\t")); + } + + private String describeFolders() { + if (byFolder.isEmpty()) { + return " The collection contains no folders."; + } + return " Folders defined in the collection are:\n\t" + String.join("\n\t", byFolder.keySet()); + } + + /** + * Decides the canonical id of every request: the plain slug when it is unique, the folder qualified slug when it is + * not, and a numeric suffix in the rare case where even that collides. + */ + private static List assignCanonicalIds(List raws) { + Map slugCounts = raws.stream() + .collect(Collectors.groupingBy(raw -> raw.slug, Collectors.counting())); + + Set taken = new LinkedHashSet<>(); + List answer = new ArrayList<>(raws.size()); + for (Raw raw : raws) { + String candidate = slugCounts.get(raw.slug) == 1 ? raw.slug : raw.qualifiedSlug; + String canonical = candidate; + int suffix = 2; + while (!taken.add(canonical)) { + // two requests with the same name in the same folder, which Postman permits + canonical = candidate + "-" + suffix++; + LOG.warn("Postman collection has more than one request named {} in the same folder." + + " Using `{}` as its id; rename one of them to make routes unambiguous.", + raw.name, canonical); + } + answer.add(new PostmanItem( + raw.json, raw.id, raw.name, raw.folderPath, raw.slug, raw.qualifiedSlug, + canonical, raw.request, raw.auth, raw.variables)); + } + return answer; + } + + private static List slugifyFolders(List folderPath) { + List answer = new ArrayList<>(folderPath.size()); + for (int i = 0; i < folderPath.size(); i++) { + answer.add(RestPostmanHelper.slugify(folderPath.get(i), "folder" + i)); + } + return answer; + } + + /** + * What a URI fragment selected. + * + * @param items the matched requests, never empty + * @param description a human readable description for logs and error messages + * @param single whether exactly one request was named, as opposed to a folder or the whole collection + */ + public record Selection(List items, String description, boolean single) { + } + + /** + * A request captured during the tree walk, before canonical ids can be decided, which needs the whole collection. + */ + private record Raw(JsonObject json, String id, String name, List folderPath, String slug, + String qualifiedSlug, PostmanRequest request, PostmanAuth auth, Map variables) { + } + + /** + * Depth first walk of the item tree, carrying the inherited variable scope and auth block down as it descends. + */ + private static final class Walker { + + private final List raws = new ArrayList<>(); + private final Map rootVariables; + private final PostmanAuth rootAuth; + + Walker(Map rootVariables, PostmanAuth rootAuth) { + this.rootVariables = rootVariables; + this.rootAuth = rootAuth; + } + + void walk(List nodes, List folderPath, int depth) { + walk(nodes, folderPath, depth, rootVariables, rootAuth); + } + + private void walk( + List nodes, List folderPath, int depth, + Map inheritedVariables, PostmanAuth inheritedAuth) { + if (depth > MAX_FOLDER_DEPTH) { + throw new IllegalArgumentException( + "Postman collection nests folders more than " + MAX_FOLDER_DEPTH + " deep at " + + String.join(" / ", folderPath)); + } + int index = 0; + for (Object node : nodes) { + JsonObject entry = PostmanJson.asObject(node); + if (entry == null) { + continue; + } + String name = PostmanJson.asString(entry.get("name")); + + Map scope = new LinkedHashMap<>(inheritedVariables); + scope.putAll(PostmanCollection.variablesOf(entry)); + PostmanAuth auth = PostmanAuth.parse(entry.get("auth")); + PostmanAuth effectiveAuth = auth != null ? auth : inheritedAuth; + + Object children = entry.get("item"); + if (children instanceof List list) { + List nested = new ArrayList<>(folderPath); + nested.add(name != null ? name : "folder" + index); + walk(list, nested, depth + 1, scope, effectiveAuth); + } else { + PostmanRequest request = PostmanRequest.parse(entry.get("request")); + if (request == null) { + LOG.debug("Skipping Postman item {} because it has neither a request nor nested items", name); + } else { + addRequest(entry, name, folderPath, index, request, effectiveAuth, scope); + } + } + index++; + } + } + + private void addRequest( + JsonObject entry, String name, List folderPath, int index, + PostmanRequest request, PostmanAuth auth, Map scope) { + if (raws.size() >= MAX_ITEMS) { + throw new IllegalArgumentException( + "Postman collection contains more than " + MAX_ITEMS + " requests"); + } + String slug = RestPostmanHelper.slugify(name, "request" + index); + List folderSlugs = slugifyFolders(folderPath); + String qualifiedSlug = folderSlugs.isEmpty() ? slug : String.join("/", folderSlugs) + "/" + slug; + + // url.variable supplies defaults for the :name path markers, so it belongs in the request's own scope + Map variables = new LinkedHashMap<>(scope); + request.getUrl().getPathVariables().stream() + .filter(variable -> !variable.disabled()) + .forEach(variable -> variables.put(variable.key(), + variable.value() != null ? variable.value() : "")); + + raws.add(new Raw( + entry, PostmanJson.asString(entry.get("id")), name != null ? name : slug, + folderPath, slug, qualifiedSlug, request, auth, variables)); + } + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanRequestMapper.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanRequestMapper.java new file mode 100644 index 0000000000000..97866614ca3dc --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanRequestMapper.java @@ -0,0 +1,413 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.support; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.apache.camel.CamelContext; +import org.apache.camel.component.rest.postman.RestPostmanConfiguration; +import org.apache.camel.component.rest.postman.RestPostmanHelper; +import org.apache.camel.component.rest.postman.model.PostmanAuth; +import org.apache.camel.component.rest.postman.model.PostmanBody; +import org.apache.camel.component.rest.postman.model.PostmanItem; +import org.apache.camel.component.rest.postman.model.PostmanKeyValue; +import org.apache.camel.component.rest.postman.model.PostmanRequest; +import org.apache.camel.component.rest.postman.model.PostmanUrl; +import org.apache.camel.spi.RestConfiguration; +import org.apache.camel.support.CamelContextHelper; +import org.apache.camel.util.UnsafeUriCharactersEncoder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Turns a {@link PostmanItem} into a {@link PostmanRequestBinding}. + *

+ * This is the one place where the shape of a Postman request is translated into the shape the {@code rest} component + * expects, so both producers and the contract-first consumer stay consistent with each other. + */ +public final class PostmanRequestMapper { + + public static final String QUERY_MODE_LITERAL = "literal"; + + public static final String COLLECTION_AUTH_IGNORE = "ignore"; + public static final String COLLECTION_AUTH_HEADER = "header"; + public static final String COLLECTION_AUTH_FAIL = "fail"; + + private static final Set LOCAL_HOSTS = Set.of("localhost", "127.0.0.1", "::1"); + + private static final Logger LOG = LoggerFactory.getLogger(PostmanRequestMapper.class); + + private final CamelContext camelContext; + private final RestPostmanConfiguration configuration; + private final Map endpointVariables; + private final String resourceOrigin; + + /** + * Auth types already reported, so that a hundred requests sharing a collection level auth block produce one warning + * rather than a hundred. + */ + private final Set reportedAuthTypes = new LinkedHashSet<>(); + + /** + * @param resourceOrigin the scheme and authority of the collection resource when it was loaded over HTTP, used as a + * last resort for the target host. Must be {@code null} for cloud sources, whose origin is + * the Postman API rather than the API being called. + */ + public PostmanRequestMapper(CamelContext camelContext, RestPostmanConfiguration configuration, + Map endpointVariables, String resourceOrigin) { + this.camelContext = camelContext; + this.configuration = configuration; + this.endpointVariables = endpointVariables; + this.resourceOrigin = resourceOrigin; + } + + public PostmanRequestBinding map(PostmanItem item) { + PostmanRequest request = item.getRequest(); + String description = item.describe(); + + Map scope = new LinkedHashMap<>(item.getScopeVariables()); + scope.putAll(endpointVariables); + PostmanVariableResolver resolver + = new PostmanVariableResolver(scope, camelContext, configuration.isFailOnUnresolvedVariable()); + + String method = RestPostmanHelper.validateMethod(request.getMethod(), description); + PostmanUrl url = request.getUrl(); + + Origin origin = resolveOrigin(url, resolver, description); + String basePath = resolveBasePath(origin.basePathSegments(), description); + String uriTemplate = resolveUriTemplate(url, resolver, description); + + Map staticHeaders = new LinkedHashMap<>(); + List queryParts = new ArrayList<>(); + collectQueryParameters(url, resolver, description, queryParts); + + applyCollectionAuth(item, resolver, description, staticHeaders, queryParts); + collectStaticHeaders(request, resolver, description, staticHeaders); + + Map defaultPathValues = new LinkedHashMap<>(); + for (PostmanKeyValue variable : url.getPathVariables()) { + if (!variable.disabled() && variable.value() != null) { + defaultPathValues.put(variable.key(), resolver.resolve(variable.value(), description)); + } + } + + return new PostmanRequestBinding( + item, + method, + origin.host(), + basePath, + uriTemplate, + queryParts.isEmpty() ? null : String.join("&", queryParts), + resolveConsumes(request), + resolveProduces(request), + staticHeaders, + defaultPathValues, + resolveCollectionBody(request, resolver, description)); + } + + /** + * Works out the target host and any path prefix that came with it. + *

+ * The interesting case is that {@code url.host} is normally a bare {@code {{baseUrl}}}, and a Postman base URL + * routinely expands to a complete URL such as {@code https://api.example.com/v1}. So whatever the host expands to + * is re-parsed, and any path it carries becomes the base path. + */ + private Origin resolveOrigin(PostmanUrl url, PostmanVariableResolver resolver, String description) { + if (configuration.getHost() != null) { + return new Origin(configuration.getHost(), List.of()); + } + + String rawHost = resolver.resolve(url.getHost(), description); + String protocol = resolver.resolve(url.getProtocol(), description); + String port = resolver.resolve(url.getPort(), description); + List basePathSegments = List.of(); + + if (rawHost != null && !rawHost.isBlank()) { + PostmanUrl expanded = PostmanUrl.parse(rawHost); + if (expanded.getProtocol() != null) { + protocol = expanded.getProtocol(); + } + if (expanded.getPort() != null) { + port = expanded.getPort(); + } + if (!expanded.getPathSegments().isEmpty()) { + basePathSegments = expanded.getPathSegments(); + } + rawHost = expanded.getHost(); + } + + if (rawHost == null || rawHost.isBlank()) { + String fromRestConfiguration = RestPostmanHelper.hostFrom(restConfiguration()); + if (fromRestConfiguration != null) { + return new Origin(fromRestConfiguration, basePathSegments); + } + if (resourceOrigin != null) { + return new Origin(resourceOrigin, basePathSegments); + } + return new Origin(null, basePathSegments); + } + + if (protocol == null || protocol.isBlank()) { + // Postman itself defaults to https; loopback hosts are almost always plain http in practice + protocol = LOCAL_HOSTS.contains(rawHost.toLowerCase(Locale.ROOT)) ? "http" : "https"; + } + + StringBuilder host = new StringBuilder(protocol).append("://").append(rawHost); + if (port != null && !port.isBlank()) { + host.append(':').append(port); + } + return new Origin(RestPostmanHelper.isHostParam(host.toString()), basePathSegments); + } + + private String resolveBasePath(List derivedSegments, String description) { + if (configuration.getBasePath() != null && !configuration.getBasePath().isEmpty()) { + return normalizePath(configuration.getBasePath()); + } + RestConfiguration restConfiguration = restConfiguration(); + if (restConfiguration != null && restConfiguration.getContextPath() != null + && !restConfiguration.getContextPath().isEmpty()) { + return normalizePath(restConfiguration.getContextPath()); + } + if (derivedSegments.isEmpty()) { + return RestPostmanConfiguration.DEFAULT_BASE_PATH; + } + List encoded = new ArrayList<>(derivedSegments.size()); + for (String segment : derivedSegments) { + encoded.add(UnsafeUriCharactersEncoder.encode( + RestPostmanHelper.validatePathSegment(segment, description))); + } + return "/" + String.join("/", encoded); + } + + private String resolveUriTemplate(PostmanUrl url, PostmanVariableResolver resolver, String description) { + List segments = new ArrayList<>(); + for (String segment : url.getPathSegments()) { + String resolved = resolver.resolve(segment, description); + if (resolved == null || resolved.isEmpty()) { + continue; + } + if (resolved.startsWith(":")) { + // a Postman path parameter becomes the placeholder camel-rest resolves per exchange + segments.add("{" + resolved.substring(1) + "}"); + } else { + segments.add(UnsafeUriCharactersEncoder.encode( + RestPostmanHelper.validatePathSegment(resolved, description))); + } + } + return "/" + String.join("/", segments); + } + + private void collectQueryParameters( + PostmanUrl url, PostmanVariableResolver resolver, String description, + List queryParts) { + boolean literal = QUERY_MODE_LITERAL.equals(configuration.getQueryParameterMode()); + for (PostmanKeyValue param : url.getQueryParams()) { + if (param.disabled()) { + continue; + } + String name = resolver.resolve(param.key(), description); + if (name == null || name.isEmpty()) { + continue; + } + if (literal && param.hasValue()) { + String value = resolver.resolve(param.value(), description); + queryParts.add(name + "=" + UnsafeUriCharactersEncoder.encode(value)); + } else { + // the value in the collection is sample data, so bind the name to a header instead + queryParts.add(RestPostmanHelper.queryParameterExpression(name, false)); + } + } + } + + private void collectStaticHeaders( + PostmanRequest request, PostmanVariableResolver resolver, String description, + Map staticHeaders) { + for (PostmanKeyValue header : request.getHeaders()) { + if (header.disabled()) { + continue; + } + // these two are carried as the consumes/produces options rather than as static headers + if ("Content-Type".equalsIgnoreCase(header.key()) || "Accept".equalsIgnoreCase(header.key())) { + continue; + } + staticHeaders.putIfAbsent(header.key(), resolver.resolve(header.value(), description)); + } + } + + private String resolveConsumes(PostmanRequest request) { + if (configuration.getConsumes() != null) { + return configuration.getConsumes(); + } + // a collection records no response schemas, so there is nothing to infer an Accept header from + return request.getHeader("Accept"); + } + + private String resolveProduces(PostmanRequest request) { + if (configuration.getProduces() != null) { + return configuration.getProduces(); + } + String declared = request.getHeader("Content-Type"); + if (declared != null) { + return declared; + } + PostmanBody body = request.getBody(); + return body != null ? body.inferContentType() : null; + } + + /** + * Renders the body written in the collection, for the case where a whole folder or collection is run and the + * exchange body cannot stand in for every request. + * + * @return the body, or {@code null} when there is none or it cannot be rendered + */ + private String resolveCollectionBody( + PostmanRequest request, PostmanVariableResolver resolver, + String description) { + PostmanBody body = request.getBody(); + if (body == null) { + return null; + } + String mode = body.getMode(); + if (mode == null) { + return null; + } + switch (mode) { + case PostmanBody.MODE_RAW: + case PostmanBody.MODE_GRAPHQL: + return resolver.resolve(body.getRaw(), description); + case PostmanBody.MODE_URLENCODED: + return renderFormBody(body, resolver, description); + case PostmanBody.MODE_FORMDATA: + case PostmanBody.MODE_FILE: + default: + // multipart and file bodies cannot be rebuilt from the collection alone: a file body only records a + // path on the machine of whoever authored the collection, which must never be read + LOG.warn("Postman request {} uses a {} body, which cannot be reconstructed from the collection." + + " The message body of the exchange is sent instead.", + description, mode); + return null; + } + } + + private String renderFormBody(PostmanBody body, PostmanVariableResolver resolver, String description) { + List parts = new ArrayList<>(); + for (PostmanKeyValue field : body.getFormFields()) { + if (field.disabled()) { + continue; + } + String value = resolver.resolve(field.value(), description); + parts.add(URLEncoder.encode(field.key(), StandardCharsets.UTF_8) + + "=" + URLEncoder.encode(value != null ? value : "", StandardCharsets.UTF_8)); + } + return parts.isEmpty() ? null : String.join("&", parts); + } + + /** + * Applies the collection's own auth block to the outgoing request, according to {@code collectionAuth}. + *

+ * This credential is entirely separate from {@code postmanApiKey}: this one authenticates against the API the + * collection describes, the other one authenticates against Postman in order to download the collection. + */ + private void applyCollectionAuth( + PostmanItem item, PostmanVariableResolver resolver, String description, + Map staticHeaders, List queryParts) { + PostmanAuth auth = item.getEffectiveAuth(); + if (auth == null || auth.isNoAuth()) { + return; + } + String mode = configuration.getCollectionAuth(); + + if (COLLECTION_AUTH_FAIL.equals(mode)) { + throw new IllegalArgumentException( + "Postman request " + description + " declares " + auth.getType() + " authentication, and" + + " collectionAuth=fail rejects any auth block. Configure" + + " authentication on the delegate HTTP component instead."); + } + if (COLLECTION_AUTH_IGNORE.equals(mode)) { + if (reportedAuthTypes.add(auth.getType())) { + LOG.warn("Postman collection declares {} authentication, which is not applied because" + + " collectionAuth=ignore. Set collectionAuth=header to apply it, or configure" + + " authentication on the delegate HTTP component.", + auth.getType()); + } + return; + } + + if (!auth.isSupported()) { + throw new IllegalArgumentException( + "Postman request " + description + " declares " + auth.getType() + " authentication, which" + + " cannot be reproduced as a static header because it requires" + + " per-request signing or a token exchange. Configure it on the" + + " delegate HTTP component, or set collectionAuth=ignore."); + } + + switch (auth.getType()) { + case PostmanAuth.TYPE_BASIC -> { + String username = resolver.resolve(auth.getParameterOrDefault("username", ""), description); + String password = resolver.resolve(auth.getParameterOrDefault("password", ""), description); + String encoded = Base64.getEncoder() + .encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8)); + staticHeaders.put("Authorization", "Basic " + encoded); + } + case PostmanAuth.TYPE_BEARER -> { + String token = resolver.resolve(auth.getParameterOrDefault("token", ""), description); + staticHeaders.put("Authorization", "Bearer " + token); + } + case PostmanAuth.TYPE_APIKEY -> { + String key = resolver.resolve(auth.getParameterOrDefault("key", "Authorization"), description); + String value = resolver.resolve(auth.getParameterOrDefault("value", ""), description); + String in = auth.getParameterOrDefault("in", "header"); + if ("query".equalsIgnoreCase(in)) { + queryParts.add(key + "=" + UnsafeUriCharactersEncoder.encode(value)); + } else { + staticHeaders.put(key, value); + } + } + default -> throw new IllegalStateException("Unhandled supported auth type: " + auth.getType()); + } + } + + private RestConfiguration restConfiguration() { + return CamelContextHelper.getRestConfiguration(camelContext, null, configuration.getComponentName()); + } + + private static String normalizePath(String path) { + String answer = path.trim(); + if (!answer.startsWith("/")) { + answer = "/" + answer; + } + while (answer.length() > 1 && answer.endsWith("/")) { + answer = answer.substring(0, answer.length() - 1); + } + return answer; + } + + /** + * A target host together with any path prefix recovered from it. + */ + private record Origin(String host, List basePathSegments) { + } +} diff --git a/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanVariableResolver.java b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanVariableResolver.java new file mode 100644 index 0000000000000..bd53f64db73b2 --- /dev/null +++ b/components/camel-rest-postman/src/main/java/org/apache/camel/component/rest/postman/support/PostmanVariableResolver.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.support; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.camel.CamelContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Resolves the {@code {{name}}} placeholders that Postman collections use in place of environment values. + *

+ * Scopes are layered, with the innermost winning: + *

    + *
  1. collection level {@code variable}
  2. + *
  3. folder level {@code variable}, outermost folder first
  4. + *
  5. the request's own {@code url.variable}
  6. + *
  7. the endpoint's {@code variables} option
  8. + *
  9. Camel property placeholders
  10. + *
+ * The first four are merged into the map handed to the constructor; property placeholders are consulted last, when a + * name is otherwise unknown, so that an operator can override anything the collection ships with. + */ +public final class PostmanVariableResolver { + + /** + * How many times substitution will re-scan its own output. A collection can define {@code a = {{b}}} and {@code b = + * {{a}}}, so the rewriting has to be bounded or it never terminates. + */ + private static final int MAX_DEPTH = 5; + + private static final Pattern PLACEHOLDER = Pattern.compile("\\{\\{([^{}]+)}}"); + + private static final Logger LOG = LoggerFactory.getLogger(PostmanVariableResolver.class); + + private final Map variables; + private final CamelContext camelContext; + private final boolean failOnUnresolved; + + public PostmanVariableResolver(Map variables, CamelContext camelContext, boolean failOnUnresolved) { + this.variables = new LinkedHashMap<>(variables); + this.camelContext = camelContext; + this.failOnUnresolved = failOnUnresolved; + } + + /** + * Substitutes every known placeholder in the text. + *

+ * Unknown placeholders are left verbatim, so that a URL such as {@code {{baseUrl}}/users} still reveals what was + * missing when it later fails, unless {@code failOnUnresolvedVariable} was enabled. + * + * @param text the text to expand, may be {@code null} + * @param context what is being expanded, used in the failure message + * @return the expanded text, or {@code null} when the input was {@code null} + */ + public String resolve(String text, String context) { + if (text == null || text.indexOf('{') < 0) { + return text; + } + + String current = text; + for (int depth = 0; depth < MAX_DEPTH; depth++) { + Matcher matcher = PLACEHOLDER.matcher(current); + StringBuilder answer = new StringBuilder(); + boolean replaced = false; + + while (matcher.find()) { + String name = matcher.group(1).trim(); + String value = lookup(name); + if (value != null) { + matcher.appendReplacement(answer, Matcher.quoteReplacement(value)); + replaced = true; + } else { + matcher.appendReplacement(answer, Matcher.quoteReplacement(matcher.group(0))); + } + } + matcher.appendTail(answer); + current = answer.toString(); + + if (!replaced) { + break; + } + if (depth == MAX_DEPTH - 1 && PLACEHOLDER.matcher(current).find()) { + LOG.warn("Postman variable substitution in {} stopped after {} passes, which usually means two" + + " variables reference each other. Remaining placeholders are left as-is: {}", + context, MAX_DEPTH, current); + } + } + + if (failOnUnresolved) { + Matcher remaining = PLACEHOLDER.matcher(current); + if (remaining.find()) { + throw new IllegalArgumentException( + "Postman variable {{" + remaining.group(1).trim() + "}} used in " + context + + " cannot be resolved. Define it in the collection, or supply it" + + " with the variables option, or set failOnUnresolvedVariable=false."); + } + } + return current; + } + + private String lookup(String name) { + String value = variables.get(name); + if (value != null) { + return value; + } + if (camelContext != null && isSafeToResolveAsProperty(name)) { + try { + // resolvePropertyPlaceholders throws when the key is unknown, which here just means "not ours" + return camelContext.resolvePropertyPlaceholders("{{" + name + "}}"); + } catch (Exception e) { + LOG.trace("Postman variable {} is not a Camel property placeholder either", name, e); + } + } + return null; + } + + /** + * Whether a placeholder name read out of a collection may be handed to Camel's property resolver. + *

+ * Camel's placeholder functions are all written {@code prefix:argument} - {@code env:}, {@code sys:}, + * {@code bean:} and the vault functions among them. A collection is route-author configuration, but a cloud-hosted + * one is editable by anyone with access to the Postman workspace, so letting its content name those functions would + * turn "read the collection" into "read this environment variable and put it in an outgoing request". Plain names + * are resolved as before, so an operator can still override any variable through properties; only the function + * syntax is refused. + */ + private static boolean isSafeToResolveAsProperty(String name) { + if (name.indexOf(':') < 0) { + return true; + } + LOG.debug("Postman variable {} is not resolved from Camel properties because it uses the prefix:value syntax" + + " of a property placeholder function. Supply it with the variables option instead.", + name); + return false; + } + + /** + * The merged variable scope, for diagnostics. + */ + public Map getVariables() { + return Map.copyOf(variables); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/DefaultRestPostmanProcessorStrategyTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/DefaultRestPostmanProcessorStrategyTest.java new file mode 100644 index 0000000000000..1877be22518f0 --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/DefaultRestPostmanProcessorStrategyTest.java @@ -0,0 +1,185 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.util.List; + +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.rest.postman.support.PostmanRequestBinding; +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Exercises the dispatch and validation logic with plain {@code direct} routes and no HTTP server. + */ +class DefaultRestPostmanProcessorStrategyTest { + + private static final String COLLECTION = "classpath:petstore-collection.json"; + + private DefaultCamelContext context; + + @BeforeEach + void setUp() { + context = new DefaultCamelContext(); + } + + @AfterEach + void tearDown() { + context.stop(); + } + + private List bindings(String uri) { + return context.getEndpoint(uri, RestPostmanEndpoint.class).resolveBindings(); + } + + private DefaultRestPostmanProcessorStrategy strategy(String missingRequest) { + DefaultRestPostmanProcessorStrategy strategy = new DefaultRestPostmanProcessorStrategy(); + strategy.setCamelContext(context); + strategy.setMissingRequest(missingRequest); + return strategy; + } + + /** + * The folder qualified id contains a slash, and it is used verbatim as a {@code direct} endpoint name. This pins + * that a slash survives endpoint URI normalisation, which the whole dispatch contract depends on. + */ + @Test + void shouldMatchADirectRouteWhoseNameContainsASlash() throws Exception { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("direct:users/getUserById").to("mock:out"); + } + }); + context.start(); + + assertThat(context.getRoutes().get(0).getEndpoint().getEndpointBaseUri()) + .isEqualTo("direct://users/getUserById"); + } + + @Test + void shouldResolveTheDispatchIdToTheRouteThatExists() throws Exception { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("direct:getPetById").to("mock:out"); + } + }); + context.start(); + + PostmanRequestBinding binding = bindings("rest-postman:" + COLLECTION + "#getPetById").get(0); + + assertThat(strategy("fail").resolveDispatchId(binding)).isEqualTo("getPetById"); + } + + /** + * A collection fetched from the cloud carries request ids, so a route may legitimately be named after one. + */ + @Test + void shouldAlsoAcceptTheRequestIdAsADispatchId() throws Exception { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("direct:3f2504e0-4f89-11d3-9a0c-0305e82c3301").to("mock:out"); + } + }); + context.start(); + + PostmanRequestBinding binding = bindings("rest-postman:" + COLLECTION + "#getPetById").get(0); + + assertThat(strategy("fail").resolveDispatchId(binding)) + .isEqualTo("3f2504e0-4f89-11d3-9a0c-0305e82c3301"); + } + + @Test + void shouldFailWhenARequestHasNoRoute() throws Exception { + context.start(); + List bindings = bindings("rest-postman:" + COLLECTION); + + assertThatThrownBy(() -> strategy("fail").validateCollection(bindings, "/v3", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not mapped to a corresponding route") + .hasMessageContaining("direct:getPetById"); + } + + @Test + void shouldNotFailWhenMissingRequestIsIgnore() throws Exception { + context.start(); + List bindings = bindings("rest-postman:" + COLLECTION); + + assertThatCode(() -> strategy("ignore").validateCollection(bindings, "/v3", null)) + .doesNotThrowAnyException(); + } + + @Test + void shouldNotFailWhenMissingRequestIsMock() throws Exception { + context.start(); + List bindings = bindings("rest-postman:" + COLLECTION); + + assertThatCode(() -> strategy("mock").validateCollection(bindings, "/v3", null)) + .doesNotThrowAnyException(); + } + + @Test + void shouldPassValidationWhenEveryRequestHasARoute() throws Exception { + context.addRoutes(new RouteBuilder() { + @Override + public void configure() { + from("direct:getPetById").to("mock:out"); + from("direct:addPet").to("mock:out"); + from("direct:listPets").to("mock:out"); + } + }); + context.start(); + List bindings = bindings("rest-postman:" + COLLECTION); + + assertThatCode(() -> strategy("fail").validateCollection(bindings, "/v3", null)) + .doesNotThrowAnyException(); + } + + /** + * Two requests on the same method and path would shadow each other in the matcher, which is worse than a loud + * startup failure. + */ + @Test + void shouldFailWhenTwoRequestsShareAMethodAndPath() throws Exception { + context.start(); + List bindings = bindings("rest-postman:classpath:shadowed-collection.json"); + + assertThatThrownBy(() -> strategy("ignore").validateCollection(bindings, "/", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("shadow each other") + .hasMessageContaining("requestFilter"); + } + + @Test + void shouldAllowShadowedRequestsToBeFilteredOut() throws Exception { + context.start(); + List bindings + = bindings("rest-postman:classpath:shadowed-collection.json?requestFilter=!getUserError"); + + assertThat(bindings).hasSize(1); + assertThatCode(() -> strategy("ignore").validateCollection(bindings, "/", null)) + .doesNotThrowAnyException(); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanCloudCollectionTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanCloudCollectionTest.java new file mode 100644 index 0000000000000..9a79f7f829be3 --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanCloudCollectionTest.java @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.nio.charset.StandardCharsets; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import org.apache.camel.ProducerTemplate; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.util.IOHelper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.absent; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Fetches a collection from a stub standing in for the Postman cloud, then calls the API it describes. + *

+ * The point of this test is the separation of the two credentials: the Postman API key must authenticate the collection + * download and must never appear on the call to the API itself. + */ +class RestPostmanCloudCollectionTest { + + private static final String UID = "12ece9e1-2abf-4edc-8e34-de66e74114d2"; + private static final String API_KEY = "PMAK-must-not-reach-the-target-api"; + + private WireMockServer server; + private DefaultCamelContext context; + private ProducerTemplate template; + + @BeforeEach + void setUp() throws Exception { + server = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + server.start(); + WireMock.configureFor("localhost", server.port()); + + // the Postman cloud wraps the collection in a "collection" envelope, and records an id per request + String collection = IOHelper.loadText( + getClass().getClassLoader().getResourceAsStream("petstore-collection.json")); + server.stubFor(get(urlEqualTo("/collections/" + UID)) + .willReturn(aResponse().withStatus(200) + .withBody(("{\"collection\":" + collection + "}").getBytes(StandardCharsets.UTF_8)))); + + context = new DefaultCamelContext(); + context.start(); + template = context.createProducerTemplate(); + } + + @AfterEach + void tearDown() { + context.stop(); + server.stop(); + } + + private String uri(String extra) { + return "rest-postman:" + UID + "#getPetById" + + "?postmanApiUrl=http://localhost:" + server.port() + + "&postmanApiKey=" + API_KEY + + "&variable.baseUrl=http://localhost:" + server.port() + "/v3" + + (extra != null ? "&" + extra : ""); + } + + @Test + void shouldFetchTheCollectionWithTheKeyAndCallTheApiWithout() { + server.stubFor(get(urlPathEqualTo("/v3/pet/42")) + .willReturn(aResponse().withStatus(200).withBody("{\"id\":42}"))); + + String body = template.requestBody(uri(null), null, String.class); + + assertThat(body).isEqualTo("{\"id\":42}"); + + // the collection download carried the Postman API key + WireMock.verify(getRequestedFor(urlEqualTo("/collections/" + UID)) + .withHeader("X-Api-Key", equalTo(API_KEY))); + + // the call to the API the collection describes did not + WireMock.verify(getRequestedFor(urlPathEqualTo("/v3/pet/42")) + .withHeader("X-Api-Key", absent())); + } + + @Test + void shouldFetchTheCollectionOnlyOnceForRepeatedCalls() { + server.stubFor(get(urlPathEqualTo("/v3/pet/42")).willReturn(aResponse().withStatus(200).withBody("ok"))); + + template.requestBody(uri(null), null, String.class); + template.requestBody(uri(null), null, String.class); + + WireMock.verify(1, getRequestedFor(urlEqualTo("/collections/" + UID))); + } + + @Test + void shouldResolveRequestsByTheirCloudId() { + server.stubFor(get(urlPathEqualTo("/v3/pet/42")).willReturn(aResponse().withStatus(200).withBody("ok"))); + + String byId = "rest-postman:" + UID + "#3f2504e0-4f89-11d3-9a0c-0305e82c3301" + + "?postmanApiUrl=http://localhost:" + server.port() + + "&postmanApiKey=" + API_KEY + + "&variable.baseUrl=http://localhost:" + server.port() + "/v3"; + + assertThat(template.requestBody(byId, null, String.class)).isEqualTo("ok"); + } + + @Test + void shouldFailClearlyWhenTheApiKeyIsMissing() { + String noKey = "rest-postman:" + UID + "#getPetById?postmanApiUrl=http://localhost:" + server.port(); + + assertThatThrownBy(() -> template.requestBody(noKey, null, String.class)) + .rootCause() + .hasMessageContaining("postmanApiKey is required") + .hasMessageContaining("collectionSourceType=resource"); + } + + @Test + void shouldRejectAPlainHttpApiUrlToARemoteHost() { + String remote = "rest-postman:" + UID + "#getPetById" + + "?postmanApiUrl=http://api.getpostman.com&postmanApiKey=" + API_KEY; + + assertThatThrownBy(() -> template.requestBody(remote, null, String.class)) + .rootCause() + .hasMessageContaining("would send the Postman API key in clear text"); + } + + /** + * A uid-looking source can be forced to be read as a local resource, which is the escape hatch for a file that + * happens to be named after a UUID. + */ + @Test + void shouldNotFetchFromTheCloudWhenSourceTypeIsResource() { + String asResource = "rest-postman:" + UID + "#getPetById?collectionSourceType=resource"; + + assertThatThrownBy(() -> template.requestBody(asResource, null, String.class)) + .rootCause() + .hasMessageContaining(UID); + WireMock.verify(0, getRequestedFor(urlEqualTo("/collections/" + UID))); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanComponentTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanComponentTest.java new file mode 100644 index 0000000000000..f0de6ed9c9b6b --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanComponentTest.java @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.util.List; +import java.util.Map; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import org.apache.camel.ProducerTemplate; +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end producer tests against a stub HTTP backend. + */ +class RestPostmanComponentTest { + + private static final String COLLECTION = "classpath:petstore-collection.json"; + + private WireMockServer server; + private DefaultCamelContext context; + private ProducerTemplate template; + + @BeforeEach + void setUp() { + server = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + server.start(); + WireMock.configureFor("localhost", server.port()); + + context = new DefaultCamelContext(); + context.start(); + template = context.createProducerTemplate(); + } + + @AfterEach + void tearDown() { + context.stop(); + server.stop(); + } + + /** + * Points the collection's {{baseUrl}} at the stub server, keeping its /v3 path prefix. + */ + private String uri(String fragment, String extra) { + return "rest-postman:" + COLLECTION + (fragment != null ? "#" + fragment : "") + + "?variable.baseUrl=http://localhost:" + server.port() + "/v3" + + (extra != null ? "&" + extra : ""); + } + + @Test + void shouldInvokeASingleRequest() { + server.stubFor(get(urlPathEqualTo("/v3/pet/7")) + .willReturn(aResponse().withStatus(200).withBody("{\"id\":7}"))); + + String body = template.requestBodyAndHeader(uri("getPetById", null), null, "petId", 7, String.class); + + assertThat(body).isEqualTo("{\"id\":7}"); + WireMock.verify(getRequestedFor(urlPathEqualTo("/v3/pet/7")) + // the header declared in the collection is applied, with its variable resolved + .withHeader("X-Tenant", equalTo("acme"))); + } + + @Test + void shouldFallBackToThePathValueDeclaredInTheCollection() { + server.stubFor(get(urlPathEqualTo("/v3/pet/42")) + .willReturn(aResponse().withStatus(200).withBody("{\"id\":42}"))); + + String body = template.requestBody(uri("getPetById", null), null, String.class); + + assertThat(body).isEqualTo("{\"id\":42}"); + } + + @Test + void shouldBindQueryParametersToMessageHeaders() { + server.stubFor(get(urlEqualTo("/v3/pet/7?verbose=false")) + .willReturn(aResponse().withStatus(200).withBody("ok"))); + + String body = template.requestBodyAndHeaders(uri("getPetById", null), null, + Map.of("petId", 7, "verbose", false), String.class); + + assertThat(body).isEqualTo("ok"); + } + + @Test + void shouldLetTheMessageHeaderOverrideTheCollectionHeader() { + server.stubFor(get(urlPathEqualTo("/v3/pet/7")).willReturn(aResponse().withStatus(200).withBody("ok"))); + + template.requestBodyAndHeaders(uri("getPetById", null), null, + Map.of("petId", 7, "X-Tenant", "override"), String.class); + + WireMock.verify(getRequestedFor(urlPathEqualTo("/v3/pet/7")).withHeader("X-Tenant", equalTo("override"))); + } + + @Test + void shouldSendTheExchangeBodyForASingleRequest() { + server.stubFor(post(urlPathEqualTo("/v3/pet")).willReturn(aResponse().withStatus(201).withBody("created"))); + + String body = template.requestBody(uri("addPet", null), "{\"name\":\"Bella\"}", String.class); + + assertThat(body).isEqualTo("created"); + WireMock.verify(postRequestedFor(urlPathEqualTo("/v3/pet")) + .withRequestBody(equalTo("{\"name\":\"Bella\"}")) + .withHeader("Content-Type", WireMock.containing("application/json"))); + } + + @Test + void shouldApplyCollectionBearerAuthWhenEnabled() { + server.stubFor(get(urlPathEqualTo("/v3/pet/42")).willReturn(aResponse().withStatus(200).withBody("ok"))); + + template.requestBody(uri("getPetById", "collectionAuth=header"), null, String.class); + + WireMock.verify(getRequestedFor(urlPathEqualTo("/v3/pet/42")) + .withHeader("Authorization", equalTo("Bearer s3cr3t"))); + } + + @Test + void shouldRunEveryRequestOfAFolder() { + server.stubFor(post(urlPathEqualTo("/v3/pet")).willReturn(aResponse().withStatus(201).withBody("created"))); + server.stubFor(get(urlPathEqualTo("/v3/pet/findByStatus")) + .willReturn(aResponse().withStatus(200).withBody("[]"))); + + @SuppressWarnings("unchecked") + List results = template.requestBody(uri("pets", null), null, List.class); + + assertThat(results).hasSize(2); + assertThat(results).extracting(PostmanRunResult::requestId).containsExactly("addPet", "listPets"); + assertThat(results).allMatch(PostmanRunResult::isSuccess); + assertThat(results).extracting(PostmanRunResult::httpStatus).containsExactly(201, 200); + + // the runner sends the body written in the collection, since one exchange body cannot serve both requests + WireMock.verify(postRequestedFor(urlPathEqualTo("/v3/pet")) + .withRequestBody(equalTo("{\"name\":\"Rex\",\"tenant\":\"acme\"}"))); + } + + @Test + void shouldRunTheWholeCollectionWhenNoRequestIsNamed() { + server.stubFor(get(urlPathEqualTo("/v3/pet/42")).willReturn(aResponse().withStatus(200).withBody("{}"))); + server.stubFor(post(urlPathEqualTo("/v3/pet")).willReturn(aResponse().withStatus(201).withBody("created"))); + server.stubFor(get(urlPathEqualTo("/v3/pet/findByStatus")) + .willReturn(aResponse().withStatus(200).withBody("[]"))); + + @SuppressWarnings("unchecked") + List results = template.requestBody(uri(null, null), null, List.class); + + assertThat(results).extracting(PostmanRunResult::requestId) + .containsExactly("getPetById", "addPet", "listPets"); + } + + @Test + void shouldRecordFailuresPerRequestWhenNotFailingFast() { + server.stubFor(post(urlPathEqualTo("/v3/pet")).willReturn(aResponse().withStatus(500).withBody("boom"))); + server.stubFor(get(urlPathEqualTo("/v3/pet/findByStatus")) + .willReturn(aResponse().withStatus(200).withBody("[]"))); + + @SuppressWarnings("unchecked") + List results + = template.requestBody(uri("pets", "runFailFast=false"), null, List.class); + + assertThat(results).hasSize(2); + assertThat(results.get(0).isSuccess()).isFalse(); + assertThat(results.get(0).failure()).isNotNull(); + // the run continued past the failure + assertThat(results.get(1).isSuccess()).isTrue(); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanEndpointUriParsingTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanEndpointUriParsingTest.java new file mode 100644 index 0000000000000..79fde86df21d0 --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanEndpointUriParsingTest.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class RestPostmanEndpointUriParsingTest { + + private DefaultCamelContext context; + + @BeforeEach + void setUp() { + context = new DefaultCamelContext(); + } + + @AfterEach + void tearDown() { + context.stop(); + } + + @ParameterizedTest + @CsvSource({ + // with an explicit fragment the source and the request are both given + "'rest-postman:my-api.json#getPetById', my-api.json, getPetById", + "'rest-postman:classpath:my-api.json#pets/addPet', classpath:my-api.json, pets/addPet", + "'rest-postman:https://example.com/api.json#getPetById', https://example.com/api.json, getPetById", + // a bare word is a request against the default collection + "'rest-postman:getPetById', postman-collection.json, getPetById", + // a bare .json is the collection, with the whole collection selected + "'rest-postman:my-api.json', my-api.json, ", + // a bare uid is a cloud collection, with the whole collection selected + "'rest-postman:12ece9e1-2abf-4edc-8e34-de66e74114d2', 12ece9e1-2abf-4edc-8e34-de66e74114d2, ", + // an empty fragment means the whole collection too + "'rest-postman:my-api.json#', my-api.json, " + }) + void shouldParseTheUriRemainder(String uri, String expectedSource, String expectedRequestId) { + RestPostmanEndpoint endpoint = context.getEndpoint(uri, RestPostmanEndpoint.class); + + assertThat(endpoint.getCollectionSource()).isEqualTo(expectedSource); + assertThat(endpoint.getRequestId()).isEqualTo(expectedRequestId); + } + + @Test + void shouldFallBackToTheCollectionSourceOfTheComponent() { + RestPostmanComponent component = new RestPostmanComponent(context); + component.setCollectionSource("shared.json"); + context.addComponent("rest-postman", component); + + RestPostmanEndpoint endpoint = context.getEndpoint("rest-postman:getPetById", RestPostmanEndpoint.class); + + assertThat(endpoint.getCollectionSource()).isEqualTo("shared.json"); + assertThat(endpoint.getRequestId()).isEqualTo("getPetById"); + } + + @Test + void shouldBeLenientSoThatUnknownParametersBecomeLiteralValues() { + RestPostmanEndpoint endpoint + = context.getEndpoint("rest-postman:my-api.json#getPetById?version=v3", RestPostmanEndpoint.class); + + assertThat(endpoint.isLenientProperties()).isTrue(); + assertThat(endpoint.parameters).containsEntry("version", "v3"); + } + + @Test + void shouldNotLeakVariableOptionsIntoTheLenientParameters() { + // variable.x is a real multiValue option, so it must be consumed rather than treated as a query value + RestPostmanEndpoint endpoint = context.getEndpoint( + "rest-postman:my-api.json#getPetById?variable.baseUrl=https://x.example.com", RestPostmanEndpoint.class); + + assertThat(endpoint.parameters).doesNotContainKey("variable.baseUrl"); + assertThat(endpoint.getConfiguration().variablesAsStrings()) + .containsEntry("baseUrl", "https://x.example.com"); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanHelperTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanHelperTest.java new file mode 100644 index 0000000000000..ad4dfc114a10c --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanHelperTest.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RestPostmanHelperTest { + + @ParameterizedTest + @CsvSource({ + "'Get User By Id', getUserById", + "'get user by id', getUserById", + "'GET /v1/users', getV1Users", + "'Add-Pet', addPet", + "' Trim Me ', trimMe", + "'Créer Utilisateur', creerUtilisateur", + "'user', user", + "'2FA Verify', r2faVerify", + "'!!!', fallback", + "'', fallback" + }) + void shouldSlugifyNames(String name, String expected) { + assertThat(RestPostmanHelper.slugify(name, "fallback")).isEqualTo(expected); + } + + @Test + void shouldSlugifyNullToFallback() { + assertThat(RestPostmanHelper.slugify(null, "fallback")).isEqualTo("fallback"); + } + + @ParameterizedTest + @ValueSource(strings = { + "3f2504e0-4f89-11d3-9a0c-0305e82c3301", + "12345678-3f2504e0-4f89-11d3-9a0c-0305e82c3301" + }) + void shouldRecogniseUuids(String candidate) { + assertThat(RestPostmanHelper.isUuid(candidate)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { + "getUserById", + "3f2504e0-4f89-11d3-9a0c-0305e82c3301.json", + "users/getUserById", + "not-a-uuid" + }) + void shouldRejectNonUuids(String candidate) { + assertThat(RestPostmanHelper.isUuid(candidate)).isFalse(); + } + + @Test + void shouldRejectNullUuid() { + assertThat(RestPostmanHelper.isUuid(null)).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = { "http://api.example.com", "https://api.example.com", "https://api.example.com:8443" }) + void shouldAcceptAbsoluteHosts(String host) { + assertThat(RestPostmanHelper.isHostParam(host)).isEqualTo(host); + } + + @ParameterizedTest + @ValueSource(strings = { "api.example.com", "ftp://api.example.com", "https://api.example.com/v3" }) + void shouldRejectMalformedHosts(String host) { + assertThatThrownBy(() -> RestPostmanHelper.isHostParam(host)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("host must be an absolute URI"); + } + + @Test + void shouldUpperCaseValidMethods() { + assertThat(RestPostmanHelper.validateMethod("get", "'x'")).isEqualTo("GET"); + } + + @ParameterizedTest + @ValueSource(strings = { "GET:/evil", "GET POST", "GET?x=1", "" }) + void shouldRejectMethodsThatCouldCorruptTheDelegateUri(String method) { + assertThatThrownBy(() -> RestPostmanHelper.validateMethod(method, "'x'")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid HTTP method"); + } + + @ParameterizedTest + @ValueSource(strings = { "a?b", "a#b", "a&b", "a:b" }) + void shouldRejectPathSegmentsThatCouldCorruptTheDelegateUri(String segment) { + assertThatThrownBy(() -> RestPostmanHelper.validatePathSegment(segment, "'x'")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be expressed as a REST endpoint"); + } + + @Test + void shouldAcceptOrdinaryPathSegments() { + assertThat(RestPostmanHelper.validatePathSegment("users", "'x'")).isEqualTo("users"); + } + + @Test + void shouldBuildQueryParameterExpressions() { + assertThat(RestPostmanHelper.queryParameterExpression("status", false)).isEqualTo("status={status?}"); + assertThat(RestPostmanHelper.queryParameterExpression("status", true)).isEqualTo("status={status}"); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanProducerUriTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanProducerUriTest.java new file mode 100644 index 0000000000000..fc4ba18ac2b36 --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/RestPostmanProducerUriTest.java @@ -0,0 +1,200 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman; + +import java.util.List; + +import org.apache.camel.component.rest.postman.support.PostmanRequestBinding; +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Asserts the exact shape of the delegated {@code rest} endpoint, with no network involved. This is the contract + * between this component and {@code camel-rest}, so it is pinned precisely. + */ +class RestPostmanProducerUriTest { + + private static final String COLLECTION = "classpath:petstore-collection.json"; + + private DefaultCamelContext context; + + @BeforeEach + void setUp() { + context = new DefaultCamelContext(); + } + + @AfterEach + void tearDown() { + context.stop(); + } + + private RestPostmanEndpoint endpoint(String uri) { + return context.getEndpoint(uri, RestPostmanEndpoint.class); + } + + @Test + void shouldMapAPathAndQueryRequest() { + RestPostmanEndpoint endpoint = endpoint("rest-postman:" + COLLECTION + "#getPetById"); + + PostmanRequestBinding binding = endpoint.resolveBindings().get(0); + + assertThat(binding.method()).isEqualTo("GET"); + // {{baseUrl}} expands to https://api.example.com/v3, so the path it carries becomes the base path + assertThat(binding.host()).isEqualTo("https://api.example.com"); + assertThat(binding.basePath()).isEqualTo("/v3"); + assertThat(binding.uriTemplate()).isEqualTo("/pet/{petId}"); + assertThat(binding.queryParameters()).isEqualTo("verbose={verbose?}"); + assertThat(binding.staticHeaders()).containsEntry("X-Tenant", "acme"); + assertThat(binding.staticHeaders()).doesNotContainKey("X-Disabled"); + assertThat(binding.defaultPathValues()).containsEntry("petId", "42"); + + // only the braces are escaped: = and ? are safe characters in a Camel endpoint URI + assertThat(endpoint.buildDelegateUri(binding)) + .isEqualTo("rest:GET:/v3:/pet/{petId}?host=https://api.example.com" + + "&queryParameters=verbose=%7Bverbose?%7D"); + } + + @Test + void shouldInferContentTypeFromTheBodyMode() { + PostmanRequestBinding binding + = endpoint("rest-postman:" + COLLECTION + "#addPet").resolveBindings().get(0); + + assertThat(binding.method()).isEqualTo("POST"); + assertThat(binding.produces()).isEqualTo("application/json"); + assertThat(binding.collectionBody()).isEqualTo("{\"name\":\"Rex\",\"tenant\":\"acme\"}"); + } + + /** + * A collection describes no responses, so there is nothing to infer an Accept header from. Inventing one would + * change behaviour versus what Postman itself sends. + */ + @Test + void shouldLeaveConsumesUnsetWhenTheCollectionDoesNotDeclareAccept() { + PostmanRequestBinding binding + = endpoint("rest-postman:" + COLLECTION + "#getPetById").resolveBindings().get(0); + + assertThat(binding.consumes()).isNull(); + } + + @Test + void shouldTreatCollectionQueryValuesAsSampleDataByDefault() { + PostmanRequestBinding binding + = endpoint("rest-postman:" + COLLECTION + "#listPets").resolveBindings().get(0); + + assertThat(binding.queryParameters()).isEqualTo("status={status?}"); + } + + @Test + void shouldSendCollectionQueryValuesInLiteralMode() { + PostmanRequestBinding binding + = endpoint("rest-postman:" + COLLECTION + "#listPets?queryParameterMode=literal") + .resolveBindings().get(0); + + assertThat(binding.queryParameters()).isEqualTo("status=available"); + } + + @Test + void shouldLetTheHostOptionOverrideTheCollection() { + PostmanRequestBinding binding + = endpoint("rest-postman:" + COLLECTION + "#getPetById?host=http://localhost:8080") + .resolveBindings().get(0); + + assertThat(binding.host()).isEqualTo("http://localhost:8080"); + } + + @Test + void shouldLetTheBasePathOptionOverrideTheCollection() { + PostmanRequestBinding binding + = endpoint("rest-postman:" + COLLECTION + "#getPetById?basePath=/api") + .resolveBindings().get(0); + + assertThat(binding.basePath()).isEqualTo("/api"); + } + + @Test + void shouldLetTheVariablesOptionOverrideCollectionVariables() { + PostmanRequestBinding binding + = endpoint("rest-postman:" + COLLECTION + "#getPetById?variable.baseUrl=https://other.example.com/v9") + .resolveBindings().get(0); + + assertThat(binding.host()).isEqualTo("https://other.example.com"); + assertThat(binding.basePath()).isEqualTo("/v9"); + } + + /** + * CAMEL-24113: endpoints are cached by URI, so two requests that differ only in an option carried outside the URI + * would silently share one delegate. + */ + @Test + void shouldProduceDistinctDelegateUrisForDistinctHosts() { + RestPostmanEndpoint first = endpoint("rest-postman:" + COLLECTION + "#getPetById?host=http://one.example.com"); + RestPostmanEndpoint second = endpoint("rest-postman:" + COLLECTION + "#getPetById?host=http://two.example.com"); + + assertThat(first.buildDelegateUri(first.resolveBindings().get(0))) + .isNotEqualTo(second.buildDelegateUri(second.resolveBindings().get(0))); + } + + @Test + void shouldSelectEveryRequestOfAFolder() { + List bindings + = endpoint("rest-postman:" + COLLECTION + "#pets").resolveBindings(); + + assertThat(bindings).extracting(PostmanRequestBinding::id).containsExactly("addPet", "listPets"); + } + + @Test + void shouldSelectEveryRequestOfTheCollectionWhenNoRequestIsNamed() { + List bindings = endpoint("rest-postman:" + COLLECTION).resolveBindings(); + + assertThat(bindings).extracting(PostmanRequestBinding::id) + .containsExactly("getPetById", "addPet", "listPets"); + } + + @Test + void shouldApplyBearerAuthInHeaderMode() { + PostmanRequestBinding binding + = endpoint("rest-postman:" + COLLECTION + "#getPetById?collectionAuth=header") + .resolveBindings().get(0); + + assertThat(binding.staticHeaders()).containsEntry("Authorization", "Bearer s3cr3t"); + } + + @Test + void shouldNotApplyCollectionAuthByDefault() { + PostmanRequestBinding binding + = endpoint("rest-postman:" + COLLECTION + "#getPetById").resolveBindings().get(0); + + assertThat(binding.staticHeaders()).doesNotContainKey("Authorization"); + } + + /** + * The Postman API key authenticates against Postman in order to download the collection. It must never reach the + * endpoint URI of the delegate, which shows up in logs, JMX and the developer console. + */ + @Test + void shouldNeverPutThePostmanApiKeyInTheDelegateUri() { + RestPostmanEndpoint endpoint + = endpoint("rest-postman:" + COLLECTION + "#getPetById?postmanApiKey=PMAK-do-not-leak"); + + assertThat(endpoint.buildDelegateUri(endpoint.resolveBindings().get(0))) + .doesNotContain("PMAK-do-not-leak"); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/collection/PostmanCloudClientTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/collection/PostmanCloudClientTest.java new file mode 100644 index 0000000000000..444eabb33addd --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/collection/PostmanCloudClientTest.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.collection; + +import java.io.IOException; +import java.time.Duration; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PostmanCloudClientTest { + + private static final String UID = "12ece9e1-2abf-4edc-8e34-de66e74114d2"; + private static final String API_KEY = "PMAK-do-not-leak-me"; + private static final String PATH = "/collections/" + UID; + + private WireMockServer server; + + @BeforeEach + void setUp() { + server = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + server.start(); + WireMock.configureFor("localhost", server.port()); + } + + @AfterEach + void tearDown() { + server.stop(); + } + + private PostmanCloudClient client() { + return new PostmanCloudClient( + "http://localhost:" + server.port(), API_KEY, "X-Api-Key", + Duration.ofSeconds(5), Duration.ofSeconds(5), null); + } + + @Test + void shouldSendTheApiKeyHeader() throws Exception { + server.stubFor(get(urlEqualTo(PATH)).willReturn(aResponse().withStatus(200).withBody("{\"collection\":{}}"))); + + String body = client().fetchCollection(UID); + + assertThat(body).isEqualTo("{\"collection\":{}}"); + WireMock.verify(getRequestedFor(urlEqualTo(PATH)).withHeader("X-Api-Key", equalTo(API_KEY))); + } + + @Test + void shouldHonourACustomApiKeyHeaderName() throws Exception { + server.stubFor(get(urlEqualTo(PATH)).willReturn(aResponse().withStatus(200).withBody("{}"))); + + new PostmanCloudClient( + "http://localhost:" + server.port(), API_KEY, "X-Custom-Key", + Duration.ofSeconds(5), Duration.ofSeconds(5), null).fetchCollection(UID); + + WireMock.verify(getRequestedFor(urlEqualTo(PATH)).withHeader("X-Custom-Key", equalTo(API_KEY))); + } + + /** + * Following a redirect would replay the API key to whatever host the Location names. + */ + @Test + void shouldRejectRedirectsRatherThanFollowThem() { + server.stubFor(get(urlEqualTo(PATH)).willReturn( + aResponse().withStatus(302).withHeader("Location", "https://evil.example.com/steal"))); + + assertThatThrownBy(() -> client().fetchCollection(UID)) + .isInstanceOf(IOException.class) + .hasMessageContaining("redirects are blocked") + .hasMessageContaining("https://evil.example.com/steal"); + + // and nothing was sent to the redirect target + WireMock.verify(1, getRequestedFor(urlEqualTo(PATH))); + } + + @Test + void shouldNotLeakTheApiKeyInAFailureMessage() { + server.stubFor(get(urlEqualTo(PATH)).willReturn( + aResponse().withStatus(401).withBody("{\"error\":\"key " + API_KEY + " is invalid\"}"))); + + assertThatThrownBy(() -> client().fetchCollection(UID)) + .isInstanceOf(IOException.class) + .hasMessageContaining("HTTP 401") + // neither the key nor the response body, which may echo it, appears in the message + .hasMessageNotContaining(API_KEY); + } + + @Test + void shouldRejectAnOversizedCollection() { + server.stubFor(get(urlEqualTo(PATH)).willReturn( + aResponse().withStatus(200).withBody("x".repeat((int) PostmanCloudClient.MAX_COLLECTION_BYTES + 1)))); + + assertThatThrownBy(() -> client().fetchCollection(UID)) + .isInstanceOf(IOException.class) + .hasMessageContaining("exceeds maximum size"); + } + + @Test + void shouldRejectAUidThatCouldEscapeThePath() { + assertThatThrownBy(() -> client().fetchCollection("../../admin")) + .hasMessageContaining("illegal characters"); + } + + @Test + void shouldAcceptHttpsApiUrls() { + assertThatCode(() -> PostmanCloudClient.validateApiUrl("https://api.getpostman.com")) + .doesNotThrowAnyException(); + } + + @Test + void shouldAcceptPlainHttpForLoopbackOnly() { + assertThatCode(() -> PostmanCloudClient.validateApiUrl("http://localhost:8080")) + .doesNotThrowAnyException(); + assertThatCode(() -> PostmanCloudClient.validateApiUrl("http://127.0.0.1:8080")) + .doesNotThrowAnyException(); + } + + @Test + void shouldRejectPlainHttpToARemoteHost() { + assertThatThrownBy(() -> PostmanCloudClient.validateApiUrl("http://api.getpostman.com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("would send the Postman API key in clear text"); + } + + @Test + void shouldRejectANonHttpApiUrl() { + assertThatThrownBy(() -> PostmanCloudClient.validateApiUrl("ftp://api.getpostman.com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must use http or https"); + } + + @Test + void shouldRejectARelativeApiUrl() { + assertThatThrownBy(() -> PostmanCloudClient.validateApiUrl("api.getpostman.com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be an absolute URL"); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/collection/PostmanCollectionLoaderTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/collection/PostmanCollectionLoaderTest.java new file mode 100644 index 0000000000000..677f7915f43f0 --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/collection/PostmanCollectionLoaderTest.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.collection; + +import org.apache.camel.RuntimeCamelException; +import org.apache.camel.component.rest.postman.model.PostmanCollection; +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PostmanCollectionLoaderTest { + + private DefaultCamelContext context; + + @BeforeEach + void setUp() { + context = new DefaultCamelContext(); + } + + @AfterEach + void tearDown() { + context.stop(); + } + + @Test + void shouldLoadFromTheClasspath() { + PostmanCollection collection + = PostmanCollectionLoader.loadFromResource(context, "classpath:petstore-collection.json"); + + assertThat(collection.getName()).isEqualTo("Petstore"); + assertThat(collection.getItems()).hasSize(2); + } + + @Test + void shouldUnwrapTheCloudEnvelope() { + PostmanCollection collection = PostmanCollectionLoader.parse( + "{\"collection\":{\"info\":{\"name\":\"Wrapped\",\"schema\":\"v2.1\"},\"item\":[]}}", "test"); + + assertThat(collection.getName()).isEqualTo("Wrapped"); + } + + @Test + void shouldReportAMissingResourceClearly() { + assertThatThrownBy(() -> PostmanCollectionLoader.loadFromResource(context, "classpath:nope.json")) + .isInstanceOf(RuntimeCamelException.class) + .hasMessageContaining("Postman collection not found: classpath:nope.json"); + } + + @Test + void shouldRejectContentThatIsNotJson() { + assertThatThrownBy(() -> PostmanCollectionLoader.parse("not json at all", "test")) + .isInstanceOf(RuntimeCamelException.class) + .hasMessageContaining("not valid JSON"); + } + + @Test + void shouldRejectJsonThatIsNotACollection() { + assertThatThrownBy(() -> PostmanCollectionLoader.parse("{\"hello\":\"world\"}", "test")) + .isInstanceOf(RuntimeCamelException.class) + .hasMessageContaining("the info object is missing"); + } + + @Test + void shouldAcceptACollectionWithAnUnexpectedSchemaVersion() { + // it only warns: refusing outright would block collections that are in practice still readable + PostmanCollection collection = PostmanCollectionLoader.parse( + "{\"info\":{\"name\":\"Old\",\"schema\":\"https://schema.getpostman.com/json/collection/v2.0.0/collection.json\"}," + + "\"item\":[]}", + "test"); + + assertThat(collection.getName()).isEqualTo("Old"); + } + + @ParameterizedTest + @ValueSource(strings = { + "12ece9e1-2abf-4edc-8e34-de66e74114d2", + "12345678-12ece9e1-2abf-4edc-8e34-de66e74114d2" + }) + void shouldDetectCloudSources(String source) { + assertThat(PostmanCollectionLoader.isCloudSource(source, PostmanCollectionLoader.SOURCE_TYPE_AUTO)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { + "petstore.json", + "classpath:petstore.json", + "https://example.com/petstore.json", + "12ece9e1-2abf-4edc-8e34-de66e74114d2.json" + }) + void shouldDetectResourceSources(String source) { + assertThat(PostmanCollectionLoader.isCloudSource(source, PostmanCollectionLoader.SOURCE_TYPE_AUTO)).isFalse(); + } + + @Test + void shouldHonourAnExplicitSourceType() { + assertThat(PostmanCollectionLoader.isCloudSource("petstore.json", PostmanCollectionLoader.SOURCE_TYPE_CLOUD)) + .isTrue(); + assertThat(PostmanCollectionLoader.isCloudSource( + "12ece9e1-2abf-4edc-8e34-de66e74114d2", PostmanCollectionLoader.SOURCE_TYPE_RESOURCE)).isFalse(); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/model/PostmanBodyTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/model/PostmanBodyTest.java new file mode 100644 index 0000000000000..ef579e9f037bb --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/model/PostmanBodyTest.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.model; + +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class PostmanBodyTest { + + private static PostmanBody parse(String json) throws Exception { + return PostmanBody.parse(Jsoner.deserialize(json)); + } + + @ParameterizedTest + @CsvSource({ + "json, application/json", + "xml, application/xml", + "html, text/html", + "javascript, application/javascript", + "text, text/plain", + "JSON, application/json" + }) + void shouldInferContentTypeFromTheRawLanguage(String language, String expected) throws Exception { + PostmanBody body = parse("{\"mode\":\"raw\",\"raw\":\"x\",\"options\":{\"raw\":{\"language\":\"" + + language + "\"}}}"); + + assertThat(body.inferContentType()).isEqualTo(expected); + } + + @Test + void shouldDefaultARawBodyWithoutLanguageToPlainText() throws Exception { + assertThat(parse("{\"mode\":\"raw\",\"raw\":\"x\"}").inferContentType()).isEqualTo("text/plain"); + } + + @ParameterizedTest + @CsvSource({ + "graphql, application/json", + "urlencoded, application/x-www-form-urlencoded", + "formdata, multipart/form-data", + "file, application/octet-stream" + }) + void shouldInferContentTypeFromTheMode(String mode, String expected) throws Exception { + assertThat(parse("{\"mode\":\"" + mode + "\"}").inferContentType()).isEqualTo(expected); + } + + @Test + void shouldReturnNullForAnUnknownMode() throws Exception { + assertThat(parse("{\"mode\":\"martian\"}").inferContentType()).isNull(); + } + + @Test + void shouldTreatAnAbsentBodyAsNoBody() { + assertThat(PostmanBody.parse(null)).isNull(); + } + + @Test + void shouldTreatAnEmptyBodyAsNoBody() throws Exception { + assertThat(parse("{}")).isNull(); + } + + @Test + void shouldTreatADisabledBodyAsNoBody() throws Exception { + assertThat(parse("{\"mode\":\"raw\",\"raw\":\"x\",\"disabled\":true}")).isNull(); + } + + @Test + void shouldReadUrlencodedFields() throws Exception { + PostmanBody body = parse(""" + {"mode":"urlencoded","urlencoded":[{"key":"a","value":"1"}, + {"key":"b","value":"2","disabled":true}]}"""); + + assertThat(body.getFormFields()).hasSize(2); + assertThat(body.getFormFields().get(0).key()).isEqualTo("a"); + assertThat(body.getFormFields().get(1).disabled()).isTrue(); + } + + @Test + void shouldNotReadFormFieldsForARawBody() throws Exception { + assertThat(parse("{\"mode\":\"raw\",\"raw\":\"x\"}").getFormFields()).isEmpty(); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/model/PostmanUrlTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/model/PostmanUrlTest.java new file mode 100644 index 0000000000000..394ce4b3054bd --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/model/PostmanUrlTest.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.model; + +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PostmanUrlTest { + + private static PostmanUrl parse(String json) throws Exception { + return PostmanUrl.parse(Jsoner.deserialize(json)); + } + + @Test + void shouldParseStructuredUrl() throws Exception { + PostmanUrl url = parse(""" + {"protocol":"https","host":["api","example","com"],"port":"8443", + "path":["v3","pet",":petId"], + "query":[{"key":"verbose","value":"true"}], + "variable":[{"key":"petId","value":"42"}]}"""); + + assertThat(url.getProtocol()).isEqualTo("https"); + assertThat(url.getHost()).isEqualTo("api.example.com"); + assertThat(url.getPort()).isEqualTo("8443"); + assertThat(url.getPathSegments()).containsExactly("v3", "pet", ":petId"); + assertThat(url.getQueryParams()).singleElement() + .satisfies(param -> assertThat(param.key()).isEqualTo("verbose")); + assertThat(url.getPathVariables()).singleElement() + .satisfies(variable -> assertThat(variable.value()).isEqualTo("42")); + } + + @Test + void shouldParseBareStringUrl() { + PostmanUrl url = PostmanUrl.parse("https://api.example.com/v3/pet/:petId?verbose=true#frag"); + + assertThat(url.getProtocol()).isEqualTo("https"); + assertThat(url.getHost()).isEqualTo("api.example.com"); + assertThat(url.getPathSegments()).containsExactly("v3", "pet", ":petId"); + assertThat(url.getQueryParams()).singleElement() + .satisfies(param -> { + assertThat(param.key()).isEqualTo("verbose"); + assertThat(param.value()).isEqualTo("true"); + }); + } + + @Test + void shouldRecoverStructureFromRawWhenOnlyRawIsGiven() throws Exception { + PostmanUrl url = parse(""" + {"raw":"https://api.example.com:9090/v3/pet?status=available"}"""); + + assertThat(url.getHost()).isEqualTo("api.example.com"); + assertThat(url.getPort()).isEqualTo("9090"); + assertThat(url.getPathSegments()).containsExactly("v3", "pet"); + assertThat(url.getQueryParams()).singleElement() + .satisfies(param -> assertThat(param.key()).isEqualTo("status")); + } + + @Test + void shouldPreferStructuredFieldsOverRaw() throws Exception { + PostmanUrl url = parse(""" + {"raw":"https://ignored.example.com/nope","host":["api","example","com"],"path":["pet"]}"""); + + assertThat(url.getHost()).isEqualTo("api.example.com"); + assertThat(url.getPathSegments()).containsExactly("pet"); + } + + /** + * The single most common Postman idiom: the host is a placeholder that expands to a complete URL carrying a path + * prefix. Parsing that expansion is what recovers the base path. + */ + @Test + void shouldParseAnExpandedBaseUrlAsAFullUrl() { + PostmanUrl expanded = PostmanUrl.parse("https://api.example.com/v3"); + + assertThat(expanded.getProtocol()).isEqualTo("https"); + assertThat(expanded.getHost()).isEqualTo("api.example.com"); + assertThat(expanded.getPathSegments()).containsExactly("v3"); + } + + @Test + void shouldLeaveUnexpandedPlaceholderHostAlone() throws Exception { + PostmanUrl url = parse(""" + {"host":["{{baseUrl}}"],"path":["pet"]}"""); + + assertThat(url.getHost()).isEqualTo("{{baseUrl}}"); + assertThat(url.getProtocol()).isNull(); + } + + @Test + void shouldParseHostRelativeUrl() { + PostmanUrl url = PostmanUrl.parse("/v3/pet"); + + assertThat(url.getHost()).isNull(); + assertThat(url.getPathSegments()).containsExactly("v3", "pet"); + } + + @Test + void shouldParsePathGivenAsAString() throws Exception { + PostmanUrl url = parse(""" + {"host":["api","example","com"],"path":"v3/pet"}"""); + + assertThat(url.getPathSegments()).containsExactly("v3", "pet"); + } + + @Test + void shouldParsePathSegmentsGivenAsObjects() throws Exception { + PostmanUrl url = parse(""" + {"host":["api","example","com"],"path":[{"value":"v3"},{"value":"pet"}]}"""); + + assertThat(url.getPathSegments()).containsExactly("v3", "pet"); + } + + @Test + void shouldNotMistakeAColonInThePathForAPort() { + PostmanUrl url = PostmanUrl.parse("{{baseUrl}}/pet/:petId"); + + assertThat(url.getHost()).isEqualTo("{{baseUrl}}"); + assertThat(url.getPort()).isNull(); + assertThat(url.getPathSegments()).containsExactly("pet", ":petId"); + } + + @Test + void shouldReturnEmptyUrlForMissingNode() { + PostmanUrl url = PostmanUrl.parse(null); + + assertThat(url.getHost()).isNull(); + assertThat(url.getPathSegments()).isEmpty(); + assertThat(url.getQueryParams()).isEmpty(); + } + + @Test + void shouldSkipQueryEntriesWithoutAKey() throws Exception { + JsonObject node = (JsonObject) Jsoner.deserialize(""" + {"host":["api","example","com"],"query":[{"value":"orphan"},{"key":"ok","value":"1"}]}"""); + + assertThat(PostmanUrl.parse(node).getQueryParams()).singleElement() + .satisfies(param -> assertThat(param.key()).isEqualTo("ok")); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/support/PostmanRedactorTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/support/PostmanRedactorTest.java new file mode 100644 index 0000000000000..e130fadaedde8 --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/support/PostmanRedactorTest.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.support; + +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PostmanRedactorTest { + + private static JsonObject redact(String json) throws Exception { + return PostmanRedactor.redact((JsonObject) Jsoner.deserialize(json)); + } + + @Test + void shouldRemoveTheCollectionAuthBlock() throws Exception { + JsonObject redacted = redact(""" + {"info":{"name":"t"}, + "auth":{"type":"bearer","bearer":[{"key":"token","value":"s3cr3t"}]}, + "item":[]}"""); + + assertThat(redacted).doesNotContainKey("auth"); + assertThat(redacted.toJson()).doesNotContain("s3cr3t"); + } + + @Test + void shouldRemoveNestedAuthBlocks() throws Exception { + JsonObject redacted = redact(""" + {"info":{"name":"t"}, + "item":[{"name":"F","auth":{"type":"basic","basic":[{"key":"password","value":"hunter2"}]}, + "item":[{"name":"R","request":{"method":"GET","url":"https://h/x", + "auth":{"type":"bearer","bearer":[{"key":"token","value":"leaky"}]}}}]}]}"""); + + assertThat(redacted.toJson()).doesNotContain("hunter2").doesNotContain("leaky").doesNotContain("\"auth\""); + } + + @Test + void shouldRedactSecretVariables() throws Exception { + JsonObject redacted = redact(""" + {"info":{"name":"t"}, + "variable":[{"key":"apiToken","value":"s3cr3t","type":"secret"}, + {"key":"baseUrl","value":"https://api.example.com"}], + "item":[]}"""); + + assertThat(redacted.toJson()).doesNotContain("s3cr3t"); + + JsonArray variables = redacted.getJsonArray("variable"); + assertThat(variables.getJsonObject(0).getString("value")).isEqualTo("***"); + // non-secret variables are untouched, as they are needed to make sense of the document + assertThat(variables.getJsonObject(1).getString("value")).isEqualTo("https://api.example.com"); + } + + @Test + void shouldLeaveTheRestOfTheDocumentIntact() throws Exception { + JsonObject redacted = redact(""" + {"info":{"name":"Petstore","schema":"v2.1"}, + "item":[{"name":"Get Pet","request":{"method":"GET","url":"https://api.example.com/pet"}}]}"""); + + assertThat(redacted.getJsonObject("info").getString("name")).isEqualTo("Petstore"); + + JsonObject item = redacted.getJsonArray("item").getJsonObject(0); + assertThat(item.getString("name")).isEqualTo("Get Pet"); + assertThat(item.getJsonObject("request").getString("url")).isEqualTo("https://api.example.com/pet"); + } + + @Test + void shouldNotMutateTheOriginalDocument() throws Exception { + JsonObject original = (JsonObject) Jsoner.deserialize(""" + {"info":{"name":"t"},"auth":{"type":"bearer","bearer":[{"key":"token","value":"s3cr3t"}]}, + "item":[]}"""); + + PostmanRedactor.redact(original); + + assertThat(original).containsKey("auth"); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/support/PostmanRequestIndexTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/support/PostmanRequestIndexTest.java new file mode 100644 index 0000000000000..19ed5098f0675 --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/support/PostmanRequestIndexTest.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.support; + +import org.apache.camel.component.rest.postman.model.PostmanCollection; +import org.apache.camel.component.rest.postman.model.PostmanItem; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PostmanRequestIndexTest { + + private static PostmanRequestIndex index(String json, String filter) throws Exception { + return PostmanRequestIndex.build(PostmanCollection.parse((JsonObject) Jsoner.deserialize(json)), filter); + } + + private static String collection(String items) { + return "{\"info\":{\"name\":\"t\",\"schema\":\"v2.1\"},\"item\":[" + items + "]}"; + } + + private static String request(String name) { + return "{\"name\":\"" + name + "\",\"request\":{\"method\":\"GET\",\"url\":\"https://h/x\"}}"; + } + + private static String requestWithId(String name, String id) { + return "{\"id\":\"" + id + "\",\"name\":\"" + name + + "\",\"request\":{\"method\":\"GET\",\"url\":\"https://h/x\"}}"; + } + + private static String folder(String name, String items) { + return "{\"name\":\"" + name + "\",\"item\":[" + items + "]}"; + } + + @Test + void shouldResolveByUniqueSlug() throws Exception { + PostmanRequestIndex index = index(collection(request("Get User By Id")), null); + + PostmanRequestIndex.Selection selection = index.resolve("getUserById"); + + assertThat(selection.single()).isTrue(); + assertThat(selection.items()).singleElement() + .extracting(PostmanItem::getName).isEqualTo("Get User By Id"); + } + + @Test + void shouldPreferAnExactIdOverASlug() throws Exception { + String id = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; + PostmanRequestIndex index = index(collection(requestWithId("Get User", id)), null); + + assertThat(index.resolve(id).items()).singleElement() + .extracting(PostmanItem::getName).isEqualTo("Get User"); + } + + @Test + void shouldQualifyCollidingSlugsWithTheirFolder() throws Exception { + PostmanRequestIndex index = index(collection( + folder("Users", request("Get")) + "," + folder("Pets", request("Get"))), null); + + assertThat(index.getItems()).extracting(PostmanItem::getCanonicalId) + .containsExactly("users/get", "pets/get"); + assertThat(index.resolve("users/get").items()).singleElement() + .extracting(PostmanItem::getFolderPath).isEqualTo(java.util.List.of("Users")); + } + + @Test + void shouldRejectAnAmbiguousSlug() throws Exception { + PostmanRequestIndex index = index(collection( + folder("Users", request("Get")) + "," + folder("Pets", request("Get"))), null); + + assertThatThrownBy(() -> index.resolve("get")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("2 requests named `get`") + .hasMessageContaining("users/get") + .hasMessageContaining("pets/get"); + } + + @Test + void shouldDisambiguateDuplicateNamesInTheSameFolderWithASuffix() throws Exception { + PostmanRequestIndex index = index(collection(request("Get") + "," + request("Get")), null); + + assertThat(index.getItems()).extracting(PostmanItem::getCanonicalId) + .containsExactly("get", "get-2"); + } + + @Test + void shouldSelectTheWholeCollectionWhenNoSelectorIsGiven() throws Exception { + PostmanRequestIndex index = index(collection( + request("A") + "," + folder("Users", request("B") + "," + request("C"))), null); + + PostmanRequestIndex.Selection selection = index.resolve(null); + + assertThat(selection.single()).isFalse(); + assertThat(selection.description()).isEqualTo("the whole collection"); + assertThat(selection.items()).hasSize(3); + } + + @Test + void shouldSelectAFolder() throws Exception { + PostmanRequestIndex index = index(collection( + request("A") + "," + folder("Users", request("B") + "," + request("C"))), null); + + PostmanRequestIndex.Selection selection = index.resolve("users"); + + assertThat(selection.single()).isFalse(); + assertThat(selection.items()).extracting(PostmanItem::getName).containsExactly("B", "C"); + } + + @Test + void shouldSelectANestedFolderByItsOuterFolder() throws Exception { + PostmanRequestIndex index = index(collection( + folder("Api", folder("Users", request("B")) + "," + request("C"))), null); + + assertThat(index.resolve("api").items()).hasSize(2); + assertThat(index.resolve("api/users").items()).extracting(PostmanItem::getName).containsExactly("B"); + } + + @Test + void shouldForceAFolderMatchWithATrailingSlash() throws Exception { + // a folder and a request that slugify to the same thing + PostmanRequestIndex index = index(collection( + request("Users") + "," + folder("Users", request("B"))), null); + + assertThat(index.resolve("users").single()).isTrue(); + assertThat(index.resolve("users/").single()).isFalse(); + assertThat(index.resolve("users/").items()).extracting(PostmanItem::getName).containsExactly("B"); + } + + @Test + void shouldListCandidatesWhenNothingMatches() throws Exception { + PostmanRequestIndex index = index(collection(request("Get User")), null); + + assertThatThrownBy(() -> index.resolve("nope")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no request or folder `nope`") + .hasMessageContaining("getUser"); + } + + @Test + void shouldApplyAnIncludeFilter() throws Exception { + PostmanRequestIndex index = index(collection( + folder("Users", request("A")) + "," + folder("Pets", request("B"))), "users/**"); + + assertThat(index.getItems()).extracting(PostmanItem::getName).containsExactly("A"); + } + + @Test + void shouldApplyAnExcludeFilter() throws Exception { + PostmanRequestIndex index = index(collection( + folder("Users", request("A")) + "," + folder("Pets", request("B"))), "!pets/**"); + + assertThat(index.getItems()).extracting(PostmanItem::getName).containsExactly("A"); + } + + @Test + void shouldInheritVariablesFromCollectionAndFolder() throws Exception { + String json = "{\"info\":{\"name\":\"t\",\"schema\":\"v2.1\"}," + + "\"variable\":[{\"key\":\"a\",\"value\":\"1\"},{\"key\":\"b\",\"value\":\"root\"}]," + + "\"item\":[{\"name\":\"F\",\"variable\":[{\"key\":\"b\",\"value\":\"folder\"}]," + + "\"item\":[" + request("R") + "]}]}"; + + PostmanItem item = index(json, null).getItems().get(0); + + assertThat(item.getScopeVariables()).containsEntry("a", "1").containsEntry("b", "folder"); + } + + @Test + void shouldInheritAuthFromTheNearestScope() throws Exception { + String json = "{\"info\":{\"name\":\"t\",\"schema\":\"v2.1\"}," + + "\"auth\":{\"type\":\"bearer\",\"bearer\":[{\"key\":\"token\",\"value\":\"root\"}]}," + + "\"item\":[{\"name\":\"F\"," + + "\"auth\":{\"type\":\"basic\",\"basic\":[{\"key\":\"username\",\"value\":\"u\"}]}," + + "\"item\":[" + request("R") + "]}]}"; + + PostmanItem item = index(json, null).getItems().get(0); + + assertThat(item.getEffectiveAuth().getType()).isEqualTo("basic"); + } + + @Test + void shouldSkipItemsThatAreNeitherRequestNorFolder() throws Exception { + PostmanRequestIndex index = index(collection("{\"name\":\"empty\"}," + request("A")), null); + + assertThat(index.getItems()).extracting(PostmanItem::getName).containsExactly("A"); + } + + @Test + void shouldExpandTheShorthandStringRequest() throws Exception { + PostmanRequestIndex index = index( + collection("{\"name\":\"Ping\",\"request\":\"https://api.example.com/ping\"}"), null); + + PostmanItem item = index.getItems().get(0); + assertThat(item.getRequest().getMethod()).isEqualTo("GET"); + assertThat(item.getRequest().getUrl().getHost()).isEqualTo("api.example.com"); + } +} diff --git a/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/support/PostmanVariableResolverTest.java b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/support/PostmanVariableResolverTest.java new file mode 100644 index 0000000000000..271d0a8956307 --- /dev/null +++ b/components/camel-rest-postman/src/test/java/org/apache/camel/component/rest/postman/support/PostmanVariableResolverTest.java @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.rest.postman.support; + +import java.util.Map; +import java.util.Properties; + +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PostmanVariableResolverTest { + + private static PostmanVariableResolver resolver(Map variables) { + return new PostmanVariableResolver(variables, null, false); + } + + @Test + void shouldSubstituteKnownPlaceholders() { + PostmanVariableResolver resolver = resolver(Map.of("baseUrl", "https://api.example.com")); + + assertThat(resolver.resolve("{{baseUrl}}/pet", "'x'")).isEqualTo("https://api.example.com/pet"); + } + + @Test + void shouldSubstituteSeveralPlaceholdersInOneValue() { + PostmanVariableResolver resolver = resolver(Map.of("a", "1", "b", "2")); + + assertThat(resolver.resolve("{{a}}-{{b}}", "'x'")).isEqualTo("1-2"); + } + + @Test + void shouldTolerateWhitespaceInsideThePlaceholder() { + PostmanVariableResolver resolver = resolver(Map.of("a", "1")); + + assertThat(resolver.resolve("{{ a }}", "'x'")).isEqualTo("1"); + } + + @Test + void shouldResolveChainedPlaceholders() { + PostmanVariableResolver resolver = resolver(Map.of("a", "{{b}}", "b", "final")); + + assertThat(resolver.resolve("{{a}}", "'x'")).isEqualTo("final"); + } + + @Test + void shouldLeaveUnknownPlaceholdersVerbatim() { + PostmanVariableResolver resolver = resolver(Map.of()); + + assertThat(resolver.resolve("{{missing}}/pet", "'x'")).isEqualTo("{{missing}}/pet"); + } + + @Test + void shouldTerminateOnCircularReferences() { + PostmanVariableResolver resolver = resolver(Map.of("a", "{{b}}", "b", "{{a}}")); + + // the point is that this returns rather than looping forever; what it returns is unresolved either way + assertThat(resolver.resolve("{{a}}", "'x'")).contains("{{"); + } + + @Test + void shouldFailOnUnresolvedWhenConfiguredTo() { + PostmanVariableResolver resolver = new PostmanVariableResolver(Map.of(), null, true); + + assertThatThrownBy(() -> resolver.resolve("{{missing}}", "request 'x'")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("{{missing}}") + .hasMessageContaining("request 'x'") + .hasMessageContaining("failOnUnresolvedVariable=false"); + } + + @Test + void shouldPassThroughTextWithoutPlaceholders() { + assertThat(resolver(Map.of("a", "1")).resolve("plain", "'x'")).isEqualTo("plain"); + } + + @Test + void shouldPassThroughNull() { + assertThat(resolver(Map.of()).resolve(null, "'x'")).isNull(); + } + + /** + * A cloud-hosted collection is editable by anyone with access to the Postman workspace, so its content must not be + * able to name a Camel property placeholder function and pull an environment variable into an outgoing request. + */ + @Test + void shouldNotResolvePropertyPlaceholderFunctionsFromCollectionContent() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + context.start(); + PostmanVariableResolver resolver = new PostmanVariableResolver(Map.of(), context, false); + + assertThat(resolver.resolve("{{env:PATH}}", "'x'")).isEqualTo("{{env:PATH}}"); + assertThat(resolver.resolve("{{sys:user.home}}", "'x'")).isEqualTo("{{sys:user.home}}"); + assertThat(resolver.resolve("{{bean:foo}}", "'x'")).isEqualTo("{{bean:foo}}"); + } + } + + @Test + void shouldStillResolvePlainNamesFromCamelProperties() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + context.getPropertiesComponent().setInitialProperties(propertiesOf("myBaseUrl", "https://from.properties")); + context.start(); + PostmanVariableResolver resolver = new PostmanVariableResolver(Map.of(), context, false); + + assertThat(resolver.resolve("{{myBaseUrl}}", "'x'")).isEqualTo("https://from.properties"); + } + } + + @Test + void shouldPreferTheCollectionScopeOverCamelProperties() throws Exception { + try (DefaultCamelContext context = new DefaultCamelContext()) { + context.getPropertiesComponent().setInitialProperties(propertiesOf("baseUrl", "https://from.properties")); + context.start(); + PostmanVariableResolver resolver + = new PostmanVariableResolver(Map.of("baseUrl", "https://from.collection"), context, false); + + assertThat(resolver.resolve("{{baseUrl}}", "'x'")).isEqualTo("https://from.collection"); + } + } + + private static Properties propertiesOf(String key, String value) { + Properties properties = new Properties(); + properties.setProperty(key, value); + return properties; + } + + @Test + void shouldNotTreatReplacementValueAsARegexReplacement() { + // a value containing $ or \ must be inserted literally, not interpreted as a group reference + PostmanVariableResolver resolver = resolver(Map.of("token", "a$1b\\c")); + + assertThat(resolver.resolve("{{token}}", "'x'")).isEqualTo("a$1b\\c"); + } +} diff --git a/components/camel-rest-postman/src/test/resources/petstore-collection.json b/components/camel-rest-postman/src/test/resources/petstore-collection.json new file mode 100644 index 0000000000000..8dedfdeb1374f --- /dev/null +++ b/components/camel-rest-postman/src/test/resources/petstore-collection.json @@ -0,0 +1,145 @@ +{ + "info": { + "_postman_id": "0b3f0f52-4c1e-4a3a-9e57-3a2b4a1f9c11", + "name": "Petstore", + "description": "A small collection used to test camel-rest-postman.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://api.example.com/v3" + }, + { + "key": "tenant", + "value": "acme" + }, + { + "key": "apiToken", + "value": "s3cr3t", + "type": "secret" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{apiToken}}", + "type": "string" + } + ] + }, + "item": [ + { + "id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301", + "name": "Get Pet By Id", + "request": { + "method": "GET", + "header": [ + { + "key": "X-Tenant", + "value": "{{tenant}}" + }, + { + "key": "X-Disabled", + "value": "nope", + "disabled": true + } + ], + "url": { + "raw": "{{baseUrl}}/pet/:petId?verbose=true", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "pet", + ":petId" + ], + "query": [ + { + "key": "verbose", + "value": "true" + } + ], + "variable": [ + { + "key": "petId", + "value": "42" + } + ] + } + }, + "response": [ + { + "name": "found", + "code": 200, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\"id\":42,\"name\":\"Rex\"}" + } + ] + }, + { + "name": "Pets", + "item": [ + { + "name": "Add Pet", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\"name\":\"Rex\",\"tenant\":\"{{tenant}}\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/pet", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "pet" + ] + } + } + }, + { + "name": "List Pets", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/pet/findByStatus?status=available", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "pet", + "findByStatus" + ], + "query": [ + { + "key": "status", + "value": "available" + } + ] + } + } + } + ] + } + ] +} diff --git a/components/camel-rest-postman/src/test/resources/shadowed-collection.json b/components/camel-rest-postman/src/test/resources/shadowed-collection.json new file mode 100644 index 0000000000000..61279d120af1d --- /dev/null +++ b/components/camel-rest-postman/src/test/resources/shadowed-collection.json @@ -0,0 +1,46 @@ +{ + "info": { + "name": "Shadowed", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://api.example.com" + } + ], + "item": [ + { + "name": "Get User Success", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/users/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + ":id" + ] + } + } + }, + { + "name": "Get User Error", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/users/:id", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "users", + ":id" + ] + } + } + } + ] +} diff --git a/components/pom.xml b/components/pom.xml index 5c9fca05136b3..84f645a0db55c 100644 --- a/components/pom.xml +++ b/components/pom.xml @@ -354,8 +354,9 @@ camel-zipfile camel-zookeeper-master camel-zookeeper - + camel-rest-openapi + camel-rest-postman camel-jooq diff --git a/core/camel-main/src/generated/resources/org/apache/camel/main/components.properties b/core/camel-main/src/generated/resources/org/apache/camel/main/components.properties index 883f470b7e2ce..e8a80790f9c4c 100644 --- a/core/camel-main/src/generated/resources/org/apache/camel/main/components.properties +++ b/core/camel-main/src/generated/resources/org/apache/camel/main/components.properties @@ -314,6 +314,7 @@ ref rest rest-api rest-openapi +rest-postman robotframework rocketmq rss diff --git a/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java b/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java index c342d5019cba2..718c0eaaf23d7 100644 --- a/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java +++ b/core/camel-util/src/main/java/org/apache/camel/util/SensitiveUtils.java @@ -103,6 +103,7 @@ public final class SensitiveUtils { "passphrase", "password", "personalaccesstoken", + "postmanapikey", "privatekey", "privatekeyfile", "privatekeyname", @@ -202,6 +203,7 @@ public final class SensitiveUtils { + "|\\Qpassphrase\\E" + "|\\Qpassword\\E" + "|\\Qpersonalaccesstoken\\E" + + "|\\Qpostmanapikey\\E" + "|\\Qprivatekey\\E" + "|\\Qprivatekeyfile\\E" + "|\\Qprivatekeyname\\E" diff --git a/docs/components/modules/ROOT/examples/json/rest-postman.json b/docs/components/modules/ROOT/examples/json/rest-postman.json new file mode 120000 index 0000000000000..9363524fd4997 --- /dev/null +++ b/docs/components/modules/ROOT/examples/json/rest-postman.json @@ -0,0 +1 @@ +../../../../../../components/camel-rest-postman/src/generated/resources/META-INF/org/apache/camel/component/rest/postman/rest-postman.json \ No newline at end of file diff --git a/docs/components/modules/ROOT/nav.adoc b/docs/components/modules/ROOT/nav.adoc index 9f63cc2a09785..f92937294b7f8 100644 --- a/docs/components/modules/ROOT/nav.adoc +++ b/docs/components/modules/ROOT/nav.adoc @@ -332,6 +332,7 @@ ** xref:rest-component.adoc[REST] ** xref:rest-api-component.adoc[REST API] ** xref:rest-openapi-component.adoc[REST OpenApi] +** xref:rest-postman-component.adoc[REST Postman] ** xref:robotframework-component.adoc[Robot Framework] ** xref:rocketmq-component.adoc[RocketMQ] ** xref:rss-component.adoc[RSS] diff --git a/docs/components/modules/ROOT/pages/rest-postman-component.adoc b/docs/components/modules/ROOT/pages/rest-postman-component.adoc new file mode 120000 index 0000000000000..23e10a71f8e7d --- /dev/null +++ b/docs/components/modules/ROOT/pages/rest-postman-component.adoc @@ -0,0 +1 @@ +../../../../../components/camel-rest-postman/src/main/docs/rest-postman-component.adoc \ No newline at end of file diff --git a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/ComponentsBuilderFactory.java b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/ComponentsBuilderFactory.java index 4da9e1666ef9b..ef69e1b6f0574 100644 --- a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/ComponentsBuilderFactory.java +++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/ComponentsBuilderFactory.java @@ -4251,6 +4251,19 @@ static RestApiComponentBuilderFactory.RestApiComponentBuilder restApi() { static RestOpenapiComponentBuilderFactory.RestOpenapiComponentBuilder restOpenapi() { return RestOpenapiComponentBuilderFactory.restOpenapi(); } + /** + * REST Postman (camel-rest-postman) + * To call and expose REST services using a Postman Collection as contract. + * + * Category: rest,api + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-rest-postman + * + * @return the dsl builder + */ + static RestPostmanComponentBuilderFactory.RestPostmanComponentBuilder restPostman() { + return RestPostmanComponentBuilderFactory.restPostman(); + } /** * Robot Framework (camel-robotframework) * Pass camel exchanges to acceptance test written in Robot DSL. diff --git a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/RestPostmanComponentBuilderFactory.java b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/RestPostmanComponentBuilderFactory.java new file mode 100644 index 0000000000000..8f9aea274aba6 --- /dev/null +++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/RestPostmanComponentBuilderFactory.java @@ -0,0 +1,713 @@ +/* Generated by camel build tools - do NOT edit this file! */ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.builder.component.dsl; + +import javax.annotation.processing.Generated; +import org.apache.camel.Component; +import org.apache.camel.builder.component.AbstractComponentBuilder; +import org.apache.camel.builder.component.ComponentBuilder; +import org.apache.camel.component.rest.postman.RestPostmanComponent; + +/** + * To call and expose REST services using a Postman Collection as contract. + * + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.ComponentDslMojo") +public interface RestPostmanComponentBuilderFactory { + + /** + * REST Postman (camel-rest-postman) + * To call and expose REST services using a Postman Collection as contract. + * + * Category: rest,api + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-rest-postman + * + * @return the dsl builder + */ + static RestPostmanComponentBuilder restPostman() { + return new RestPostmanComponentBuilderImpl(); + } + + /** + * Builder for the REST Postman component. + */ + interface RestPostmanComponentBuilder extends ComponentBuilder { + + /** + * API basePath, for example /v2. Default is unset, if set overrides the + * value derived from the request URL in the collection. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: common + * + * @param basePath the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder basePath(java.lang.String basePath) { + doSetProperty("basePath", basePath); + return this; + } + + /** + * The Postman Collection to use, when it is not given on the endpoint. + * Either a resource URI of a Collection v2.1 JSON document (classpath:, + * file: or http:), or the uid of a collection to fetch from the Postman + * cloud. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: common + * + * @param collectionSource the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder collectionSource(java.lang.String collectionSource) { + doSetProperty("collectionSource", collectionSource); + return this; + } + + + /** + * How to interpret collectionSource. With auto, a bare collection UUID + * or {ownerId}-{uuid} is fetched from the Postman cloud and anything + * else is resolved as a resource (classpath:, file:, http:). Use + * resource or cloud to decide explicitly. + * + * The option is a: <code>java.lang.String</code> type. + * + * Default: auto + * Group: common + * + * @param collectionSourceType the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder collectionSourceType(java.lang.String collectionSourceType) { + doSetProperty("collectionSourceType", collectionSourceType); + return this; + } + + /** + * Values for the {{variable}} placeholders used in the collection. + * These override the variables declared by the collection and its + * folders. This is a multi-value option with prefix: variable. + * + * The option is a: <code>java.util.Map&lt;java.lang.String, + * java.lang.Object&gt;</code> type. + * + * Group: common + * + * @param variables the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder variables(java.util.Map variables) { + doSetProperty("variables", variables); + return this; + } + + + /** + * Whether to fail if a {{variable}} placeholder used by the selected + * request cannot be resolved. When false the placeholder is left as-is. + * + * The option is a: <code>boolean</code> type. + * + * Default: false + * Group: common (advanced) + * + * @param failOnUnresolvedVariable the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder failOnUnresolvedVariable(boolean failOnUnresolvedVariable) { + doSetProperty("failOnUnresolvedVariable", failOnUnresolvedVariable); + return this; + } + + /** + * Sets the context-path to use for servicing the Postman collection + * document. The document is served with all auth blocks and all secret + * variables removed. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: consumer + * + * @param apiContextPath the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder apiContextPath(java.lang.String apiContextPath) { + doSetProperty("apiContextPath", apiContextPath); + return this; + } + + + /** + * Allows for bridging the consumer to the Camel routing Error Handler, + * which mean any exceptions (if possible) occurred while the Camel + * consumer is trying to pickup incoming messages, or the likes, will + * now be processed as a message and handled by the routing Error + * Handler. Important: This is only possible if the 3rd party component + * allows Camel to be alerted if an exception was thrown. Some + * components handle this internally only, and therefore + * bridgeErrorHandler is not possible. In other situations we may + * improve the Camel component to hook into the 3rd party component and + * make this possible for future releases. By default the consumer will + * use the org.apache.camel.spi.ExceptionHandler to deal with + * exceptions, that will be logged at WARN or ERROR level and ignored. + * + * The option is a: <code>boolean</code> type. + * + * Default: false + * Group: consumer + * + * @param bridgeErrorHandler the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder bridgeErrorHandler(boolean bridgeErrorHandler) { + doSetProperty("bridgeErrorHandler", bridgeErrorHandler); + return this; + } + + + /** + * Whether to enable validation of the client request. A Postman + * collection has no schemas, so this is a best-effort check of required + * headers, query parameters and body presence only. + * + * The option is a: <code>boolean</code> type. + * + * Default: false + * Group: consumer + * + * @param clientRequestValidation the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder clientRequestValidation(boolean clientRequestValidation) { + doSetProperty("clientRequestValidation", clientRequestValidation); + return this; + } + + + /** + * Whether the consumer should fail, ignore or return a mock response + * for requests in the collection that are not mapped to a corresponding + * route. + * + * The option is a: <code>java.lang.String</code> type. + * + * Default: fail + * Group: consumer + * + * @param missingRequest the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder missingRequest(java.lang.String missingRequest) { + doSetProperty("missingRequest", missingRequest); + return this; + } + + /** + * Name of the Camel component that will service the requests. The + * component must be present in Camel registry and it must be able to + * service contract-first REST consumers, as platform-http does. If not + * set CLASSPATH is searched for a single component with that + * capability. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: consumer (advanced) + * + * @param consumerComponentName the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder consumerComponentName(java.lang.String consumerComponentName) { + doSetProperty("consumerComponentName", consumerComponentName); + return this; + } + + + /** + * Used for inclusive filtering of mock data from directories. The + * pattern is using Ant-path style pattern. Multiple patterns can be + * specified separated by comma. Saved example responses in the + * collection are preferred over these files. + * + * The option is a: <code>java.lang.String</code> type. + * + * Default: classpath:camel-mock/** + * Group: consumer (advanced) + * + * @param mockIncludePattern the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder mockIncludePattern(java.lang.String mockIncludePattern) { + doSetProperty("mockIncludePattern", mockIncludePattern); + return this; + } + + /** + * Filters which requests of the collection are used, as comma separated + * Ant-style patterns matched against the folder qualified request id. + * Prefix a pattern with ! to exclude. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: consumer (advanced) + * + * @param requestFilter the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder requestFilter(java.lang.String requestFilter) { + doSetProperty("requestFilter", requestFilter); + return this; + } + + /** + * To use a custom strategy for how to service the requests of the + * collection. + * + * The option is a: + * <code>org.apache.camel.component.rest.postman.RestPostmanProcessorStrategy</code> type. + * + * Group: consumer (advanced) + * + * @param restPostmanProcessorStrategy the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder restPostmanProcessorStrategy(org.apache.camel.component.rest.postman.RestPostmanProcessorStrategy restPostmanProcessorStrategy) { + doSetProperty("restPostmanProcessorStrategy", restPostmanProcessorStrategy); + return this; + } + + /** + * Scheme hostname and port to direct the HTTP requests to in the form + * of https://hostname:port. If set overrides any value derived from the + * collection. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: producer + * + * @param host the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder host(java.lang.String host) { + doSetProperty("host", host); + return this; + } + + + /** + * Whether the producer should be started lazy (on the first message). + * By starting lazy you can use this to allow CamelContext and routes to + * startup in situations where a producer may otherwise fail during + * starting and cause the route to fail being started. By deferring this + * startup to be lazy then the startup failure can be handled during + * routing messages via Camel's routing error handlers. Beware that when + * the first message is processed then creating and starting the + * producer may take a little time and prolong the total processing time + * of the processing. + * + * The option is a: <code>boolean</code> type. + * + * Default: false + * Group: producer + * + * @param lazyStartProducer the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder lazyStartProducer(boolean lazyStartProducer) { + doSetProperty("lazyStartProducer", lazyStartProducer); + return this; + } + + + /** + * When the endpoint runs more than one request, that is when it selects + * a folder or the whole collection, whether to stop and fail on the + * first request that fails. When false every request is attempted and + * the failure is recorded in its result. + * + * The option is a: <code>boolean</code> type. + * + * Default: true + * Group: producer + * + * @param runFailFast the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder runFailFast(boolean runFailFast) { + doSetProperty("runFailFast", runFailFast); + return this; + } + + /** + * Name of the Camel component that will perform the requests. The + * component must be present in Camel registry and it must implement + * RestProducerFactory service provider interface. If not set CLASSPATH + * is searched for single component that implements RestProducerFactory + * SPI. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: producer (advanced) + * + * @param componentName the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder componentName(java.lang.String componentName) { + doSetProperty("componentName", componentName); + return this; + } + + /** + * What payload type this component is capable of consuming. This + * equates to the value of the Accept HTTP header. A Postman collection + * does not describe responses, so unlike an OpenAPI specification there + * is nothing to infer this from and it is unset by default. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: producer (advanced) + * + * @param consumes the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder consumes(java.lang.String consumes) { + doSetProperty("consumes", consumes); + return this; + } + + /** + * What payload type this component is producing. This equates to the + * value of the Content-Type HTTP header. If not set it is inferred from + * the body mode of the request in the collection. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: producer (advanced) + * + * @param produces the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder produces(java.lang.String produces) { + doSetProperty("produces", produces); + return this; + } + + + /** + * How to treat the query parameters declared in the collection. With + * placeholder the parameter names are bound to message headers and the + * values in the collection are ignored as sample data. With literal the + * values in the collection are sent as-is. + * + * The option is a: <code>java.lang.String</code> type. + * + * Default: placeholder + * Group: producer (advanced) + * + * @param queryParameterMode the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder queryParameterMode(java.lang.String queryParameterMode) { + doSetProperty("queryParameterMode", queryParameterMode); + return this; + } + + + /** + * Whether autowiring is enabled. This is used for automatic autowiring + * options (the option must be marked as autowired) by looking up in the + * registry to find if there is a single instance of matching type, + * which then gets configured on the component. This can be used for + * automatic configuring JDBC data sources, JMS connection factories, + * AWS Clients, etc. + * + * The option is a: <code>boolean</code> type. + * + * Default: true + * Group: advanced + * + * @param autowiredEnabled the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder autowiredEnabled(boolean autowiredEnabled) { + doSetProperty("autowiredEnabled", autowiredEnabled); + return this; + } + + + /** + * How long a loaded collection is cached, in milliseconds. Use -1 to + * cache for the lifetime of the component. + * + * The option is a: <code>long</code> type. + * + * Default: -1 + * Group: advanced + * + * @param collectionCacheTtl the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder collectionCacheTtl(long collectionCacheTtl) { + doSetProperty("collectionCacheTtl", collectionCacheTtl); + return this; + } + + /** + * The shared configuration used as the template for every endpoint + * created by this component. + * + * The option is a: + * <code>org.apache.camel.component.rest.postman.RestPostmanConfiguration</code> type. + * + * Group: advanced + * + * @param configuration the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder configuration(org.apache.camel.component.rest.postman.RestPostmanConfiguration configuration) { + doSetProperty("configuration", configuration); + return this; + } + + + /** + * Connection timeout in milliseconds when fetching a collection from + * the Postman cloud. + * + * The option is a: <code>long</code> type. + * + * Default: 15000 + * Group: advanced + * + * @param connectTimeout the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder connectTimeout(long connectTimeout) { + doSetProperty("connectTimeout", connectTimeout); + return this; + } + + + /** + * Request timeout in milliseconds when fetching a collection from the + * Postman cloud. + * + * The option is a: <code>long</code> type. + * + * Default: 30000 + * Group: advanced + * + * @param requestTimeout the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder requestTimeout(long requestTimeout) { + doSetProperty("requestTimeout", requestTimeout); + return this; + } + + + /** + * What to do with the auth block the collection declares for the target + * API. With ignore the block is not applied, and a warning names the + * type that was found. With header the basic, bearer and apikey types + * are applied as a static header or query parameter, and any other type + * fails at startup rather than silently sending no credential. With + * fail any auth block other than noauth is rejected. + * + * The option is a: <code>java.lang.String</code> type. + * + * Default: ignore + * Group: security + * + * @param collectionAuth the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder collectionAuth(java.lang.String collectionAuth) { + doSetProperty("collectionAuth", collectionAuth); + return this; + } + + /** + * The OAuth profile to use for authenticating the incoming requests. + * The profile is enforced by the consumer component servicing the + * requests. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: security + * + * @param oauthProfile the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder oauthProfile(java.lang.String oauthProfile) { + doSetProperty("oauthProfile", oauthProfile); + return this; + } + + /** + * The Postman API key used to fetch the collection from the Postman + * cloud. This credential authenticates against Postman itself and is + * never sent to the API the collection describes. + * + * The option is a: <code>java.lang.String</code> type. + * + * Group: security + * + * @param postmanApiKey the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder postmanApiKey(java.lang.String postmanApiKey) { + doSetProperty("postmanApiKey", postmanApiKey); + return this; + } + + + /** + * The HTTP header used to send the Postman API key when fetching a + * collection. + * + * The option is a: <code>java.lang.String</code> type. + * + * Default: X-Api-Key + * Group: security + * + * @param postmanApiKeyHeader the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder postmanApiKeyHeader(java.lang.String postmanApiKeyHeader) { + doSetProperty("postmanApiKeyHeader", postmanApiKeyHeader); + return this; + } + + /** + * Customize TLS parameters used by the component. If not set defaults + * to the TLS parameters set in the Camel context. These parameters are + * used both when fetching a collection from the Postman cloud and by + * the delegate producer. + * + * The option is a: + * <code>org.apache.camel.support.jsse.SSLContextParameters</code> type. + * + * Group: security + * + * @param sslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder sslContextParameters(org.apache.camel.support.jsse.SSLContextParameters sslContextParameters) { + doSetProperty("sslContextParameters", sslContextParameters); + return this; + } + + + /** + * Enable usage of global SSL context parameters. + * + * The option is a: <code>boolean</code> type. + * + * Default: false + * Group: security + * + * @param useGlobalSslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder useGlobalSslContextParameters(boolean useGlobalSslContextParameters) { + doSetProperty("useGlobalSslContextParameters", useGlobalSslContextParameters); + return this; + } + + + /** + * The base URL of the Postman API used to fetch collections. Must use + * https, except for localhost, because plain http would send the + * Postman API key in clear text. + * + * The option is a: <code>java.lang.String</code> type. + * + * Default: https://api.getpostman.com + * Group: security (advanced) + * + * @param postmanApiUrl the value to set + * @return the dsl builder + */ + default RestPostmanComponentBuilder postmanApiUrl(java.lang.String postmanApiUrl) { + doSetProperty("postmanApiUrl", postmanApiUrl); + return this; + } + } + + class RestPostmanComponentBuilderImpl + extends AbstractComponentBuilder + implements RestPostmanComponentBuilder { + @Override + protected RestPostmanComponent buildConcreteComponent() { + return new RestPostmanComponent(); + } + private org.apache.camel.component.rest.postman.RestPostmanConfiguration getOrCreateConfiguration(RestPostmanComponent component) { + if (component.getConfiguration() == null) { + component.setConfiguration(new org.apache.camel.component.rest.postman.RestPostmanConfiguration()); + } + return component.getConfiguration(); + } + @Override + protected boolean setPropertyOnComponent( + Component component, + String name, + Object value) { + switch (name) { + case "basePath": getOrCreateConfiguration((RestPostmanComponent) component).setBasePath((java.lang.String) value); return true; + case "collectionSource": ((RestPostmanComponent) component).setCollectionSource((java.lang.String) value); return true; + case "collectionSourceType": getOrCreateConfiguration((RestPostmanComponent) component).setCollectionSourceType((java.lang.String) value); return true; + case "variables": getOrCreateConfiguration((RestPostmanComponent) component).setVariables((java.util.Map) value); return true; + case "failOnUnresolvedVariable": getOrCreateConfiguration((RestPostmanComponent) component).setFailOnUnresolvedVariable((boolean) value); return true; + case "apiContextPath": getOrCreateConfiguration((RestPostmanComponent) component).setApiContextPath((java.lang.String) value); return true; + case "bridgeErrorHandler": ((RestPostmanComponent) component).setBridgeErrorHandler((boolean) value); return true; + case "clientRequestValidation": getOrCreateConfiguration((RestPostmanComponent) component).setClientRequestValidation((boolean) value); return true; + case "missingRequest": getOrCreateConfiguration((RestPostmanComponent) component).setMissingRequest((java.lang.String) value); return true; + case "consumerComponentName": getOrCreateConfiguration((RestPostmanComponent) component).setConsumerComponentName((java.lang.String) value); return true; + case "mockIncludePattern": getOrCreateConfiguration((RestPostmanComponent) component).setMockIncludePattern((java.lang.String) value); return true; + case "requestFilter": getOrCreateConfiguration((RestPostmanComponent) component).setRequestFilter((java.lang.String) value); return true; + case "restPostmanProcessorStrategy": ((RestPostmanComponent) component).setRestPostmanProcessorStrategy((org.apache.camel.component.rest.postman.RestPostmanProcessorStrategy) value); return true; + case "host": getOrCreateConfiguration((RestPostmanComponent) component).setHost((java.lang.String) value); return true; + case "lazyStartProducer": ((RestPostmanComponent) component).setLazyStartProducer((boolean) value); return true; + case "runFailFast": getOrCreateConfiguration((RestPostmanComponent) component).setRunFailFast((boolean) value); return true; + case "componentName": getOrCreateConfiguration((RestPostmanComponent) component).setComponentName((java.lang.String) value); return true; + case "consumes": getOrCreateConfiguration((RestPostmanComponent) component).setConsumes((java.lang.String) value); return true; + case "produces": getOrCreateConfiguration((RestPostmanComponent) component).setProduces((java.lang.String) value); return true; + case "queryParameterMode": getOrCreateConfiguration((RestPostmanComponent) component).setQueryParameterMode((java.lang.String) value); return true; + case "autowiredEnabled": ((RestPostmanComponent) component).setAutowiredEnabled((boolean) value); return true; + case "collectionCacheTtl": getOrCreateConfiguration((RestPostmanComponent) component).setCollectionCacheTtl((long) value); return true; + case "configuration": ((RestPostmanComponent) component).setConfiguration((org.apache.camel.component.rest.postman.RestPostmanConfiguration) value); return true; + case "connectTimeout": getOrCreateConfiguration((RestPostmanComponent) component).setConnectTimeout((long) value); return true; + case "requestTimeout": getOrCreateConfiguration((RestPostmanComponent) component).setRequestTimeout((long) value); return true; + case "collectionAuth": getOrCreateConfiguration((RestPostmanComponent) component).setCollectionAuth((java.lang.String) value); return true; + case "oauthProfile": getOrCreateConfiguration((RestPostmanComponent) component).setOauthProfile((java.lang.String) value); return true; + case "postmanApiKey": getOrCreateConfiguration((RestPostmanComponent) component).setPostmanApiKey((java.lang.String) value); return true; + case "postmanApiKeyHeader": getOrCreateConfiguration((RestPostmanComponent) component).setPostmanApiKeyHeader((java.lang.String) value); return true; + case "sslContextParameters": getOrCreateConfiguration((RestPostmanComponent) component).setSslContextParameters((org.apache.camel.support.jsse.SSLContextParameters) value); return true; + case "useGlobalSslContextParameters": getOrCreateConfiguration((RestPostmanComponent) component).setUseGlobalSslContextParameters((boolean) value); return true; + case "postmanApiUrl": getOrCreateConfiguration((RestPostmanComponent) component).setPostmanApiUrl((java.lang.String) value); return true; + default: return false; + } + } + } +} \ No newline at end of file diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilderFactory.java index b49ce736393a2..83a4781d4449f 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilderFactory.java @@ -318,6 +318,7 @@ public interface EndpointBuilderFactory org.apache.camel.builder.endpoint.dsl.RestApiEndpointBuilderFactory.RestApiBuilders, org.apache.camel.builder.endpoint.dsl.RestEndpointBuilderFactory.RestBuilders, org.apache.camel.builder.endpoint.dsl.RestOpenApiEndpointBuilderFactory.RestOpenApiBuilders, + org.apache.camel.builder.endpoint.dsl.RestPostmanEndpointBuilderFactory.RestPostmanBuilders, org.apache.camel.builder.endpoint.dsl.RobotFrameworkEndpointBuilderFactory.RobotFrameworkBuilders, org.apache.camel.builder.endpoint.dsl.RocketMQEndpointBuilderFactory.RocketMQBuilders, org.apache.camel.builder.endpoint.dsl.RssEndpointBuilderFactory.RssBuilders, diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilders.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilders.java index 2a2b029521791..5674d6acd2e8e 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilders.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointBuilders.java @@ -315,6 +315,7 @@ public interface EndpointBuilders org.apache.camel.builder.endpoint.dsl.RestApiEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.RestEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.RestOpenApiEndpointBuilderFactory, + org.apache.camel.builder.endpoint.dsl.RestPostmanEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.RobotFrameworkEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.RocketMQEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.RssEndpointBuilderFactory, diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointHeaderBuilders.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointHeaderBuilders.java index d0d15c98d316b..143e729785b25 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointHeaderBuilders.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/EndpointHeaderBuilders.java @@ -3293,6 +3293,19 @@ public static ReactiveStreamsEndpointBuilderFactory.ReactiveStreamsHeaderNameBui public static RestEndpointBuilderFactory.RestHeaderNameBuilder restEndpoint() { return RestEndpointBuilderFactory.RestHeaderNameBuilder.INSTANCE; } + /** + * REST Postman (camel-rest-postman) + * To call and expose REST services using a Postman Collection as contract. + * + * Category: rest,api + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-rest-postman + * + * @return the dsl builder for the headers' name. + */ + public static RestPostmanEndpointBuilderFactory.RestPostmanHeaderNameBuilder restPostman() { + return RestPostmanEndpointBuilderFactory.RestPostmanHeaderNameBuilder.INSTANCE; + } /** * Robot Framework (camel-robotframework) * Pass camel exchanges to acceptance test written in Robot DSL. diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/StaticEndpointBuilders.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/StaticEndpointBuilders.java index 52460b2cf8071..c3cbe197944e2 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/StaticEndpointBuilders.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/StaticEndpointBuilders.java @@ -14182,6 +14182,68 @@ public static RestOpenApiEndpointBuilderFactory.RestOpenApiEndpointBuilder restO public static RestOpenApiEndpointBuilderFactory.RestOpenApiEndpointBuilder restOpenapi(String componentName, String path) { return RestOpenApiEndpointBuilderFactory.endpointBuilder(componentName, path); } + /** + * REST Postman (camel-rest-postman) + * To call and expose REST services using a Postman Collection as contract. + * + * Category: rest,api + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-rest-postman + * + * Syntax: rest-postman:collectionSource#requestId + * + * Path parameter: collectionSource + * The Postman Collection to use. Either a resource URI of a Collection v2.1 + * JSON document (classpath:, file: or http:), or the uid of a collection to + * fetch from the Postman cloud, which requires postmanApiKey. Default value + * notice: By default loads the postman-collection.json file + * Default value: postman-collection.json + * + * Path parameter: requestId + * The request to invoke, identified by its id in the collection or by its + * slugified name, for example getUserById. Use a folder id to run every + * request in that folder, and leave it out to run the whole collection. + * Append a slash to force a folder match when a request and a folder share + * a name. + * + * @param path collectionSource#requestId + * @return the dsl builder + */ + public static RestPostmanEndpointBuilderFactory.RestPostmanEndpointBuilder restPostman(String path) { + return restPostman("rest-postman", path); + } + /** + * REST Postman (camel-rest-postman) + * To call and expose REST services using a Postman Collection as contract. + * + * Category: rest,api + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-rest-postman + * + * Syntax: rest-postman:collectionSource#requestId + * + * Path parameter: collectionSource + * The Postman Collection to use. Either a resource URI of a Collection v2.1 + * JSON document (classpath:, file: or http:), or the uid of a collection to + * fetch from the Postman cloud, which requires postmanApiKey. Default value + * notice: By default loads the postman-collection.json file + * Default value: postman-collection.json + * + * Path parameter: requestId + * The request to invoke, identified by its id in the collection or by its + * slugified name, for example getUserById. Use a folder id to run every + * request in that folder, and leave it out to run the whole collection. + * Append a slash to force a folder match when a request and a folder share + * a name. + * + * @param componentName to use a custom component name for the endpoint + * instead of the default name + * @param path collectionSource#requestId + * @return the dsl builder + */ + public static RestPostmanEndpointBuilderFactory.RestPostmanEndpointBuilder restPostman(String componentName, String path) { + return RestPostmanEndpointBuilderFactory.endpointBuilder(componentName, path); + } /** * Robot Framework (camel-robotframework) * Pass camel exchanges to acceptance test written in Robot DSL. diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/RestPostmanEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/RestPostmanEndpointBuilderFactory.java new file mode 100644 index 0000000000000..0ec3f7b5a1181 --- /dev/null +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/RestPostmanEndpointBuilderFactory.java @@ -0,0 +1,1702 @@ +/* Generated by camel build tools - do NOT edit this file! */ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.builder.endpoint.dsl; + +import java.util.*; +import java.util.concurrent.*; +import java.util.function.*; +import java.util.stream.*; +import javax.annotation.processing.Generated; +import org.apache.camel.builder.EndpointConsumerBuilder; +import org.apache.camel.builder.EndpointProducerBuilder; +import org.apache.camel.builder.endpoint.AbstractEndpointBuilder; + +/** + * To call and expose REST services using a Postman Collection as contract. + * + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.EndpointDslMojo") +public interface RestPostmanEndpointBuilderFactory { + + /** + * Builder for endpoint consumers for the REST Postman component. + */ + public interface RestPostmanEndpointConsumerBuilder + extends + EndpointConsumerBuilder { + default AdvancedRestPostmanEndpointConsumerBuilder advanced() { + return (AdvancedRestPostmanEndpointConsumerBuilder) this; + } + /** + * API basePath, for example /v2. Default is unset, if set overrides the + * value derived from the request URL in the collection. + * + * The option is a: java.lang.String type. + * + * Group: common + * + * @param basePath the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder basePath(String basePath) { + doSetProperty("basePath", basePath); + return this; + } + /** + * How to interpret collectionSource. With auto, a bare collection UUID + * or {ownerId}-{uuid} is fetched from the Postman cloud and anything + * else is resolved as a resource (classpath:, file:, http:). Use + * resource or cloud to decide explicitly. + * + * The option is a: java.lang.String type. + * + * Default: auto + * Group: common + * + * @param collectionSourceType the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder collectionSourceType(String collectionSourceType) { + doSetProperty("collectionSourceType", collectionSourceType); + return this; + } + /** + * Values for the {{variable}} placeholders used in the collection. + * These override the variables declared by the collection and its + * folders. This is a multi-value option with prefix: variable. + * + * The option is a: java.util.Map<java.lang.String, + * java.lang.Object> type. + * The option is multivalued, and you can use the variables(String, + * Object) method to add a value (call the method multiple times to set + * more values). + * + * Group: common + * + * @param key the option key + * @param value the option value + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder variables(String key, Object value) { + doSetMultiValueProperty("variables", "variable." + key, value); + return this; + } + /** + * Values for the {{variable}} placeholders used in the collection. + * These override the variables declared by the collection and its + * folders. This is a multi-value option with prefix: variable. + * + * The option is a: java.util.Map<java.lang.String, + * java.lang.Object> type. + * The option is multivalued, and you can use the variables(String, + * Object) method to add a value (call the method multiple times to set + * more values). + * + * Group: common + * + * @param values the values + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder variables(Map values) { + doSetMultiValueProperties("variables", "variable.", values); + return this; + } + /** + * Sets the context-path to use for servicing the Postman collection + * document. The document is served with all auth blocks and all secret + * variables removed. + * + * The option is a: java.lang.String type. + * + * Group: consumer + * + * @param apiContextPath the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder apiContextPath(String apiContextPath) { + doSetProperty("apiContextPath", apiContextPath); + return this; + } + /** + * Whether to enable validation of the client request. A Postman + * collection has no schemas, so this is a best-effort check of required + * headers, query parameters and body presence only. + * + * The option is a: boolean type. + * + * Default: false + * Group: consumer + * + * @param clientRequestValidation the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder clientRequestValidation(boolean clientRequestValidation) { + doSetProperty("clientRequestValidation", clientRequestValidation); + return this; + } + /** + * Whether to enable validation of the client request. A Postman + * collection has no schemas, so this is a best-effort check of required + * headers, query parameters and body presence only. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: consumer + * + * @param clientRequestValidation the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder clientRequestValidation(String clientRequestValidation) { + doSetProperty("clientRequestValidation", clientRequestValidation); + return this; + } + /** + * Whether the consumer should fail, ignore or return a mock response + * for requests in the collection that are not mapped to a corresponding + * route. + * + * The option is a: java.lang.String type. + * + * Default: fail + * Group: consumer + * + * @param missingRequest the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder missingRequest(String missingRequest) { + doSetProperty("missingRequest", missingRequest); + return this; + } + /** + * What to do with the auth block the collection declares for the target + * API. With ignore the block is not applied, and a warning names the + * type that was found. With header the basic, bearer and apikey types + * are applied as a static header or query parameter, and any other type + * fails at startup rather than silently sending no credential. With + * fail any auth block other than noauth is rejected. + * + * The option is a: java.lang.String type. + * + * Default: ignore + * Group: security + * + * @param collectionAuth the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder collectionAuth(String collectionAuth) { + doSetProperty("collectionAuth", collectionAuth); + return this; + } + /** + * The OAuth profile to use for authenticating the incoming requests. + * The profile is enforced by the consumer component servicing the + * requests. + * + * The option is a: java.lang.String type. + * + * Group: security + * + * @param oauthProfile the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder oauthProfile(String oauthProfile) { + doSetProperty("oauthProfile", oauthProfile); + return this; + } + /** + * The Postman API key used to fetch the collection from the Postman + * cloud. This credential authenticates against Postman itself and is + * never sent to the API the collection describes. + * + * The option is a: java.lang.String type. + * + * Group: security + * + * @param postmanApiKey the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder postmanApiKey(String postmanApiKey) { + doSetProperty("postmanApiKey", postmanApiKey); + return this; + } + /** + * The HTTP header used to send the Postman API key when fetching a + * collection. + * + * The option is a: java.lang.String type. + * + * Default: X-Api-Key + * Group: security + * + * @param postmanApiKeyHeader the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder postmanApiKeyHeader(String postmanApiKeyHeader) { + doSetProperty("postmanApiKeyHeader", postmanApiKeyHeader); + return this; + } + /** + * Customize TLS parameters used by the component. If not set defaults + * to the TLS parameters set in the Camel context. These parameters are + * used both when fetching a collection from the Postman cloud and by + * the delegate producer. + * + * The option is a: + * org.apache.camel.support.jsse.SSLContextParameters type. + * + * Group: security + * + * @param sslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder sslContextParameters(org.apache.camel.support.jsse.SSLContextParameters sslContextParameters) { + doSetProperty("sslContextParameters", sslContextParameters); + return this; + } + /** + * Customize TLS parameters used by the component. If not set defaults + * to the TLS parameters set in the Camel context. These parameters are + * used both when fetching a collection from the Postman cloud and by + * the delegate producer. + * + * The option will be converted to a + * org.apache.camel.support.jsse.SSLContextParameters type. + * + * Group: security + * + * @param sslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder sslContextParameters(String sslContextParameters) { + doSetProperty("sslContextParameters", sslContextParameters); + return this; + } + /** + * Enable usage of global SSL context parameters. + * + * The option is a: boolean type. + * + * Default: false + * Group: security + * + * @param useGlobalSslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder useGlobalSslContextParameters(boolean useGlobalSslContextParameters) { + doSetProperty("useGlobalSslContextParameters", useGlobalSslContextParameters); + return this; + } + /** + * Enable usage of global SSL context parameters. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: security + * + * @param useGlobalSslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanEndpointConsumerBuilder useGlobalSslContextParameters(String useGlobalSslContextParameters) { + doSetProperty("useGlobalSslContextParameters", useGlobalSslContextParameters); + return this; + } + } + + /** + * Advanced builder for endpoint consumers for the REST Postman component. + */ + public interface AdvancedRestPostmanEndpointConsumerBuilder + extends + EndpointConsumerBuilder { + default RestPostmanEndpointConsumerBuilder basic() { + return (RestPostmanEndpointConsumerBuilder) this; + } + /** + * Whether to fail if a {{variable}} placeholder used by the selected + * request cannot be resolved. When false the placeholder is left as-is. + * + * The option is a: boolean type. + * + * Default: false + * Group: common (advanced) + * + * @param failOnUnresolvedVariable the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder failOnUnresolvedVariable(boolean failOnUnresolvedVariable) { + doSetProperty("failOnUnresolvedVariable", failOnUnresolvedVariable); + return this; + } + /** + * Whether to fail if a {{variable}} placeholder used by the selected + * request cannot be resolved. When false the placeholder is left as-is. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: common (advanced) + * + * @param failOnUnresolvedVariable the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder failOnUnresolvedVariable(String failOnUnresolvedVariable) { + doSetProperty("failOnUnresolvedVariable", failOnUnresolvedVariable); + return this; + } + /** + * Allows for bridging the consumer to the Camel routing Error Handler, + * which mean any exceptions (if possible) occurred while the Camel + * consumer is trying to pickup incoming messages, or the likes, will + * now be processed as a message and handled by the routing Error + * Handler. Important: This is only possible if the 3rd party component + * allows Camel to be alerted if an exception was thrown. Some + * components handle this internally only, and therefore + * bridgeErrorHandler is not possible. In other situations we may + * improve the Camel component to hook into the 3rd party component and + * make this possible for future releases. By default the consumer will + * use the org.apache.camel.spi.ExceptionHandler to deal with + * exceptions, that will be logged at WARN or ERROR level and ignored. + * + * The option is a: boolean type. + * + * Default: false + * Group: consumer (advanced) + * + * @param bridgeErrorHandler the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder bridgeErrorHandler(boolean bridgeErrorHandler) { + doSetProperty("bridgeErrorHandler", bridgeErrorHandler); + return this; + } + /** + * Allows for bridging the consumer to the Camel routing Error Handler, + * which mean any exceptions (if possible) occurred while the Camel + * consumer is trying to pickup incoming messages, or the likes, will + * now be processed as a message and handled by the routing Error + * Handler. Important: This is only possible if the 3rd party component + * allows Camel to be alerted if an exception was thrown. Some + * components handle this internally only, and therefore + * bridgeErrorHandler is not possible. In other situations we may + * improve the Camel component to hook into the 3rd party component and + * make this possible for future releases. By default the consumer will + * use the org.apache.camel.spi.ExceptionHandler to deal with + * exceptions, that will be logged at WARN or ERROR level and ignored. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: consumer (advanced) + * + * @param bridgeErrorHandler the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder bridgeErrorHandler(String bridgeErrorHandler) { + doSetProperty("bridgeErrorHandler", bridgeErrorHandler); + return this; + } + /** + * Name of the Camel component that will service the requests. The + * component must be present in Camel registry and it must be able to + * service contract-first REST consumers, as platform-http does. If not + * set CLASSPATH is searched for a single component with that + * capability. + * + * The option is a: java.lang.String type. + * + * Group: consumer (advanced) + * + * @param consumerComponentName the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder consumerComponentName(String consumerComponentName) { + doSetProperty("consumerComponentName", consumerComponentName); + return this; + } + /** + * To let the consumer use a custom ExceptionHandler. Notice if the + * option bridgeErrorHandler is enabled then this option is not in use. + * By default the consumer will deal with exceptions, that will be + * logged at WARN or ERROR level and ignored. + * + * The option is a: org.apache.camel.spi.ExceptionHandler + * type. + * + * Group: consumer (advanced) + * + * @param exceptionHandler the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder exceptionHandler(org.apache.camel.spi.ExceptionHandler exceptionHandler) { + doSetProperty("exceptionHandler", exceptionHandler); + return this; + } + /** + * To let the consumer use a custom ExceptionHandler. Notice if the + * option bridgeErrorHandler is enabled then this option is not in use. + * By default the consumer will deal with exceptions, that will be + * logged at WARN or ERROR level and ignored. + * + * The option will be converted to a + * org.apache.camel.spi.ExceptionHandler type. + * + * Group: consumer (advanced) + * + * @param exceptionHandler the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder exceptionHandler(String exceptionHandler) { + doSetProperty("exceptionHandler", exceptionHandler); + return this; + } + /** + * Sets the exchange pattern when the consumer creates an exchange. + * + * The option is a: org.apache.camel.ExchangePattern type. + * + * Group: consumer (advanced) + * + * @param exchangePattern the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder exchangePattern(org.apache.camel.ExchangePattern exchangePattern) { + doSetProperty("exchangePattern", exchangePattern); + return this; + } + /** + * Sets the exchange pattern when the consumer creates an exchange. + * + * The option will be converted to a + * org.apache.camel.ExchangePattern type. + * + * Group: consumer (advanced) + * + * @param exchangePattern the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder exchangePattern(String exchangePattern) { + doSetProperty("exchangePattern", exchangePattern); + return this; + } + /** + * Used for inclusive filtering of mock data from directories. The + * pattern is using Ant-path style pattern. Multiple patterns can be + * specified separated by comma. Saved example responses in the + * collection are preferred over these files. + * + * The option is a: java.lang.String type. + * + * Default: classpath:camel-mock/** + * Group: consumer (advanced) + * + * @param mockIncludePattern the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder mockIncludePattern(String mockIncludePattern) { + doSetProperty("mockIncludePattern", mockIncludePattern); + return this; + } + /** + * Filters which requests of the collection are used, as comma separated + * Ant-style patterns matched against the folder qualified request id. + * Prefix a pattern with ! to exclude. + * + * The option is a: java.lang.String type. + * + * Group: consumer (advanced) + * + * @param requestFilter the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder requestFilter(String requestFilter) { + doSetProperty("requestFilter", requestFilter); + return this; + } + /** + * How long a loaded collection is cached, in milliseconds. Use -1 to + * cache for the lifetime of the component. + * + * The option is a: long type. + * + * Default: -1 + * Group: advanced + * + * @param collectionCacheTtl the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder collectionCacheTtl(long collectionCacheTtl) { + doSetProperty("collectionCacheTtl", collectionCacheTtl); + return this; + } + /** + * How long a loaded collection is cached, in milliseconds. Use -1 to + * cache for the lifetime of the component. + * + * The option will be converted to a long type. + * + * Default: -1 + * Group: advanced + * + * @param collectionCacheTtl the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder collectionCacheTtl(String collectionCacheTtl) { + doSetProperty("collectionCacheTtl", collectionCacheTtl); + return this; + } + /** + * Connection timeout in milliseconds when fetching a collection from + * the Postman cloud. + * + * The option is a: long type. + * + * Default: 15000 + * Group: advanced + * + * @param connectTimeout the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder connectTimeout(long connectTimeout) { + doSetProperty("connectTimeout", connectTimeout); + return this; + } + /** + * Connection timeout in milliseconds when fetching a collection from + * the Postman cloud. + * + * The option will be converted to a long type. + * + * Default: 15000 + * Group: advanced + * + * @param connectTimeout the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder connectTimeout(String connectTimeout) { + doSetProperty("connectTimeout", connectTimeout); + return this; + } + /** + * Request timeout in milliseconds when fetching a collection from the + * Postman cloud. + * + * The option is a: long type. + * + * Default: 30000 + * Group: advanced + * + * @param requestTimeout the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder requestTimeout(long requestTimeout) { + doSetProperty("requestTimeout", requestTimeout); + return this; + } + /** + * Request timeout in milliseconds when fetching a collection from the + * Postman cloud. + * + * The option will be converted to a long type. + * + * Default: 30000 + * Group: advanced + * + * @param requestTimeout the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder requestTimeout(String requestTimeout) { + doSetProperty("requestTimeout", requestTimeout); + return this; + } + /** + * The base URL of the Postman API used to fetch collections. Must use + * https, except for localhost, because plain http would send the + * Postman API key in clear text. + * + * The option is a: java.lang.String type. + * + * Default: https://api.getpostman.com + * Group: security (advanced) + * + * @param postmanApiUrl the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointConsumerBuilder postmanApiUrl(String postmanApiUrl) { + doSetProperty("postmanApiUrl", postmanApiUrl); + return this; + } + } + + /** + * Builder for endpoint producers for the REST Postman component. + */ + public interface RestPostmanEndpointProducerBuilder + extends + EndpointProducerBuilder { + default AdvancedRestPostmanEndpointProducerBuilder advanced() { + return (AdvancedRestPostmanEndpointProducerBuilder) this; + } + + /** + * API basePath, for example /v2. Default is unset, if set overrides the + * value derived from the request URL in the collection. + * + * The option is a: java.lang.String type. + * + * Group: common + * + * @param basePath the value to set + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder basePath(String basePath) { + doSetProperty("basePath", basePath); + return this; + } + /** + * How to interpret collectionSource. With auto, a bare collection UUID + * or {ownerId}-{uuid} is fetched from the Postman cloud and anything + * else is resolved as a resource (classpath:, file:, http:). Use + * resource or cloud to decide explicitly. + * + * The option is a: java.lang.String type. + * + * Default: auto + * Group: common + * + * @param collectionSourceType the value to set + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder collectionSourceType(String collectionSourceType) { + doSetProperty("collectionSourceType", collectionSourceType); + return this; + } + /** + * Values for the {{variable}} placeholders used in the collection. + * These override the variables declared by the collection and its + * folders. This is a multi-value option with prefix: variable. + * + * The option is a: java.util.Map<java.lang.String, + * java.lang.Object> type. + * The option is multivalued, and you can use the variables(String, + * Object) method to add a value (call the method multiple times to set + * more values). + * + * Group: common + * + * @param key the option key + * @param value the option value + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder variables(String key, Object value) { + doSetMultiValueProperty("variables", "variable." + key, value); + return this; + } + /** + * Values for the {{variable}} placeholders used in the collection. + * These override the variables declared by the collection and its + * folders. This is a multi-value option with prefix: variable. + * + * The option is a: java.util.Map<java.lang.String, + * java.lang.Object> type. + * The option is multivalued, and you can use the variables(String, + * Object) method to add a value (call the method multiple times to set + * more values). + * + * Group: common + * + * @param values the values + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder variables(Map values) { + doSetMultiValueProperties("variables", "variable.", values); + return this; + } + /** + * Scheme hostname and port to direct the HTTP requests to in the form + * of https://hostname:port. If set overrides any value derived from the + * collection. + * + * The option is a: java.lang.String type. + * + * Group: producer + * + * @param host the value to set + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder host(String host) { + doSetProperty("host", host); + return this; + } + /** + * When the endpoint runs more than one request, that is when it selects + * a folder or the whole collection, whether to stop and fail on the + * first request that fails. When false every request is attempted and + * the failure is recorded in its result. + * + * The option is a: boolean type. + * + * Default: true + * Group: producer + * + * @param runFailFast the value to set + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder runFailFast(boolean runFailFast) { + doSetProperty("runFailFast", runFailFast); + return this; + } + /** + * When the endpoint runs more than one request, that is when it selects + * a folder or the whole collection, whether to stop and fail on the + * first request that fails. When false every request is attempted and + * the failure is recorded in its result. + * + * The option will be converted to a boolean type. + * + * Default: true + * Group: producer + * + * @param runFailFast the value to set + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder runFailFast(String runFailFast) { + doSetProperty("runFailFast", runFailFast); + return this; + } + /** + * What to do with the auth block the collection declares for the target + * API. With ignore the block is not applied, and a warning names the + * type that was found. With header the basic, bearer and apikey types + * are applied as a static header or query parameter, and any other type + * fails at startup rather than silently sending no credential. With + * fail any auth block other than noauth is rejected. + * + * The option is a: java.lang.String type. + * + * Default: ignore + * Group: security + * + * @param collectionAuth the value to set + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder collectionAuth(String collectionAuth) { + doSetProperty("collectionAuth", collectionAuth); + return this; + } + /** + * The Postman API key used to fetch the collection from the Postman + * cloud. This credential authenticates against Postman itself and is + * never sent to the API the collection describes. + * + * The option is a: java.lang.String type. + * + * Group: security + * + * @param postmanApiKey the value to set + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder postmanApiKey(String postmanApiKey) { + doSetProperty("postmanApiKey", postmanApiKey); + return this; + } + /** + * The HTTP header used to send the Postman API key when fetching a + * collection. + * + * The option is a: java.lang.String type. + * + * Default: X-Api-Key + * Group: security + * + * @param postmanApiKeyHeader the value to set + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder postmanApiKeyHeader(String postmanApiKeyHeader) { + doSetProperty("postmanApiKeyHeader", postmanApiKeyHeader); + return this; + } + /** + * Customize TLS parameters used by the component. If not set defaults + * to the TLS parameters set in the Camel context. These parameters are + * used both when fetching a collection from the Postman cloud and by + * the delegate producer. + * + * The option is a: + * org.apache.camel.support.jsse.SSLContextParameters type. + * + * Group: security + * + * @param sslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder sslContextParameters(org.apache.camel.support.jsse.SSLContextParameters sslContextParameters) { + doSetProperty("sslContextParameters", sslContextParameters); + return this; + } + /** + * Customize TLS parameters used by the component. If not set defaults + * to the TLS parameters set in the Camel context. These parameters are + * used both when fetching a collection from the Postman cloud and by + * the delegate producer. + * + * The option will be converted to a + * org.apache.camel.support.jsse.SSLContextParameters type. + * + * Group: security + * + * @param sslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder sslContextParameters(String sslContextParameters) { + doSetProperty("sslContextParameters", sslContextParameters); + return this; + } + /** + * Enable usage of global SSL context parameters. + * + * The option is a: boolean type. + * + * Default: false + * Group: security + * + * @param useGlobalSslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder useGlobalSslContextParameters(boolean useGlobalSslContextParameters) { + doSetProperty("useGlobalSslContextParameters", useGlobalSslContextParameters); + return this; + } + /** + * Enable usage of global SSL context parameters. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: security + * + * @param useGlobalSslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanEndpointProducerBuilder useGlobalSslContextParameters(String useGlobalSslContextParameters) { + doSetProperty("useGlobalSslContextParameters", useGlobalSslContextParameters); + return this; + } + } + + /** + * Advanced builder for endpoint producers for the REST Postman component. + */ + public interface AdvancedRestPostmanEndpointProducerBuilder extends EndpointProducerBuilder { + default RestPostmanEndpointProducerBuilder basic() { + return (RestPostmanEndpointProducerBuilder) this; + } + + /** + * Whether to fail if a {{variable}} placeholder used by the selected + * request cannot be resolved. When false the placeholder is left as-is. + * + * The option is a: boolean type. + * + * Default: false + * Group: common (advanced) + * + * @param failOnUnresolvedVariable the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder failOnUnresolvedVariable(boolean failOnUnresolvedVariable) { + doSetProperty("failOnUnresolvedVariable", failOnUnresolvedVariable); + return this; + } + /** + * Whether to fail if a {{variable}} placeholder used by the selected + * request cannot be resolved. When false the placeholder is left as-is. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: common (advanced) + * + * @param failOnUnresolvedVariable the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder failOnUnresolvedVariable(String failOnUnresolvedVariable) { + doSetProperty("failOnUnresolvedVariable", failOnUnresolvedVariable); + return this; + } + /** + * Name of the Camel component that will perform the requests. The + * component must be present in Camel registry and it must implement + * RestProducerFactory service provider interface. If not set CLASSPATH + * is searched for single component that implements RestProducerFactory + * SPI. + * + * The option is a: java.lang.String type. + * + * Group: producer (advanced) + * + * @param componentName the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder componentName(String componentName) { + doSetProperty("componentName", componentName); + return this; + } + /** + * What payload type this component is capable of consuming. This + * equates to the value of the Accept HTTP header. A Postman collection + * does not describe responses, so unlike an OpenAPI specification there + * is nothing to infer this from and it is unset by default. + * + * The option is a: java.lang.String type. + * + * Group: producer (advanced) + * + * @param consumes the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder consumes(String consumes) { + doSetProperty("consumes", consumes); + return this; + } + /** + * Whether the producer should be started lazy (on the first message). + * By starting lazy you can use this to allow CamelContext and routes to + * startup in situations where a producer may otherwise fail during + * starting and cause the route to fail being started. By deferring this + * startup to be lazy then the startup failure can be handled during + * routing messages via Camel's routing error handlers. Beware that when + * the first message is processed then creating and starting the + * producer may take a little time and prolong the total processing time + * of the processing. + * + * The option is a: boolean type. + * + * Default: false + * Group: producer (advanced) + * + * @param lazyStartProducer the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder lazyStartProducer(boolean lazyStartProducer) { + doSetProperty("lazyStartProducer", lazyStartProducer); + return this; + } + /** + * Whether the producer should be started lazy (on the first message). + * By starting lazy you can use this to allow CamelContext and routes to + * startup in situations where a producer may otherwise fail during + * starting and cause the route to fail being started. By deferring this + * startup to be lazy then the startup failure can be handled during + * routing messages via Camel's routing error handlers. Beware that when + * the first message is processed then creating and starting the + * producer may take a little time and prolong the total processing time + * of the processing. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: producer (advanced) + * + * @param lazyStartProducer the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder lazyStartProducer(String lazyStartProducer) { + doSetProperty("lazyStartProducer", lazyStartProducer); + return this; + } + /** + * What payload type this component is producing. This equates to the + * value of the Content-Type HTTP header. If not set it is inferred from + * the body mode of the request in the collection. + * + * The option is a: java.lang.String type. + * + * Group: producer (advanced) + * + * @param produces the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder produces(String produces) { + doSetProperty("produces", produces); + return this; + } + /** + * How to treat the query parameters declared in the collection. With + * placeholder the parameter names are bound to message headers and the + * values in the collection are ignored as sample data. With literal the + * values in the collection are sent as-is. + * + * The option is a: java.lang.String type. + * + * Default: placeholder + * Group: producer (advanced) + * + * @param queryParameterMode the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder queryParameterMode(String queryParameterMode) { + doSetProperty("queryParameterMode", queryParameterMode); + return this; + } + /** + * How long a loaded collection is cached, in milliseconds. Use -1 to + * cache for the lifetime of the component. + * + * The option is a: long type. + * + * Default: -1 + * Group: advanced + * + * @param collectionCacheTtl the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder collectionCacheTtl(long collectionCacheTtl) { + doSetProperty("collectionCacheTtl", collectionCacheTtl); + return this; + } + /** + * How long a loaded collection is cached, in milliseconds. Use -1 to + * cache for the lifetime of the component. + * + * The option will be converted to a long type. + * + * Default: -1 + * Group: advanced + * + * @param collectionCacheTtl the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder collectionCacheTtl(String collectionCacheTtl) { + doSetProperty("collectionCacheTtl", collectionCacheTtl); + return this; + } + /** + * Connection timeout in milliseconds when fetching a collection from + * the Postman cloud. + * + * The option is a: long type. + * + * Default: 15000 + * Group: advanced + * + * @param connectTimeout the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder connectTimeout(long connectTimeout) { + doSetProperty("connectTimeout", connectTimeout); + return this; + } + /** + * Connection timeout in milliseconds when fetching a collection from + * the Postman cloud. + * + * The option will be converted to a long type. + * + * Default: 15000 + * Group: advanced + * + * @param connectTimeout the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder connectTimeout(String connectTimeout) { + doSetProperty("connectTimeout", connectTimeout); + return this; + } + /** + * Request timeout in milliseconds when fetching a collection from the + * Postman cloud. + * + * The option is a: long type. + * + * Default: 30000 + * Group: advanced + * + * @param requestTimeout the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder requestTimeout(long requestTimeout) { + doSetProperty("requestTimeout", requestTimeout); + return this; + } + /** + * Request timeout in milliseconds when fetching a collection from the + * Postman cloud. + * + * The option will be converted to a long type. + * + * Default: 30000 + * Group: advanced + * + * @param requestTimeout the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder requestTimeout(String requestTimeout) { + doSetProperty("requestTimeout", requestTimeout); + return this; + } + /** + * The base URL of the Postman API used to fetch collections. Must use + * https, except for localhost, because plain http would send the + * Postman API key in clear text. + * + * The option is a: java.lang.String type. + * + * Default: https://api.getpostman.com + * Group: security (advanced) + * + * @param postmanApiUrl the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointProducerBuilder postmanApiUrl(String postmanApiUrl) { + doSetProperty("postmanApiUrl", postmanApiUrl); + return this; + } + } + + /** + * Builder for endpoint for the REST Postman component. + */ + public interface RestPostmanEndpointBuilder + extends + RestPostmanEndpointConsumerBuilder, + RestPostmanEndpointProducerBuilder { + default AdvancedRestPostmanEndpointBuilder advanced() { + return (AdvancedRestPostmanEndpointBuilder) this; + } + + /** + * API basePath, for example /v2. Default is unset, if set overrides the + * value derived from the request URL in the collection. + * + * The option is a: java.lang.String type. + * + * Group: common + * + * @param basePath the value to set + * @return the dsl builder + */ + default RestPostmanEndpointBuilder basePath(String basePath) { + doSetProperty("basePath", basePath); + return this; + } + /** + * How to interpret collectionSource. With auto, a bare collection UUID + * or {ownerId}-{uuid} is fetched from the Postman cloud and anything + * else is resolved as a resource (classpath:, file:, http:). Use + * resource or cloud to decide explicitly. + * + * The option is a: java.lang.String type. + * + * Default: auto + * Group: common + * + * @param collectionSourceType the value to set + * @return the dsl builder + */ + default RestPostmanEndpointBuilder collectionSourceType(String collectionSourceType) { + doSetProperty("collectionSourceType", collectionSourceType); + return this; + } + /** + * Values for the {{variable}} placeholders used in the collection. + * These override the variables declared by the collection and its + * folders. This is a multi-value option with prefix: variable. + * + * The option is a: java.util.Map<java.lang.String, + * java.lang.Object> type. + * The option is multivalued, and you can use the variables(String, + * Object) method to add a value (call the method multiple times to set + * more values). + * + * Group: common + * + * @param key the option key + * @param value the option value + * @return the dsl builder + */ + default RestPostmanEndpointBuilder variables(String key, Object value) { + doSetMultiValueProperty("variables", "variable." + key, value); + return this; + } + /** + * Values for the {{variable}} placeholders used in the collection. + * These override the variables declared by the collection and its + * folders. This is a multi-value option with prefix: variable. + * + * The option is a: java.util.Map<java.lang.String, + * java.lang.Object> type. + * The option is multivalued, and you can use the variables(String, + * Object) method to add a value (call the method multiple times to set + * more values). + * + * Group: common + * + * @param values the values + * @return the dsl builder + */ + default RestPostmanEndpointBuilder variables(Map values) { + doSetMultiValueProperties("variables", "variable.", values); + return this; + } + /** + * What to do with the auth block the collection declares for the target + * API. With ignore the block is not applied, and a warning names the + * type that was found. With header the basic, bearer and apikey types + * are applied as a static header or query parameter, and any other type + * fails at startup rather than silently sending no credential. With + * fail any auth block other than noauth is rejected. + * + * The option is a: java.lang.String type. + * + * Default: ignore + * Group: security + * + * @param collectionAuth the value to set + * @return the dsl builder + */ + default RestPostmanEndpointBuilder collectionAuth(String collectionAuth) { + doSetProperty("collectionAuth", collectionAuth); + return this; + } + /** + * The Postman API key used to fetch the collection from the Postman + * cloud. This credential authenticates against Postman itself and is + * never sent to the API the collection describes. + * + * The option is a: java.lang.String type. + * + * Group: security + * + * @param postmanApiKey the value to set + * @return the dsl builder + */ + default RestPostmanEndpointBuilder postmanApiKey(String postmanApiKey) { + doSetProperty("postmanApiKey", postmanApiKey); + return this; + } + /** + * The HTTP header used to send the Postman API key when fetching a + * collection. + * + * The option is a: java.lang.String type. + * + * Default: X-Api-Key + * Group: security + * + * @param postmanApiKeyHeader the value to set + * @return the dsl builder + */ + default RestPostmanEndpointBuilder postmanApiKeyHeader(String postmanApiKeyHeader) { + doSetProperty("postmanApiKeyHeader", postmanApiKeyHeader); + return this; + } + /** + * Customize TLS parameters used by the component. If not set defaults + * to the TLS parameters set in the Camel context. These parameters are + * used both when fetching a collection from the Postman cloud and by + * the delegate producer. + * + * The option is a: + * org.apache.camel.support.jsse.SSLContextParameters type. + * + * Group: security + * + * @param sslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanEndpointBuilder sslContextParameters(org.apache.camel.support.jsse.SSLContextParameters sslContextParameters) { + doSetProperty("sslContextParameters", sslContextParameters); + return this; + } + /** + * Customize TLS parameters used by the component. If not set defaults + * to the TLS parameters set in the Camel context. These parameters are + * used both when fetching a collection from the Postman cloud and by + * the delegate producer. + * + * The option will be converted to a + * org.apache.camel.support.jsse.SSLContextParameters type. + * + * Group: security + * + * @param sslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanEndpointBuilder sslContextParameters(String sslContextParameters) { + doSetProperty("sslContextParameters", sslContextParameters); + return this; + } + /** + * Enable usage of global SSL context parameters. + * + * The option is a: boolean type. + * + * Default: false + * Group: security + * + * @param useGlobalSslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanEndpointBuilder useGlobalSslContextParameters(boolean useGlobalSslContextParameters) { + doSetProperty("useGlobalSslContextParameters", useGlobalSslContextParameters); + return this; + } + /** + * Enable usage of global SSL context parameters. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: security + * + * @param useGlobalSslContextParameters the value to set + * @return the dsl builder + */ + default RestPostmanEndpointBuilder useGlobalSslContextParameters(String useGlobalSslContextParameters) { + doSetProperty("useGlobalSslContextParameters", useGlobalSslContextParameters); + return this; + } + } + + /** + * Advanced builder for endpoint for the REST Postman component. + */ + public interface AdvancedRestPostmanEndpointBuilder + extends + AdvancedRestPostmanEndpointConsumerBuilder, + AdvancedRestPostmanEndpointProducerBuilder { + default RestPostmanEndpointBuilder basic() { + return (RestPostmanEndpointBuilder) this; + } + + /** + * Whether to fail if a {{variable}} placeholder used by the selected + * request cannot be resolved. When false the placeholder is left as-is. + * + * The option is a: boolean type. + * + * Default: false + * Group: common (advanced) + * + * @param failOnUnresolvedVariable the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointBuilder failOnUnresolvedVariable(boolean failOnUnresolvedVariable) { + doSetProperty("failOnUnresolvedVariable", failOnUnresolvedVariable); + return this; + } + /** + * Whether to fail if a {{variable}} placeholder used by the selected + * request cannot be resolved. When false the placeholder is left as-is. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: common (advanced) + * + * @param failOnUnresolvedVariable the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointBuilder failOnUnresolvedVariable(String failOnUnresolvedVariable) { + doSetProperty("failOnUnresolvedVariable", failOnUnresolvedVariable); + return this; + } + /** + * How long a loaded collection is cached, in milliseconds. Use -1 to + * cache for the lifetime of the component. + * + * The option is a: long type. + * + * Default: -1 + * Group: advanced + * + * @param collectionCacheTtl the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointBuilder collectionCacheTtl(long collectionCacheTtl) { + doSetProperty("collectionCacheTtl", collectionCacheTtl); + return this; + } + /** + * How long a loaded collection is cached, in milliseconds. Use -1 to + * cache for the lifetime of the component. + * + * The option will be converted to a long type. + * + * Default: -1 + * Group: advanced + * + * @param collectionCacheTtl the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointBuilder collectionCacheTtl(String collectionCacheTtl) { + doSetProperty("collectionCacheTtl", collectionCacheTtl); + return this; + } + /** + * Connection timeout in milliseconds when fetching a collection from + * the Postman cloud. + * + * The option is a: long type. + * + * Default: 15000 + * Group: advanced + * + * @param connectTimeout the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointBuilder connectTimeout(long connectTimeout) { + doSetProperty("connectTimeout", connectTimeout); + return this; + } + /** + * Connection timeout in milliseconds when fetching a collection from + * the Postman cloud. + * + * The option will be converted to a long type. + * + * Default: 15000 + * Group: advanced + * + * @param connectTimeout the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointBuilder connectTimeout(String connectTimeout) { + doSetProperty("connectTimeout", connectTimeout); + return this; + } + /** + * Request timeout in milliseconds when fetching a collection from the + * Postman cloud. + * + * The option is a: long type. + * + * Default: 30000 + * Group: advanced + * + * @param requestTimeout the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointBuilder requestTimeout(long requestTimeout) { + doSetProperty("requestTimeout", requestTimeout); + return this; + } + /** + * Request timeout in milliseconds when fetching a collection from the + * Postman cloud. + * + * The option will be converted to a long type. + * + * Default: 30000 + * Group: advanced + * + * @param requestTimeout the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointBuilder requestTimeout(String requestTimeout) { + doSetProperty("requestTimeout", requestTimeout); + return this; + } + /** + * The base URL of the Postman API used to fetch collections. Must use + * https, except for localhost, because plain http would send the + * Postman API key in clear text. + * + * The option is a: java.lang.String type. + * + * Default: https://api.getpostman.com + * Group: security (advanced) + * + * @param postmanApiUrl the value to set + * @return the dsl builder + */ + default AdvancedRestPostmanEndpointBuilder postmanApiUrl(String postmanApiUrl) { + doSetProperty("postmanApiUrl", postmanApiUrl); + return this; + } + } + + public interface RestPostmanBuilders { + /** + * REST Postman (camel-rest-postman) + * To call and expose REST services using a Postman Collection as + * contract. + * + * Category: rest,api + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-rest-postman + * + * @return the dsl builder for the headers' name. + */ + default RestPostmanHeaderNameBuilder restPostman() { + return RestPostmanHeaderNameBuilder.INSTANCE; + } + /** + * REST Postman (camel-rest-postman) + * To call and expose REST services using a Postman Collection as + * contract. + * + * Category: rest,api + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-rest-postman + * + * Syntax: rest-postman:collectionSource#requestId + * + * Path parameter: collectionSource + * The Postman Collection to use. Either a resource URI of a Collection + * v2.1 JSON document (classpath:, file: or http:), or the uid of a + * collection to fetch from the Postman cloud, which requires + * postmanApiKey. Default value notice: By default loads the + * postman-collection.json file + * Default value: postman-collection.json + * + * Path parameter: requestId + * The request to invoke, identified by its id in the collection or by + * its slugified name, for example getUserById. Use a folder id to run + * every request in that folder, and leave it out to run the whole + * collection. Append a slash to force a folder match when a request and + * a folder share a name. + * + * @param path collectionSource#requestId + * @return the dsl builder + */ + default RestPostmanEndpointBuilder restPostman(String path) { + return RestPostmanEndpointBuilderFactory.endpointBuilder("rest-postman", path); + } + /** + * REST Postman (camel-rest-postman) + * To call and expose REST services using a Postman Collection as + * contract. + * + * Category: rest,api + * Since: 4.22 + * Maven coordinates: org.apache.camel:camel-rest-postman + * + * Syntax: rest-postman:collectionSource#requestId + * + * Path parameter: collectionSource + * The Postman Collection to use. Either a resource URI of a Collection + * v2.1 JSON document (classpath:, file: or http:), or the uid of a + * collection to fetch from the Postman cloud, which requires + * postmanApiKey. Default value notice: By default loads the + * postman-collection.json file + * Default value: postman-collection.json + * + * Path parameter: requestId + * The request to invoke, identified by its id in the collection or by + * its slugified name, for example getUserById. Use a folder id to run + * every request in that folder, and leave it out to run the whole + * collection. Append a slash to force a folder match when a request and + * a folder share a name. + * + * @param componentName to use a custom component name for the endpoint + * instead of the default name + * @param path collectionSource#requestId + * @return the dsl builder + */ + default RestPostmanEndpointBuilder restPostman(String componentName, String path) { + return RestPostmanEndpointBuilderFactory.endpointBuilder(componentName, path); + } + + } + /** + * The builder of headers' name for the REST Postman component. + */ + public static class RestPostmanHeaderNameBuilder { + /** + * The internal instance of the builder used to access to all the + * methods representing the name of headers. + */ + public static final RestPostmanHeaderNameBuilder INSTANCE = new RestPostmanHeaderNameBuilder(); + + /** + * The id of the Postman request being invoked or serviced. + * + * The option is a: {@code String} type. + * + * Group: common + * + * @return the name of the header {@code RestPostmanRequestId}. + */ + public String restPostmanRequestId() { + return "CamelRestPostmanRequestId"; + } + /** + * The name of the Postman request, as written in the collection. + * + * The option is a: {@code String} type. + * + * Group: common + * + * @return the name of the header {@code RestPostmanRequestName}. + */ + public String restPostmanRequestName() { + return "CamelRestPostmanRequestName"; + } + /** + * The folder path of the Postman request, with folders separated by a + * slash. + * + * The option is a: {@code String} type. + * + * Group: common + * + * @return the name of the header {@code RestPostmanFolderPath}. + */ + public String restPostmanFolderPath() { + return "CamelRestPostmanFolderPath"; + } + /** + * The number of requests executed when running a folder or a whole + * collection. + * + * The option is a: {@code Integer} type. + * + * Group: common + * + * @return the name of the header {@code RestPostmanRequestCount}. + */ + public String restPostmanRequestCount() { + return "CamelRestPostmanRequestCount"; + } + /** + * The number of requests that failed when running a folder or a whole + * collection with runFailFast disabled. + * + * The option is a: {@code Integer} type. + * + * Group: common + * + * @return the name of the header {@code RestPostmanFailedCount}. + */ + public String restPostmanFailedCount() { + return "CamelRestPostmanFailedCount"; + } + } + static RestPostmanEndpointBuilder endpointBuilder(String componentName, String path) { + class RestPostmanEndpointBuilderImpl extends AbstractEndpointBuilder implements RestPostmanEndpointBuilder, AdvancedRestPostmanEndpointBuilder { + public RestPostmanEndpointBuilderImpl(String path) { + super(componentName, path); + } + } + return new RestPostmanEndpointBuilderImpl(path); + } +} \ No newline at end of file diff --git a/dsl/camel-kamelet-main/src/generated/resources/camel-component-known-dependencies.properties b/dsl/camel-kamelet-main/src/generated/resources/camel-component-known-dependencies.properties index a20ca8d6ca7eb..9e616b03ac047 100644 --- a/dsl/camel-kamelet-main/src/generated/resources/camel-component-known-dependencies.properties +++ b/dsl/camel-kamelet-main/src/generated/resources/camel-component-known-dependencies.properties @@ -324,6 +324,7 @@ org.apache.camel.component.ref.RefComponent=camel:ref org.apache.camel.component.rest.RestApiComponent=camel:rest org.apache.camel.component.rest.RestComponent=camel:rest org.apache.camel.component.rest.openapi.RestOpenApiComponent=camel:rest-openapi +org.apache.camel.component.rest.postman.RestPostmanComponent=camel:rest-postman org.apache.camel.component.robotframework.RobotFrameworkComponent=camel:robotframework org.apache.camel.component.rocketmq.RocketMQComponent=camel:rocketmq org.apache.camel.component.rss.RssComponent=camel:rss diff --git a/parent/pom.xml b/parent/pom.xml index 67bf7a46b22b8..6be0959b7e512 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -2512,6 +2512,11 @@ camel-rest-openapi ${project.version} + + org.apache.camel + camel-rest-postman + ${project.version} + org.apache.camel camel-robotframework