diff --git a/bom/camel-bom/pom.xml b/bom/camel-bom/pom.xml index bd3e0a1aaea82..72312b9de0739 100644 --- a/bom/camel-bom/pom.xml +++ b/bom/camel-bom/pom.xml @@ -2452,6 +2452,11 @@ camel-tensorflow-serving 4.24.0-SNAPSHOT + + org.apache.camel + camel-test-citrus + 4.24.0-SNAPSHOT + org.apache.camel camel-test-junit5 diff --git a/catalog/camel-allcomponents/pom.xml b/catalog/camel-allcomponents/pom.xml index 9ec0b460bef6c..2f5e3ecf493d6 100644 --- a/catalog/camel-allcomponents/pom.xml +++ b/catalog/camel-allcomponents/pom.xml @@ -2232,6 +2232,11 @@ camel-tensorflow-serving ${project.version} + + org.apache.camel + camel-test-citrus + ${project.version} + org.apache.camel camel-test-junit5 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 b1ac8d318b553..a72d3a6c6980d 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 @@ -75,6 +75,7 @@ caffeine-loadcache camunda chatscript chunk +citrus class clickhouse clickup diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/citrus.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/citrus.json new file mode 100644 index 0000000000000..b068b5b4b76a8 --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/citrus.json @@ -0,0 +1,50 @@ +{ + "component": { + "kind": "component", + "name": "citrus", + "title": "Citrus", + "description": "Citrus message validation component for testing", + "deprecated": false, + "firstVersion": "4.24.0", + "label": "testing", + "javaType": "org.apache.camel.component.citrus.CitrusComponent", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-test-citrus", + "version": "4.24.0-SNAPSHOT", + "scheme": "citrus", + "extendsScheme": "", + "syntax": "citrus:operation\/name", + "async": false, + "api": false, + "consumerOnly": false, + "producerOnly": true, + "lenientProperties": false, + "browsable": true, + "remote": false + }, + "componentProperties": { + "lazyStartProducer": { "index": 0, "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." }, + "log": { "index": 1, "kind": "property", "displayName": "Log", "group": "producer", "label": "producer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "To turn on logging when the mock receives an incoming message. This will log only one time at INFO level for the incoming message. For more detailed logging, then set the logger to DEBUG level for the org.apache.camel.component.mock.MockEndpoint class." }, + "autowiredEnabled": { "index": 2, "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." }, + "exchangeFormatter": { "index": 3, "kind": "property", "displayName": "Exchange Formatter", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "org.apache.camel.spi.ExchangeFormatter", "deprecated": false, "autowired": true, "secret": false, "description": "To use a custom ExchangeFormatter to format the Exchange into a String suitable for logging." } + }, + "properties": { + "operation": { "index": 0, "kind": "path", "displayName": "Operation", "group": "producer", "label": "", "required": true, "type": "enum", "javaType": "java.lang.String", "enum": [ "validate", "receive" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The operation to perform. 'validate' validates exchanges without modifying them. 'receive' validates exchanges and returns a simulated response." }, + "name": { "index": 1, "kind": "path", "displayName": "Name", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "A unique name to identify this endpoint. Allows multiple Citrus endpoints with different validation expectations in the same route." }, + "assertPeriod": { "index": 2, "kind": "parameter", "displayName": "Assert Period", "group": "producer", "label": "producer", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Sets a grace period after which the mock will re-assert to ensure the preliminary assertion is still valid. This is used, for example, to assert that exactly a number of messages arrive. For example, if the expected count was set to 5, then the assertion is satisfied when five or more messages arrive. To ensure that exactly 5 messages arrive, then you would need to wait a little period to ensure no further message arrives. This is what you can use this method for. By default, this period is disabled." }, + "expectedCount": { "index": 3, "kind": "parameter", "displayName": "Expected Count", "group": "producer", "label": "producer", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": -1, "description": "Specifies the expected number of message exchanges that should be received by this mock. Beware: If you want to expect that 0 messages, then take extra care, as 0 matches when the tests starts, so you need to set a assert period time to let the test run for a while to make sure there are still no messages arrived; for that use the assertPeriod option. If you want to assert that exactly nth message arrives to this mock, then see also the assertPeriod option for further details." }, + "messageType": { "index": 4, "kind": "parameter", "displayName": "Message Type", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The message type for validation. Determines which Citrus validation module is used. Supported values: json, xml, yaml, plaintext." }, + "copyOnExchange": { "index": 5, "kind": "parameter", "displayName": "Copy On Exchange", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Sets whether to make a deep copy of the incoming Exchange when received at this mock endpoint." }, + "failFast": { "index": 6, "kind": "parameter", "displayName": "Fail Fast", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Sets whether assertIsSatisfied() should fail fast at the first detected failed expectation while it may otherwise wait for all expected messages to arrive before performing expectations verifications. Is by default true. Set to false to use behavior as in Camel 2.x." }, + "lazyStartProducer": { "index": 7, "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." }, + "log": { "index": 8, "kind": "parameter", "displayName": "Log", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "To turn on logging when the mock receives an incoming message. This will log only one time at INFO level for the incoming message. For more detailed logging, then set the logger to DEBUG level for the org.apache.camel.component.mock.MockEndpoint class." }, + "reportGroup": { "index": 9, "kind": "parameter", "displayName": "Report Group", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "description": "A number that is used to turn on throughput logging based on groups of the size." }, + "resultMinimumWaitTime": { "index": 10, "kind": "parameter", "displayName": "Result Minimum Wait Time", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Sets the minimum expected amount of time the assertIsSatisfied() will wait on a latch until it is satisfied" }, + "resultWaitTime": { "index": 11, "kind": "parameter", "displayName": "Result Wait Time", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Sets the maximum amount of time the assertIsSatisfied() will wait on a latch until it is satisfied" }, + "retainFirst": { "index": 12, "kind": "parameter", "displayName": "Retain First", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": -1, "description": "Specifies to only retain the first nth number of received Exchanges. This is used when testing with big data, to reduce memory consumption by not storing copies of every Exchange this mock endpoint receives. Important: When using this limitation, then the getReceivedCounter() will still return the actual number of received message. For example if we have received 5000 messages and have configured to only retain the first 10 Exchanges, then the getReceivedCounter() will still return 5000 but there is only the first 10 Exchanges in the getExchanges() and getReceivedExchanges() methods. When using this method, then some of the other expectation methods is not supported, for example the expectedBodiesReceived(Object...) sets a expectation on the first number of bodies received. You can configure both retainFirst and retainLast options, to limit both the first and last received." }, + "retainLast": { "index": 13, "kind": "parameter", "displayName": "Retain Last", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": -1, "description": "Specifies to only retain the last nth number of received Exchanges. This is used when testing with big data, to reduce memory consumption by not storing copies of every Exchange this mock endpoint receives. Important: When using this limitation, then the getReceivedCounter() will still return the actual number of received message. For example if we have received 5000 messages and have configured to only retain the last 20 Exchanges, then the getReceivedCounter() will still return 5000 but there is only the last 20 Exchanges in the getExchanges() and getReceivedExchanges() methods. When using this method, then some of the other expectation methods is not supported, for example the expectedBodiesReceived(Object...) sets a expectation on the first number of bodies received. You can configure both retainFirst and retainLast options, to limit both the first and last received." }, + "sleepForEmptyTest": { "index": 14, "kind": "parameter", "displayName": "Sleep For Empty Test", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Allows a sleep to be specified to wait to check that this mock really is empty when expectedMessageCount(int) is called with zero value" }, + "browseLimit": { "index": 15, "kind": "parameter", "displayName": "Browse Limit", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 100, "description": "Maximum number of messages to keep in memory available for browsing. Use 0 for unlimited." } + } +} 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 488fc5343155c..8433969615ad0 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 @@ -107,6 +107,7 @@ chatscript-component choice-eip chunk-component circuitBreaker-eip +citrus-component claimCheck-eip class-component cli-connector diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/citrus-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/citrus-component.adoc new file mode 100644 index 0000000000000..91b9b9254e038 --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/citrus-component.adoc @@ -0,0 +1,437 @@ += Citrus Component +:doctitle: Citrus +:shortname: citrus +:artifactid: camel-test-citrus +:description: Citrus message validation component for testing +:since: 4.24 +:supportlevel: Preview +:tabs-sync-option: +:component-header: Only producer is supported + +*Since Camel {since}* + +*{component-header}* + +The Citrus component integrates with the https://citrusframework.org/[Citrus integration test framework] +to perform message validation on exchanges arriving at a Camel endpoint. It allows test authors to +verify exchange body content, headers, and path expressions (JsonPath, XPath) using Citrus validation +modules. + +This component is intended for use in Camel route unit and integration tests. + +== Maven dependency + +[source,xml] +---- + + org.apache.camel + camel-test-citrus + test + +---- + +== URI format + +---- +citrus:operation/name[?options] +---- + +Where `operation` is `validate` or `receive`, and `name` is an optional identifier to distinguish +multiple Citrus endpoints in the same route. + +* `validate` — validates exchanges without modifying them (pass-through validation). Validation + failures are collected silently and only surface when `assertIsSatisfied()` is called. +* `receive` — validates exchanges and returns a simulated response (request/response simulation). + Validation failures remain on the exchange and trigger Camel's `onException` handlers. + +// 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 + +The Citrus endpoint acts as a producer-only endpoint. Routes send exchanges *to* `citrus:validate`, +and the component validates each exchange using Citrus message validation. + +NOTE: Spec setup (`validate()`, `validateAll()`, `when().receive()`) must complete before exchanges +start arriving. For async consumers (SEDA, timer-based routes), ensure all validation chains are +fully configured before starting the `CamelContext`. + +=== Validation modules + +The component does not bundle Citrus validation modules. You must add the required validation +module dependency for your message type to your test classpath: + +[cols="1,2", options="header"] +|=== +| Dependency | Description + +| `citrus-validation-json` +| JSON message validation with JsonPath expressions and JSON schema support + +| `citrus-validation-xml` +| XML message validation with XPath expressions, XML schema (XSD/WSDL) and XSLT support + +| `citrus-validation-text` +| Plain text message validation including binary (stream and base64) and gzip support + +| `citrus-validation-yaml` +| YAML message validation + +| `citrus-validation-binary` +| Binary message validation with stream comparison and base64 encoding + +| `citrus-validation-hamcrest` +| Hamcrest matcher assertions for use with JsonPath and XPath expressions +|=== + +All modules use the `org.citrusframework` group ID. Refer to the +https://citrusframework.org/citrus/reference/html/[Citrus reference documentation] for detailed +usage of each validation module. + +=== Citrus empowered message validation + +Citrus uses a powerful template-based approach to message validation: you provide an expected message template and +Citrus compares it against the received message using format-aware comparison. This brings several +capabilities beyond simple string equality. + +==== Ignore expressions + +Use the `@ignore@` placeholder anywhere in an expected message template to skip validation of dynamic +values such as timestamps, generated IDs, or correlation tokens. The element must be present, but its +value is not checked. + +[source,json] +---- +{ + "orderId": "@ignore@", + "timestamp": "@ignore@", + "status": "created" +} +---- + +This works across all supported message types — in XML element and attribute values, JSON property values, +and YAML property values. The ignore placeholder can also replace an entire sub-object or element +including its children. + +For plain text messages, the `@ignore@` placeholder works on a positional basis — it matches a word-length +character sequence at the exact position in the text. Use `@ignore(n)@` to ignore exactly _n_ characters +when the value contains non-word characters: + +[source,plaintext] +---- +Your current id is "@ignore@", it expires on 01/01/2030 +---- + +This ignores the word following `id is "` based on the position of the ignore expression in the template. +Because no character count is specified, Citrus ignores the entire word regardless of its length. +The word boundary is determined by the first non-word character (whitespace, punctuation, etc.). + +[source,plaintext] +---- +Your current id is "@ignore(12)@", it expires on @ignore(10)@ +---- + +This ignores the next 12 characters starting at the position of the ignore expression in the template. +This also includes whitespace and non-word characters (e.g. `Your current id is "1234/ 34567 D", it expires on 01/01/2030`). + +==== Validation matchers + +Citrus validation matchers are inline assertions embedded directly in the expected message template, +surrounded by `@` markers. They work across all message formats (JSON, XML, YAML, plain text) and +provide richer assertions than exact equality: + +[source,json] +---- +{ + "name": "@equalsIgnoreCase('JOHN')@", + "age": "@greaterThan(17)@", + "email": "@matches('[a-z]+@[a-z]+\\.[a-z]+')@", + "created": "@matchesDatePattern('yyyy-MM-dd')@", + "id": "@isNumber()@", + "token": "@startsWith('tk-')@", + "status": "@contains('active')@" +} +---- + +You can also use `@variable('name')@` to extract the actual value into a Citrus test variable for +later use, and `@assertThat(hamcrestMatcher)@` to use Hamcrest matchers inline (requires +`citrus-validation-hamcrest`): + +[source,json] +---- +{ + "count": "@assertThat(allOf(greaterThan(0), lessThan(100)))@", + "type": "@assertThat(anyOf(equalTo(A), equalTo(B)))@" +} +---- + +==== JSON validation + +Citrus JSON validation compares JSON object structures regardless of property order. +It supports two modes configurable via `citrus.json.message.validation.strict`: + +* **Strict mode** (default): the received JSON must match the expected structure exactly — no + additional properties or array items are allowed. +* **Non-strict mode**: the expected JSON can be a subset of the received message — additional + properties and array items in the received JSON are silently ignored. This is useful when you + only want to verify specific fields. + +JSON validation also supports: + +* **JsonPath expressions** for targeted field validation without specifying the full message body +* **JSON schema validation** against JSON Schema V3/V4 definitions +* **`@ignore@` and validation matchers** within JSON values + +==== XML validation + +Citrus XML validation performs a structural DOM tree comparison that correctly handles: + +* **XML namespaces** with different prefixes — the comparison is namespace-aware, so + `` and `` are treated as equivalent + when they share the same namespace URI. +* **XML schema validation** against XSD and WSDL definitions to verify structural correctness. +* **XPath expressions** for targeted element validation, with full namespace support via + namespace context configuration. This includes automatic namespace prefix resolution when the + prefixes differ between the control template and the received message. +* **`@ignore@` and validation matchers** within XML element values and attributes. + +==== Plain text validation + +Plain text validation performs exact string comparison with support for: + +* **Positional `@ignore@`** to skip dynamic word-length sequences at specific positions. +* **`@ignore(n)@`** to skip exactly _n_ characters (useful for values with non-word characters). +* **`@variable('name')@`** to extract values at specific positions into test variables. +* **Whitespace normalization** (configurable) to ignore line separator differences (CR, LF, CRLF) + or all whitespace. +* **Gzip** and **binary/base64** content comparison via the respective message types. + +==== YAML validation + +YAML validation compares YAML documents structurally, similar to JSON validation: + +* **Strict/non-strict mode** — configurable via `citrus.yaml.message.validation.strict`. +* **Path expressions** for ignoring specific elements in the YAML structure. +* **`@ignore@` and validation matchers** within YAML values. + +=== Validation specification API + +The endpoint provides a fluent API for specifying validation expectations. You can define a single +validation that applies to all incoming exchanges, or specify individual validations for each +exchange in sequence. + +==== Single validation for all exchanges + +Use `validateAll()` to define one validation specification that is applied to every exchange. +The returned `MessageValidationSpec` provides a fluent API with `body()`, `header()`, and +`validate()` methods: + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); +citrus.validateAll() + .body("{\"status\": \"ok\"}") + .header("operation", "test"); +citrus.expectedMessageCount(3); + +// All three exchanges are validated against the same spec +template.sendBodyAndHeader("direct:start", "{\"status\": \"ok\"}", "operation", "test"); +template.sendBodyAndHeader("direct:start", "{\"status\": \"ok\"}", "operation", "test"); +template.sendBodyAndHeader("direct:start", "{\"status\": \"ok\"}", "operation", "test"); + +citrus.assertIsSatisfied(); +---- + +==== Per-exchange sequential validation + +Use `validate()` to specify different validation expectations for the 1st, 2nd, 3rd, etc. +exchange. Each call to `validate()` auto-increments the index, so specs are applied in order. +Each incoming exchange triggers the next validation spec in sequence: + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); + +citrus.validate().body("{\"event\": \"created\"}").header("action", "create"); +citrus.validate().body("{\"event\": \"updated\"}").header("action", "update"); +citrus.validate().body("{\"event\": \"deleted\"}").header("action", "delete"); +citrus.expectedMessageCount(3); + +template.sendBodyAndHeader("direct:start", "{\"event\": \"created\"}", "action", "create"); +template.sendBodyAndHeader("direct:start", "{\"event\": \"updated\"}", "action", "update"); +template.sendBodyAndHeader("direct:start", "{\"event\": \"deleted\"}", "action", "delete"); + +citrus.assertIsSatisfied(); +---- + +You can also use `validate(index)` to target a specific exchange by its 1-based position. + +Indexed specs (`validate()`, `validate(index)`, `when().receive()`) imply a minimum message count equal to the highest index, unless the test sets `expectedMessageCount(...)` or a larger `expectedMinimumMessageCount(...)`. So `assertIsSatisfied()` fails when fewer exchanges arrive. + +When both `validate(index)` and `validateAll()` are configured, the indexed spec takes priority — +it replaces `validateAll()` for that exchange entirely (the two are never combined). The +`validateAll()` spec acts as a fallback for exchanges that have no matching indexed spec. + +If an exchange arrives at an index with no matching indexed spec and no `validateAll()` fallback, +the exchange passes through without validation. No failure is recorded by the endpoint. +For the `validate` operation, unmatched exchanges are ignored silently. For the `receive` operation, +no simulated response is applied and the exchange continues unchanged. + +[source,java] +---- +citrus.validate(1).body("{\"event\": \"start\"}"); +citrus.validateAll().body("{\"event\": \"progress\"}").header("type", "status"); + +// Exchange 1 → validated against validate(1) only (validateAll() is NOT applied) +// Exchange 2, 3, ... → validated against validateAll() (body + header) +---- + +=== Receive operation (request/response simulation) + +The `receive` operation extends the validation endpoint with response simulation. It uses a +Gherkin-style fluent API to define what the endpoint expects to receive (`when`) and what it +should respond with (`then`). This is useful for simulating backend services in route tests. + +==== Basic usage + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + +// Define expected request and simulated response +citrus.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() + .body("{\"greeting\": \"Hello!\"}"); +citrus.expectedMessageCount(1); + +// Send a request and verify the response +Exchange result = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"action\": \"greet\"}"); +}); + +citrus.assertIsSatisfied(); +assertThat(result.getIn().getBody(String.class)).isEqualTo("{\"greeting\": \"Hello!\"}"); +---- + +==== Gherkin-style API + +The `when()` method returns a `WhenSpec` that provides a chained fluent syntax: + +* `when().receive(index).body(...).then().respond()` — define validation and response for a specific exchange (1-based index) +* `when().receive().body(...).then().respond()` — auto-incrementing index for sequential exchanges +* `when().receiveAll().body(...).then().respond()` — define default validation and response for all exchanges +* `then().send()` — alias for `respond()` + +Both the `receive()` / `receiveAll()` spec and the `respond()` / `send()` spec support `body()` and `header()` methods. + +==== Per-exchange request/response + +You can define different request expectations and responses for each exchange in sequence: + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + +citrus.when().receive(1).body("{\"action\": \"create\"}") + .then().respond().body("{\"id\": \"1\", \"status\": \"created\"}"); + +citrus.when().receive(2).body("{\"action\": \"update\"}") + .then().respond().body("{\"id\": \"1\", \"status\": \"updated\"}"); + +citrus.expectedMessageCount(2); +---- + +Indexed specs take priority over `receiveAll()` for both request validation and response +simulation. When a specific exchange has no indexed spec, the `receiveAll()` spec is used as +a fallback. + +==== Response with headers + +[source,java] +---- +citrus.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() + .body("{\"greeting\": \"Hello!\"}") + .header("Content-Type", "application/json") + .header("X-Status", "ok"); +---- + +==== Citrus variables in responses + +Exchange properties and exchange variables are available as Citrus test variables in response +templates. This enables dynamic response generation based on the incoming request context: + +[source,java] +---- +citrus.when().receiveAll() + .body("{\"action\": \"greet\", \"name\": \"@ignore@\"}") + .then().respond() + .body("{\"greeting\": \"Hello ${userName}!\"}") + .header("X-Correlation-Id", "${correlationId}"); +---- + +==== Response-only mode + +When you only need response simulation without request validation, omit the body on `receive()`: + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:receive/responder", CitrusEndpoint.class); + +citrus.when().receive().then().respond() + .body("OK") + .header("status", "200"); +citrus.expectedMessageCount(1); +---- + +=== Error handling and redelivery + +The two operations handle validation failures differently with respect to Camel's error handling: + +* **`validate` operation** — validation failures are captured internally and the exception is + cleared from the exchange. Camel's `onException` handlers and redelivery policies do *not* + trigger. Failures surface only when `assertIsSatisfied()` is called. +* **`receive` operation** — validation failures remain on the exchange as an exception. Camel's + `onException` handlers and redelivery policies *do* trigger, which matches the expected + behavior for a request/response endpoint where callers need to see errors. + +NOTE: Each redelivery attempt counts as a new exchange in the mock counter. So if a +`receive(1)` spec fails and Camel redelivers, the retry is exchange 2 and +`receive(2)` applies to it — not `receive(1)` again. If no spec exists for the retry, it +passes without validation and the earlier failure is not recorded, so set +`expectedMessageCount(...)` to catch the extra exchange. + +=== Exchange properties and variables as Citrus variables + +All exchange properties and exchange variables are automatically propagated to the Citrus test +context as test variables. This enables the use of Citrus variable placeholders (pass:c[`${variableName}`]) +in expected message templates and response bodies. + +Variables extracted by Citrus during validation (e.g., via `@variable('name')@`) are written back +to the exchange as exchange variables, making them available for downstream route processing. + +=== Path validation + +Use the `validate()` method on a `MessageValidationSpec` to add Citrus validation context builders +such as JsonPath or XPath expressions. These are provided by the Citrus `DefaultValidations` class: + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); +citrus.validateAll().validate(new DefaultValidations().jsonPath() + .expression("$.user.name", "John") + .expression("$.user.age", "30")); +---- + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=xml", CitrusEndpoint.class); +citrus.validateAll().validate(new DefaultValidations().xpath() + .expression("/order/status", "completed") + .expression("/order/total", "99.99")); +---- diff --git a/components/camel-test/camel-test-citrus/pom.xml b/components/camel-test/camel-test-citrus/pom.xml new file mode 100644 index 0000000000000..3a61ebdd45654 --- /dev/null +++ b/components/camel-test/camel-test-citrus/pom.xml @@ -0,0 +1,111 @@ + + + + 4.0.0 + + + org.apache.camel + camel-test-parent + 4.24.0-SNAPSHOT + + + camel-test-citrus + jar + + Camel :: Test :: Citrus + Citrus message validation component for testing + + + 4.24.0 + + Test Citrus + + + + + org.apache.camel + camel-support + + + org.apache.camel + camel-mock + + + + + org.citrusframework + citrus-api + ${citrus-version} + + + org.citrusframework + citrus-base + ${citrus-version} + + + + + org.apache.camel + camel-test-junit6 + test + + + org.apache.camel + camel-direct + test + + + + org.citrusframework + citrus-validation-json + ${citrus-version} + test + + + org.citrusframework + citrus-validation-xml + ${citrus-version} + test + + + org.citrusframework + citrus-validation-text + ${citrus-version} + test + + + org.citrusframework + citrus-validation-yaml + ${citrus-version} + test + + + + org.assertj + assertj-core + test + + + org.apache.logging.log4j + log4j-slf4j2-impl + test + + + diff --git a/components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusComponentConfigurer.java b/components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusComponentConfigurer.java new file mode 100644 index 0000000000000..74bcd2cc166f0 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusComponentConfigurer.java @@ -0,0 +1,22 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.citrus; + +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.mock.MockComponentConfigurer; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.EndpointSchemaGeneratorMojo") +@SuppressWarnings("unchecked") +public class CitrusComponentConfigurer extends MockComponentConfigurer implements GeneratedPropertyConfigurer, PropertyConfigurerGetter { +} + diff --git a/components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusEndpointConfigurer.java b/components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusEndpointConfigurer.java new file mode 100644 index 0000000000000..bdaf11cc6a385 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusEndpointConfigurer.java @@ -0,0 +1,51 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.citrus; + +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.mock.MockEndpointConfigurer; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.EndpointSchemaGeneratorMojo") +@SuppressWarnings("unchecked") +public class CitrusEndpointConfigurer extends MockEndpointConfigurer implements GeneratedPropertyConfigurer, PropertyConfigurerGetter { + + @Override + public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { + CitrusEndpoint target = (CitrusEndpoint) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "messagetype": + case "messageType": target.setMessageType(property(camelContext, java.lang.String.class, value)); return true; + default: return super.configure(camelContext, obj, name, value, ignoreCase); + } + } + + @Override + public Class getOptionType(String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "messagetype": + case "messageType": return java.lang.String.class; + default: return super.getOptionType(name, ignoreCase); + } + } + + @Override + public Object getOptionValue(Object obj, String name, boolean ignoreCase) { + CitrusEndpoint target = (CitrusEndpoint) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "messagetype": + case "messageType": return target.getMessageType(); + default: return super.getOptionValue(obj, name, ignoreCase); + } + } +} + diff --git a/components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusEndpointUriFactory.java b/components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusEndpointUriFactory.java new file mode 100644 index 0000000000000..54ed2323f209b --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusEndpointUriFactory.java @@ -0,0 +1,93 @@ +/* Generated by camel build tools - do NOT edit this file! */ +package org.apache.camel.component.citrus; + +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 CitrusEndpointUriFactory extends org.apache.camel.support.component.EndpointUriFactorySupport implements EndpointUriFactory { + + private static final String BASE = ":operation/name"; + + 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<>(16); + props.add("assertPeriod"); + props.add("browseLimit"); + props.add("copyOnExchange"); + props.add("expectedCount"); + props.add("failFast"); + props.add("lazyStartProducer"); + props.add("log"); + props.add("messageType"); + props.add("name"); + props.add("operation"); + props.add("reportGroup"); + props.add("resultMinimumWaitTime"); + props.add("resultWaitTime"); + props.add("retainFirst"); + props.add("retainLast"); + props.add("sleepForEmptyTest"); + PROPERTY_NAMES = Collections.unmodifiableSet(props); + SECRET_PROPERTY_NAMES = Collections.emptySet(); + ENDPOINT_IDENTITY_PROPERTY_NAMES = Collections.emptySet(); + MULTI_VALUE_PREFIXES = Collections.emptyMap(); + } + + @Override + public boolean isEnabled(String scheme) { + return "citrus".equals(scheme); + } + + @Override + public String buildUri(String scheme, Map properties, boolean encode) throws URISyntaxException { + String syntax = scheme + BASE; + String uri = syntax; + + Map copy = copyParameters(properties); + + uri = buildPathParameter(syntax, uri, "operation", null, true, copy); + uri = buildPathParameter(syntax, uri, "name", 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-test/camel-test-citrus/src/generated/resources/META-INF/org/apache/camel/component/citrus/citrus.json b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/org/apache/camel/component/citrus/citrus.json new file mode 100644 index 0000000000000..b068b5b4b76a8 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/org/apache/camel/component/citrus/citrus.json @@ -0,0 +1,50 @@ +{ + "component": { + "kind": "component", + "name": "citrus", + "title": "Citrus", + "description": "Citrus message validation component for testing", + "deprecated": false, + "firstVersion": "4.24.0", + "label": "testing", + "javaType": "org.apache.camel.component.citrus.CitrusComponent", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-test-citrus", + "version": "4.24.0-SNAPSHOT", + "scheme": "citrus", + "extendsScheme": "", + "syntax": "citrus:operation\/name", + "async": false, + "api": false, + "consumerOnly": false, + "producerOnly": true, + "lenientProperties": false, + "browsable": true, + "remote": false + }, + "componentProperties": { + "lazyStartProducer": { "index": 0, "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." }, + "log": { "index": 1, "kind": "property", "displayName": "Log", "group": "producer", "label": "producer", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "To turn on logging when the mock receives an incoming message. This will log only one time at INFO level for the incoming message. For more detailed logging, then set the logger to DEBUG level for the org.apache.camel.component.mock.MockEndpoint class." }, + "autowiredEnabled": { "index": 2, "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." }, + "exchangeFormatter": { "index": 3, "kind": "property", "displayName": "Exchange Formatter", "group": "advanced", "label": "advanced", "required": false, "type": "object", "javaType": "org.apache.camel.spi.ExchangeFormatter", "deprecated": false, "autowired": true, "secret": false, "description": "To use a custom ExchangeFormatter to format the Exchange into a String suitable for logging." } + }, + "properties": { + "operation": { "index": 0, "kind": "path", "displayName": "Operation", "group": "producer", "label": "", "required": true, "type": "enum", "javaType": "java.lang.String", "enum": [ "validate", "receive" ], "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "The operation to perform. 'validate' validates exchanges without modifying them. 'receive' validates exchanges and returns a simulated response." }, + "name": { "index": 1, "kind": "path", "displayName": "Name", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "A unique name to identify this endpoint. Allows multiple Citrus endpoints with different validation expectations in the same route." }, + "assertPeriod": { "index": 2, "kind": "parameter", "displayName": "Assert Period", "group": "producer", "label": "producer", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Sets a grace period after which the mock will re-assert to ensure the preliminary assertion is still valid. This is used, for example, to assert that exactly a number of messages arrive. For example, if the expected count was set to 5, then the assertion is satisfied when five or more messages arrive. To ensure that exactly 5 messages arrive, then you would need to wait a little period to ensure no further message arrives. This is what you can use this method for. By default, this period is disabled." }, + "expectedCount": { "index": 3, "kind": "parameter", "displayName": "Expected Count", "group": "producer", "label": "producer", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": -1, "description": "Specifies the expected number of message exchanges that should be received by this mock. Beware: If you want to expect that 0 messages, then take extra care, as 0 matches when the tests starts, so you need to set a assert period time to let the test run for a while to make sure there are still no messages arrived; for that use the assertPeriod option. If you want to assert that exactly nth message arrives to this mock, then see also the assertPeriod option for further details." }, + "messageType": { "index": 4, "kind": "parameter", "displayName": "Message Type", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "autowired": false, "secret": false, "description": "The message type for validation. Determines which Citrus validation module is used. Supported values: json, xml, yaml, plaintext." }, + "copyOnExchange": { "index": 5, "kind": "parameter", "displayName": "Copy On Exchange", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Sets whether to make a deep copy of the incoming Exchange when received at this mock endpoint." }, + "failFast": { "index": 6, "kind": "parameter", "displayName": "Fail Fast", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": true, "description": "Sets whether assertIsSatisfied() should fail fast at the first detected failed expectation while it may otherwise wait for all expected messages to arrive before performing expectations verifications. Is by default true. Set to false to use behavior as in Camel 2.x." }, + "lazyStartProducer": { "index": 7, "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." }, + "log": { "index": 8, "kind": "parameter", "displayName": "Log", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "autowired": false, "secret": false, "defaultValue": false, "description": "To turn on logging when the mock receives an incoming message. This will log only one time at INFO level for the incoming message. For more detailed logging, then set the logger to DEBUG level for the org.apache.camel.component.mock.MockEndpoint class." }, + "reportGroup": { "index": 9, "kind": "parameter", "displayName": "Report Group", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "description": "A number that is used to turn on throughput logging based on groups of the size." }, + "resultMinimumWaitTime": { "index": 10, "kind": "parameter", "displayName": "Result Minimum Wait Time", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Sets the minimum expected amount of time the assertIsSatisfied() will wait on a latch until it is satisfied" }, + "resultWaitTime": { "index": 11, "kind": "parameter", "displayName": "Result Wait Time", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Sets the maximum amount of time the assertIsSatisfied() will wait on a latch until it is satisfied" }, + "retainFirst": { "index": 12, "kind": "parameter", "displayName": "Retain First", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": -1, "description": "Specifies to only retain the first nth number of received Exchanges. This is used when testing with big data, to reduce memory consumption by not storing copies of every Exchange this mock endpoint receives. Important: When using this limitation, then the getReceivedCounter() will still return the actual number of received message. For example if we have received 5000 messages and have configured to only retain the first 10 Exchanges, then the getReceivedCounter() will still return 5000 but there is only the first 10 Exchanges in the getExchanges() and getReceivedExchanges() methods. When using this method, then some of the other expectation methods is not supported, for example the expectedBodiesReceived(Object...) sets a expectation on the first number of bodies received. You can configure both retainFirst and retainLast options, to limit both the first and last received." }, + "retainLast": { "index": 13, "kind": "parameter", "displayName": "Retain Last", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": -1, "description": "Specifies to only retain the last nth number of received Exchanges. This is used when testing with big data, to reduce memory consumption by not storing copies of every Exchange this mock endpoint receives. Important: When using this limitation, then the getReceivedCounter() will still return the actual number of received message. For example if we have received 5000 messages and have configured to only retain the last 20 Exchanges, then the getReceivedCounter() will still return 5000 but there is only the last 20 Exchanges in the getExchanges() and getReceivedExchanges() methods. When using this method, then some of the other expectation methods is not supported, for example the expectedBodiesReceived(Object...) sets a expectation on the first number of bodies received. You can configure both retainFirst and retainLast options, to limit both the first and last received." }, + "sleepForEmptyTest": { "index": 14, "kind": "parameter", "displayName": "Sleep For Empty Test", "group": "producer (advanced)", "label": "producer,advanced", "required": false, "type": "duration", "javaType": "long", "deprecated": false, "autowired": false, "secret": false, "description": "Allows a sleep to be specified to wait to check that this mock really is empty when expectedMessageCount(int) is called with zero value" }, + "browseLimit": { "index": 15, "kind": "parameter", "displayName": "Browse Limit", "group": "advanced", "label": "advanced", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "autowired": false, "secret": false, "defaultValue": 100, "description": "Maximum number of messages to keep in memory available for browsing. Use 0 for unlimited." } + } +} diff --git a/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/component.properties b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/component.properties new file mode 100644 index 0000000000000..ecc06c05f86d0 --- /dev/null +++ b/components/camel-test/camel-test-citrus/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=citrus +groupId=org.apache.camel +artifactId=camel-test-citrus +version=4.24.0-SNAPSHOT +projectName=Camel :: Test :: Citrus +projectDescription=Citrus message validation component for testing diff --git a/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/component/citrus b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/component/citrus new file mode 100644 index 0000000000000..4aafbd15388ea --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/component/citrus @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.citrus.CitrusComponent diff --git a/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/configurer/citrus-component b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/configurer/citrus-component new file mode 100644 index 0000000000000..ca57dc00fcdca --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/configurer/citrus-component @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.citrus.CitrusComponentConfigurer diff --git a/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/configurer/citrus-endpoint b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/configurer/citrus-endpoint new file mode 100644 index 0000000000000..0dc53e4404e03 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/configurer/citrus-endpoint @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.citrus.CitrusEndpointConfigurer diff --git a/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/urifactory/citrus-endpoint b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/urifactory/citrus-endpoint new file mode 100644 index 0000000000000..06ef5c6f46f18 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/urifactory/citrus-endpoint @@ -0,0 +1,2 @@ +# Generated by camel build tools - do NOT edit this file! +class=org.apache.camel.component.citrus.CitrusEndpointUriFactory diff --git a/components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc b/components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc new file mode 100644 index 0000000000000..91b9b9254e038 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc @@ -0,0 +1,437 @@ += Citrus Component +:doctitle: Citrus +:shortname: citrus +:artifactid: camel-test-citrus +:description: Citrus message validation component for testing +:since: 4.24 +:supportlevel: Preview +:tabs-sync-option: +:component-header: Only producer is supported + +*Since Camel {since}* + +*{component-header}* + +The Citrus component integrates with the https://citrusframework.org/[Citrus integration test framework] +to perform message validation on exchanges arriving at a Camel endpoint. It allows test authors to +verify exchange body content, headers, and path expressions (JsonPath, XPath) using Citrus validation +modules. + +This component is intended for use in Camel route unit and integration tests. + +== Maven dependency + +[source,xml] +---- + + org.apache.camel + camel-test-citrus + test + +---- + +== URI format + +---- +citrus:operation/name[?options] +---- + +Where `operation` is `validate` or `receive`, and `name` is an optional identifier to distinguish +multiple Citrus endpoints in the same route. + +* `validate` — validates exchanges without modifying them (pass-through validation). Validation + failures are collected silently and only surface when `assertIsSatisfied()` is called. +* `receive` — validates exchanges and returns a simulated response (request/response simulation). + Validation failures remain on the exchange and trigger Camel's `onException` handlers. + +// 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 + +The Citrus endpoint acts as a producer-only endpoint. Routes send exchanges *to* `citrus:validate`, +and the component validates each exchange using Citrus message validation. + +NOTE: Spec setup (`validate()`, `validateAll()`, `when().receive()`) must complete before exchanges +start arriving. For async consumers (SEDA, timer-based routes), ensure all validation chains are +fully configured before starting the `CamelContext`. + +=== Validation modules + +The component does not bundle Citrus validation modules. You must add the required validation +module dependency for your message type to your test classpath: + +[cols="1,2", options="header"] +|=== +| Dependency | Description + +| `citrus-validation-json` +| JSON message validation with JsonPath expressions and JSON schema support + +| `citrus-validation-xml` +| XML message validation with XPath expressions, XML schema (XSD/WSDL) and XSLT support + +| `citrus-validation-text` +| Plain text message validation including binary (stream and base64) and gzip support + +| `citrus-validation-yaml` +| YAML message validation + +| `citrus-validation-binary` +| Binary message validation with stream comparison and base64 encoding + +| `citrus-validation-hamcrest` +| Hamcrest matcher assertions for use with JsonPath and XPath expressions +|=== + +All modules use the `org.citrusframework` group ID. Refer to the +https://citrusframework.org/citrus/reference/html/[Citrus reference documentation] for detailed +usage of each validation module. + +=== Citrus empowered message validation + +Citrus uses a powerful template-based approach to message validation: you provide an expected message template and +Citrus compares it against the received message using format-aware comparison. This brings several +capabilities beyond simple string equality. + +==== Ignore expressions + +Use the `@ignore@` placeholder anywhere in an expected message template to skip validation of dynamic +values such as timestamps, generated IDs, or correlation tokens. The element must be present, but its +value is not checked. + +[source,json] +---- +{ + "orderId": "@ignore@", + "timestamp": "@ignore@", + "status": "created" +} +---- + +This works across all supported message types — in XML element and attribute values, JSON property values, +and YAML property values. The ignore placeholder can also replace an entire sub-object or element +including its children. + +For plain text messages, the `@ignore@` placeholder works on a positional basis — it matches a word-length +character sequence at the exact position in the text. Use `@ignore(n)@` to ignore exactly _n_ characters +when the value contains non-word characters: + +[source,plaintext] +---- +Your current id is "@ignore@", it expires on 01/01/2030 +---- + +This ignores the word following `id is "` based on the position of the ignore expression in the template. +Because no character count is specified, Citrus ignores the entire word regardless of its length. +The word boundary is determined by the first non-word character (whitespace, punctuation, etc.). + +[source,plaintext] +---- +Your current id is "@ignore(12)@", it expires on @ignore(10)@ +---- + +This ignores the next 12 characters starting at the position of the ignore expression in the template. +This also includes whitespace and non-word characters (e.g. `Your current id is "1234/ 34567 D", it expires on 01/01/2030`). + +==== Validation matchers + +Citrus validation matchers are inline assertions embedded directly in the expected message template, +surrounded by `@` markers. They work across all message formats (JSON, XML, YAML, plain text) and +provide richer assertions than exact equality: + +[source,json] +---- +{ + "name": "@equalsIgnoreCase('JOHN')@", + "age": "@greaterThan(17)@", + "email": "@matches('[a-z]+@[a-z]+\\.[a-z]+')@", + "created": "@matchesDatePattern('yyyy-MM-dd')@", + "id": "@isNumber()@", + "token": "@startsWith('tk-')@", + "status": "@contains('active')@" +} +---- + +You can also use `@variable('name')@` to extract the actual value into a Citrus test variable for +later use, and `@assertThat(hamcrestMatcher)@` to use Hamcrest matchers inline (requires +`citrus-validation-hamcrest`): + +[source,json] +---- +{ + "count": "@assertThat(allOf(greaterThan(0), lessThan(100)))@", + "type": "@assertThat(anyOf(equalTo(A), equalTo(B)))@" +} +---- + +==== JSON validation + +Citrus JSON validation compares JSON object structures regardless of property order. +It supports two modes configurable via `citrus.json.message.validation.strict`: + +* **Strict mode** (default): the received JSON must match the expected structure exactly — no + additional properties or array items are allowed. +* **Non-strict mode**: the expected JSON can be a subset of the received message — additional + properties and array items in the received JSON are silently ignored. This is useful when you + only want to verify specific fields. + +JSON validation also supports: + +* **JsonPath expressions** for targeted field validation without specifying the full message body +* **JSON schema validation** against JSON Schema V3/V4 definitions +* **`@ignore@` and validation matchers** within JSON values + +==== XML validation + +Citrus XML validation performs a structural DOM tree comparison that correctly handles: + +* **XML namespaces** with different prefixes — the comparison is namespace-aware, so + `` and `` are treated as equivalent + when they share the same namespace URI. +* **XML schema validation** against XSD and WSDL definitions to verify structural correctness. +* **XPath expressions** for targeted element validation, with full namespace support via + namespace context configuration. This includes automatic namespace prefix resolution when the + prefixes differ between the control template and the received message. +* **`@ignore@` and validation matchers** within XML element values and attributes. + +==== Plain text validation + +Plain text validation performs exact string comparison with support for: + +* **Positional `@ignore@`** to skip dynamic word-length sequences at specific positions. +* **`@ignore(n)@`** to skip exactly _n_ characters (useful for values with non-word characters). +* **`@variable('name')@`** to extract values at specific positions into test variables. +* **Whitespace normalization** (configurable) to ignore line separator differences (CR, LF, CRLF) + or all whitespace. +* **Gzip** and **binary/base64** content comparison via the respective message types. + +==== YAML validation + +YAML validation compares YAML documents structurally, similar to JSON validation: + +* **Strict/non-strict mode** — configurable via `citrus.yaml.message.validation.strict`. +* **Path expressions** for ignoring specific elements in the YAML structure. +* **`@ignore@` and validation matchers** within YAML values. + +=== Validation specification API + +The endpoint provides a fluent API for specifying validation expectations. You can define a single +validation that applies to all incoming exchanges, or specify individual validations for each +exchange in sequence. + +==== Single validation for all exchanges + +Use `validateAll()` to define one validation specification that is applied to every exchange. +The returned `MessageValidationSpec` provides a fluent API with `body()`, `header()`, and +`validate()` methods: + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); +citrus.validateAll() + .body("{\"status\": \"ok\"}") + .header("operation", "test"); +citrus.expectedMessageCount(3); + +// All three exchanges are validated against the same spec +template.sendBodyAndHeader("direct:start", "{\"status\": \"ok\"}", "operation", "test"); +template.sendBodyAndHeader("direct:start", "{\"status\": \"ok\"}", "operation", "test"); +template.sendBodyAndHeader("direct:start", "{\"status\": \"ok\"}", "operation", "test"); + +citrus.assertIsSatisfied(); +---- + +==== Per-exchange sequential validation + +Use `validate()` to specify different validation expectations for the 1st, 2nd, 3rd, etc. +exchange. Each call to `validate()` auto-increments the index, so specs are applied in order. +Each incoming exchange triggers the next validation spec in sequence: + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); + +citrus.validate().body("{\"event\": \"created\"}").header("action", "create"); +citrus.validate().body("{\"event\": \"updated\"}").header("action", "update"); +citrus.validate().body("{\"event\": \"deleted\"}").header("action", "delete"); +citrus.expectedMessageCount(3); + +template.sendBodyAndHeader("direct:start", "{\"event\": \"created\"}", "action", "create"); +template.sendBodyAndHeader("direct:start", "{\"event\": \"updated\"}", "action", "update"); +template.sendBodyAndHeader("direct:start", "{\"event\": \"deleted\"}", "action", "delete"); + +citrus.assertIsSatisfied(); +---- + +You can also use `validate(index)` to target a specific exchange by its 1-based position. + +Indexed specs (`validate()`, `validate(index)`, `when().receive()`) imply a minimum message count equal to the highest index, unless the test sets `expectedMessageCount(...)` or a larger `expectedMinimumMessageCount(...)`. So `assertIsSatisfied()` fails when fewer exchanges arrive. + +When both `validate(index)` and `validateAll()` are configured, the indexed spec takes priority — +it replaces `validateAll()` for that exchange entirely (the two are never combined). The +`validateAll()` spec acts as a fallback for exchanges that have no matching indexed spec. + +If an exchange arrives at an index with no matching indexed spec and no `validateAll()` fallback, +the exchange passes through without validation. No failure is recorded by the endpoint. +For the `validate` operation, unmatched exchanges are ignored silently. For the `receive` operation, +no simulated response is applied and the exchange continues unchanged. + +[source,java] +---- +citrus.validate(1).body("{\"event\": \"start\"}"); +citrus.validateAll().body("{\"event\": \"progress\"}").header("type", "status"); + +// Exchange 1 → validated against validate(1) only (validateAll() is NOT applied) +// Exchange 2, 3, ... → validated against validateAll() (body + header) +---- + +=== Receive operation (request/response simulation) + +The `receive` operation extends the validation endpoint with response simulation. It uses a +Gherkin-style fluent API to define what the endpoint expects to receive (`when`) and what it +should respond with (`then`). This is useful for simulating backend services in route tests. + +==== Basic usage + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + +// Define expected request and simulated response +citrus.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() + .body("{\"greeting\": \"Hello!\"}"); +citrus.expectedMessageCount(1); + +// Send a request and verify the response +Exchange result = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"action\": \"greet\"}"); +}); + +citrus.assertIsSatisfied(); +assertThat(result.getIn().getBody(String.class)).isEqualTo("{\"greeting\": \"Hello!\"}"); +---- + +==== Gherkin-style API + +The `when()` method returns a `WhenSpec` that provides a chained fluent syntax: + +* `when().receive(index).body(...).then().respond()` — define validation and response for a specific exchange (1-based index) +* `when().receive().body(...).then().respond()` — auto-incrementing index for sequential exchanges +* `when().receiveAll().body(...).then().respond()` — define default validation and response for all exchanges +* `then().send()` — alias for `respond()` + +Both the `receive()` / `receiveAll()` spec and the `respond()` / `send()` spec support `body()` and `header()` methods. + +==== Per-exchange request/response + +You can define different request expectations and responses for each exchange in sequence: + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + +citrus.when().receive(1).body("{\"action\": \"create\"}") + .then().respond().body("{\"id\": \"1\", \"status\": \"created\"}"); + +citrus.when().receive(2).body("{\"action\": \"update\"}") + .then().respond().body("{\"id\": \"1\", \"status\": \"updated\"}"); + +citrus.expectedMessageCount(2); +---- + +Indexed specs take priority over `receiveAll()` for both request validation and response +simulation. When a specific exchange has no indexed spec, the `receiveAll()` spec is used as +a fallback. + +==== Response with headers + +[source,java] +---- +citrus.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() + .body("{\"greeting\": \"Hello!\"}") + .header("Content-Type", "application/json") + .header("X-Status", "ok"); +---- + +==== Citrus variables in responses + +Exchange properties and exchange variables are available as Citrus test variables in response +templates. This enables dynamic response generation based on the incoming request context: + +[source,java] +---- +citrus.when().receiveAll() + .body("{\"action\": \"greet\", \"name\": \"@ignore@\"}") + .then().respond() + .body("{\"greeting\": \"Hello ${userName}!\"}") + .header("X-Correlation-Id", "${correlationId}"); +---- + +==== Response-only mode + +When you only need response simulation without request validation, omit the body on `receive()`: + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:receive/responder", CitrusEndpoint.class); + +citrus.when().receive().then().respond() + .body("OK") + .header("status", "200"); +citrus.expectedMessageCount(1); +---- + +=== Error handling and redelivery + +The two operations handle validation failures differently with respect to Camel's error handling: + +* **`validate` operation** — validation failures are captured internally and the exception is + cleared from the exchange. Camel's `onException` handlers and redelivery policies do *not* + trigger. Failures surface only when `assertIsSatisfied()` is called. +* **`receive` operation** — validation failures remain on the exchange as an exception. Camel's + `onException` handlers and redelivery policies *do* trigger, which matches the expected + behavior for a request/response endpoint where callers need to see errors. + +NOTE: Each redelivery attempt counts as a new exchange in the mock counter. So if a +`receive(1)` spec fails and Camel redelivers, the retry is exchange 2 and +`receive(2)` applies to it — not `receive(1)` again. If no spec exists for the retry, it +passes without validation and the earlier failure is not recorded, so set +`expectedMessageCount(...)` to catch the extra exchange. + +=== Exchange properties and variables as Citrus variables + +All exchange properties and exchange variables are automatically propagated to the Citrus test +context as test variables. This enables the use of Citrus variable placeholders (pass:c[`${variableName}`]) +in expected message templates and response bodies. + +Variables extracted by Citrus during validation (e.g., via `@variable('name')@`) are written back +to the exchange as exchange variables, making them available for downstream route processing. + +=== Path validation + +Use the `validate()` method on a `MessageValidationSpec` to add Citrus validation context builders +such as JsonPath or XPath expressions. These are provided by the Citrus `DefaultValidations` class: + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); +citrus.validateAll().validate(new DefaultValidations().jsonPath() + .expression("$.user.name", "John") + .expression("$.user.age", "30")); +---- + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=xml", CitrusEndpoint.class); +citrus.validateAll().validate(new DefaultValidations().xpath() + .expression("/order/status", "completed") + .expression("/order/total", "99.99")); +---- diff --git a/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusComponent.java b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusComponent.java new file mode 100644 index 0000000000000..f41e7e400a839 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusComponent.java @@ -0,0 +1,51 @@ +/* + * 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.citrus; + +import java.util.Map; + +import org.apache.camel.Endpoint; +import org.apache.camel.component.mock.MockComponent; +import org.apache.camel.spi.annotations.Component; + +@Component("citrus") +public class CitrusComponent extends MockComponent { + + @Override + protected Endpoint createEndpoint(String uri, String remaining, Map parameters) throws Exception { + CitrusEndpoint endpoint = new CitrusEndpoint(uri, this); + + String operation; + String name = null; + int idx = remaining.indexOf('/'); + if (idx > 0) { + operation = remaining.substring(0, idx); + name = remaining.substring(idx + 1); + } else { + operation = remaining; + } + + if (!"validate".equals(operation) && !"receive".equals(operation)) { + throw new IllegalArgumentException( + "Invalid citrus operation: '" + operation + "'. Valid values are: validate, receive"); + } + endpoint.setOperation(operation); + endpoint.setName(name); + setProperties(endpoint, parameters); + return endpoint; + } +} diff --git a/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusEndpoint.java b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusEndpoint.java new file mode 100644 index 0000000000000..efcdfc354e20a --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusEndpoint.java @@ -0,0 +1,276 @@ +/* + * 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.citrus; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.camel.Category; +import org.apache.camel.Exchange; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.spi.Metadata; +import org.apache.camel.spi.UriEndpoint; +import org.apache.camel.spi.UriParam; +import org.apache.camel.spi.UriPath; +import org.citrusframework.CitrusContext; +import org.citrusframework.context.TestContext; +import org.citrusframework.message.MessagePayloadBuilder; +import org.citrusframework.validation.context.ValidationContext; + +@UriEndpoint(firstVersion = "4.24.0", scheme = "citrus", title = "Citrus", syntax = "citrus:operation/name", + producerOnly = true, remote = false, category = { Category.TESTING }) +public class CitrusEndpoint extends MockEndpoint { + + @UriPath(description = "The operation to perform. 'validate' validates exchanges without modifying them." + + " 'receive' validates exchanges and returns a simulated response.", + enums = "validate,receive") + @Metadata(required = true) + private String operation; + + @UriPath(description = "A unique name to identify this endpoint. Allows multiple Citrus endpoints" + + " with different validation expectations in the same route.") + private String name; + + @UriParam(description = "The message type for validation. Determines which Citrus validation module is used." + + " Supported values: json, xml, yaml, plaintext.") + private String messageType; + + private volatile CitrusContext citrusContext; + private final AtomicInteger nextSpecIndex = new AtomicInteger(0); + + public CitrusEndpoint(String endpointUri, CitrusComponent component) { + super(endpointUri, component); + } + + // --- Lifecycle --- + + @Override + protected void doStart() throws Exception { + super.doStart(); + citrusContext = CitrusContext.create(); + } + + @Override + protected void doStop() throws Exception { + if (citrusContext != null) { + citrusContext.close(); + citrusContext = null; + } + super.doStop(); + } + + // --- Fluent validation specification API --- + + public MessageValidationSpec validateAll() { + MessageValidationSpec messageValidationSpec = new MessageValidationSpec(); + whenAnyExchangeReceived(createProcessor(messageValidationSpec)); + return messageValidationSpec; + } + + /** + * Auto-incrementing variant. Do not mix with explicit {@code validate(index)} calls — the auto-index is unaware of + * explicit registrations and may overwrite them. + */ + public MessageValidationSpec validate() { + return validate(nextSpecIndex.incrementAndGet()); + } + + public MessageValidationSpec validate(int index) { + MessageValidationSpec messageValidationSpec = new MessageValidationSpec(); + whenExchangeReceived(index, createProcessor(messageValidationSpec)); + applyImpliedMinimumCount(index); + return messageValidationSpec; + } + + private CitrusValidationProcessor createProcessor(MessageValidationSpec validationSpec) { + return new CitrusValidationProcessor(this::createTestContext, messageType, validationSpec); + } + + private CitrusValidationProcessor createProcessor( + MessageValidationSpec validationSpec, + MessageResponseSpec responseSpec) { + return new CitrusValidationProcessor(this::createTestContext, messageType, validationSpec, responseSpec); + } + + private TestContext createTestContext() { + CitrusContext ctx = citrusContext; + if (ctx == null) { + throw new IllegalStateException("CitrusEndpoint is not started"); + } + return ctx.createTestContext(); + } + + // --- Gherkin-style API (for 'receive' operation) --- + + public WhenSpec when() { + return new WhenSpec(); + } + + private void applyImpliedMinimumCount(int index) { + if (getExpectedCount() < 0 && getExpectedMinimumCount() < index) { + expectedMinimumMessageCount(index); + } + } + + @Override + public void reset() { + super.reset(); + // guard against super constructor calling reset() before field initializer runs + if (nextSpecIndex != null) { + nextSpecIndex.set(0); + } + } + + @Override + protected void performAssertions(Exchange exchange, Exchange copy) throws Exception { + super.performAssertions(exchange, copy); + + // tryProcessing() in the superclass swallows processor exceptions and sets them on the + // exchange instead of adding them to the failures list. Capture them here so + // assertIsSatisfied() can report Citrus validation errors properly. + Exception e = exchange.getException(); + if (e != null && !getFailures().contains(e)) { + if ("validate".equals(operation)) { + // For validate-only mode, collect the error and clear the exception so it does not + // propagate through the route — validation errors are collected, not thrown. + getFailures().add(e); + exchange.setException(null); + } + // For receive mode, leave the exception on the exchange so Camel's onException + // handlers trigger. Do NOT add to failures — the error is handled by the route, + // not by assertIsSatisfied(). + } + } + + // --- Getters and setters --- + + @Override + public String getName() { + return name; + } + + @Override + public void setName(String name) { + this.name = name; + super.setName(name); + } + + public String getOperation() { + return operation; + } + + public void setOperation(String operation) { + this.operation = operation; + } + + public String getMessageType() { + return messageType; + } + + public void setMessageType(String messageType) { + this.messageType = messageType; + } + + // --- Gherkin-style inner classes --- + + public class WhenSpec { + + /** + * Auto-incrementing variant. Do not mix with explicit {@code receive(index)} calls. + */ + public WhenMessageValidationSpec receive() { + return receive(nextSpecIndex.incrementAndGet()); + } + + public WhenMessageValidationSpec receive(int index) { + // registers a validation-only processor now so receive() without respond() still validates; + // ThenSpec.respond() overwrites this registration with a validation+response processor + WhenMessageValidationSpec messageValidationSpec = new WhenMessageValidationSpec(index); + whenExchangeReceived(index, createProcessor(messageValidationSpec)); + applyImpliedMinimumCount(index); + return messageValidationSpec; + } + + public WhenMessageValidationSpec receiveAll() { + WhenMessageValidationSpec messageValidationSpec = new WhenMessageValidationSpec(-1); + whenAnyExchangeReceived(createProcessor(messageValidationSpec)); + return messageValidationSpec; + } + } + + public class WhenMessageValidationSpec extends MessageValidationSpec { + + private final int index; + + WhenMessageValidationSpec(int index) { + this.index = index; + } + + @Override + public WhenMessageValidationSpec body(MessagePayloadBuilder bodyBuilder) { + super.body(bodyBuilder); + return this; + } + + @Override + public WhenMessageValidationSpec body(String body) { + super.body(body); + return this; + } + + @Override + public WhenMessageValidationSpec header(String headerName, Object value) { + super.header(headerName, value); + return this; + } + + @Override + public WhenMessageValidationSpec validate(ValidationContext.Builder... validationContexts) { + super.validate(validationContexts); + return this; + } + + public ThenSpec then() { + return new ThenSpec(index, this); + } + } + + public class ThenSpec { + + private final int index; + private final WhenMessageValidationSpec messageValidationSpec; + + public ThenSpec(int index, WhenMessageValidationSpec messageValidationSpec) { + this.index = index; + this.messageValidationSpec = messageValidationSpec; + } + + public MessageResponseSpec respond() { + MessageResponseSpec messageResponseSpec = new MessageResponseSpec(); + if (index < 0) { + whenAnyExchangeReceived(createProcessor(messageValidationSpec, messageResponseSpec)); + } else { + whenExchangeReceived(index, createProcessor(messageValidationSpec, messageResponseSpec)); + } + return messageResponseSpec; + } + + public MessageResponseSpec send() { + return respond(); + } + } + +} diff --git a/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusValidationProcessor.java b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusValidationProcessor.java new file mode 100644 index 0000000000000..29d491d79cb26 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusValidationProcessor.java @@ -0,0 +1,227 @@ +/* + * 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.citrus; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +import org.apache.camel.Exchange; +import org.apache.camel.Processor; +import org.apache.camel.StreamCache; +import org.citrusframework.context.TestContext; +import org.citrusframework.message.DefaultMessage; +import org.citrusframework.message.MessagePayloadBuilder; +import org.citrusframework.message.MessagePayloadUtils; +import org.citrusframework.message.MessageType; +import org.citrusframework.validation.MessageValidator; +import org.citrusframework.validation.MessageValidatorRegistry; +import org.citrusframework.validation.context.DefaultMessageValidationContext; +import org.citrusframework.validation.context.HeaderValidationContext; +import org.citrusframework.validation.context.ValidationContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class CitrusValidationProcessor implements Processor { + + private static final Logger LOG = LoggerFactory.getLogger(CitrusValidationProcessor.class); + + private final Supplier testContextSupplier; + private final String messageType; + private final MessageValidationSpec spec; + private final MessageResponseSpec responseSpec; + + CitrusValidationProcessor(Supplier testContextSupplier, String messageType, + MessageValidationSpec validationSpec) { + this(testContextSupplier, messageType, validationSpec, null); + } + + CitrusValidationProcessor(Supplier testContextSupplier, String messageType, + MessageValidationSpec validationSpec, + MessageResponseSpec responseSpec) { + this.testContextSupplier = testContextSupplier; + this.messageType = messageType; + this.spec = validationSpec; + this.responseSpec = responseSpec; + } + + @Override + public void process(Exchange exchange) throws Exception { + TestContext testContext = testContextSupplier.get(); + propagateExchangeVariables(exchange, testContext); + Map propagated = new HashMap<>(testContext.getVariables()); + + try { + if (spec != null) { + DefaultMessage receivedMessage = buildReceivedMessage(exchange); + DefaultMessage controlMessage = buildControlMessage(spec, testContext); + + String resolvedType = resolveMessageType(spec); + controlMessage.setType(resolvedType); + + List validationContexts = buildValidationContexts(spec); + performCitrusValidation(receivedMessage, controlMessage, testContext, validationContexts, resolvedType); + } + + if (responseSpec != null) { + applyResponse(exchange, responseSpec, testContext); + } + } finally { + writeBackExtractedVariables(exchange, testContext, propagated); + } + } + + // --- Citrus validation helpers --- + + private DefaultMessage buildReceivedMessage(Exchange exchange) { + Object body = exchange.getIn().getBody(); + if (body instanceof StreamCache sc) { + body = exchange.getIn().getBody(String.class); + sc.reset(); + } + Map headers = new LinkedHashMap<>(exchange.getIn().getHeaders()); + return new DefaultMessage(body, headers); + } + + private DefaultMessage buildControlMessage(MessageValidationSpec spec, TestContext testContext) { + Object resolvedBody = resolveBody(spec.getBody(), spec.getBodyBuilder(), testContext); + DefaultMessage controlMessage = resolvedBody != null + ? new DefaultMessage(resolvedBody) + : new DefaultMessage(); + + for (Map.Entry entry : spec.getHeaders().entrySet()) { + Object value = entry.getValue(); + if (value instanceof String s) { + value = testContext.replaceDynamicContentInString(s); + } + controlMessage.setHeader(entry.getKey(), value); + } + return controlMessage; + } + + private String resolveMessageType(MessageValidationSpec spec) { + if (messageType != null) { + if (MessageType.knows(messageType)) { + return MessageType.valueOf(messageType.toUpperCase(Locale.ROOT)).name(); + } else { + return messageType.toUpperCase(Locale.ROOT); + } + } + + String expectedBody = spec.getBody(); + if (expectedBody != null) { + if (MessagePayloadUtils.isJson(expectedBody)) { + return MessageType.JSON.name(); + } else if (MessagePayloadUtils.isXml(expectedBody)) { + return MessageType.XML.name(); + } else if (MessagePayloadUtils.isYaml(expectedBody)) { + return MessageType.YAML.name(); + } + } + return MessageType.PLAINTEXT.name(); + } + + private List buildValidationContexts(MessageValidationSpec spec) { + List contexts = new ArrayList<>(); + contexts.add(new DefaultMessageValidationContext()); + if (!spec.getHeaders().isEmpty()) { + contexts.add(new HeaderValidationContext.Builder().ignoreCase(true).build()); + } + for (ValidationContext.Builder builder : spec.getValidationContextBuilders()) { + contexts.add(builder.build()); + } + return contexts; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void performCitrusValidation( + DefaultMessage receivedMessage, DefaultMessage controlMessage, + TestContext testContext, List validationContexts, + String resolvedType) { + + MessageValidatorRegistry registry = testContext.getMessageValidatorRegistry(); + List> validators + = registry.findMessageValidators(resolvedType, receivedMessage); + + if (validators.isEmpty()) { + throw new IllegalStateException( + "No Citrus message validator found for message type '" + resolvedType + + "'. Make sure to add the required Citrus validation module " + + "to the test classpath (e.g. citrus-validation-json, " + + "citrus-validation-xml, citrus-validation-text)."); + } + + LOG.debug("Validating exchange with {} Citrus validator(s) for message type '{}'", + validators.size(), resolvedType); + + for (MessageValidator validator : validators) { + validator.validateMessage(receivedMessage, controlMessage, testContext, validationContexts); + } + } + + private void applyResponse(Exchange exchange, MessageResponseSpec responseSpec, TestContext testContext) { + Object resolvedBody = resolveBody(responseSpec.getBody(), responseSpec.getBodyBuilder(), testContext); + if (resolvedBody != null) { + exchange.getIn().setBody(resolvedBody); + } + Map resolvedHeaders = testContext.resolveDynamicValuesInMap(responseSpec.getHeaders()); + resolvedHeaders.forEach((key, value) -> exchange.getIn().setHeader(key, value)); + } + + private Object resolveBody(String body, MessagePayloadBuilder bodyBuilder, TestContext testContext) { + if (bodyBuilder != null) { + return bodyBuilder.buildPayload(testContext); + } + if (body != null) { + return testContext.replaceDynamicContentInString(body); + } + return null; + } + + // --- Variable propagation --- + + private void propagateExchangeVariables(Exchange exchange, TestContext testContext) { + for (Map.Entry entry : exchange.getProperties().entrySet()) { + if (entry.getValue() != null) { + testContext.setVariable(entry.getKey(), entry.getValue()); + } + } + for (Map.Entry entry : exchange.getVariables().entrySet()) { + if (entry.getValue() != null) { + testContext.setVariable(entry.getKey(), entry.getValue()); + } + } + } + + private void writeBackExtractedVariables(Exchange exchange, TestContext testContext, Map propagated) { + for (Map.Entry entry : testContext.getVariables().entrySet()) { + String key = entry.getKey(); + if (key.startsWith("citrus.") || key.startsWith("CITRUS_")) { + continue; + } + Object value = entry.getValue(); + if (!propagated.containsKey(key) || !Objects.equals(propagated.get(key), value)) { + exchange.setVariable(key, value); + } + } + } +} diff --git a/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/MessageResponseSpec.java b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/MessageResponseSpec.java new file mode 100644 index 0000000000000..2e1ce576bfc57 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/MessageResponseSpec.java @@ -0,0 +1,59 @@ +/* + * 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.citrus; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.citrusframework.message.MessagePayloadBuilder; + +public class MessageResponseSpec { + + private String body; + private MessagePayloadBuilder bodyBuilder; + private final Map headers = new LinkedHashMap<>(); + + public MessageResponseSpec body(String body) { + this.body = body; + this.bodyBuilder = null; + return this; + } + + public MessageResponseSpec body(MessagePayloadBuilder bodyBuilder) { + this.bodyBuilder = bodyBuilder; + this.body = null; + return this; + } + + public MessageResponseSpec header(String headerName, Object value) { + this.headers.put(headerName, value); + return this; + } + + public String getBody() { + return body; + } + + public MessagePayloadBuilder getBodyBuilder() { + return bodyBuilder; + } + + public Map getHeaders() { + return headers; + } +} diff --git a/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/MessageValidationSpec.java b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/MessageValidationSpec.java new file mode 100644 index 0000000000000..5e276576c05e2 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/MessageValidationSpec.java @@ -0,0 +1,72 @@ +/* + * 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.citrus; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.citrusframework.message.MessagePayloadBuilder; +import org.citrusframework.validation.context.ValidationContext; + +public class MessageValidationSpec { + + private String body; + private MessagePayloadBuilder bodyBuilder; + private final Map headers = new LinkedHashMap<>(); + private final List> validationContextBuilders = new ArrayList<>(); + + public MessageValidationSpec body(String body) { + this.body = body; + this.bodyBuilder = null; + return this; + } + + public MessageValidationSpec body(MessagePayloadBuilder bodyBuilder) { + this.bodyBuilder = bodyBuilder; + this.body = null; + return this; + } + + public MessageValidationSpec header(String headerName, Object value) { + this.headers.put(headerName, value); + return this; + } + + public MessageValidationSpec validate(ValidationContext.Builder... validationContexts) { + this.validationContextBuilders.addAll(List.of(validationContexts)); + return this; + } + + public String getBody() { + return body; + } + + public MessagePayloadBuilder getBodyBuilder() { + return bodyBuilder; + } + + public Map getHeaders() { + return headers; + } + + public List> getValidationContextBuilders() { + return validationContextBuilders; + } +} diff --git a/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusMultipleEndpointsTest.java b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusMultipleEndpointsTest.java new file mode 100644 index 0000000000000..8e2524b522486 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusMultipleEndpointsTest.java @@ -0,0 +1,82 @@ +/* + * 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.citrus; + +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CitrusMultipleEndpointsTest extends CamelTestSupport { + + @Test + void shouldValidateMultipleEndpointsInRoute() throws Exception { + CitrusEndpoint orderCheck + = context.getEndpoint("citrus:validate/orderCheck", CitrusEndpoint.class); + orderCheck.validateAll().body(""" + { + "orderId": "${orderId}", + "status": "created" + } + """); + orderCheck.expectedMessageCount(1); + + CitrusEndpoint responseCheck + = context.getEndpoint("citrus:validate/responseCheck", CitrusEndpoint.class); + responseCheck.validateAll().body(""" + { + "orderId": "${orderId}", + "status": "processed" + } + """); + responseCheck.expectedMessageCount(1); + + template.send("direct:start", exchange -> { + exchange.setProperty("orderId", "ORD-001"); + exchange.getIn().setBody(""" + { + "orderId": "ORD-001", + "status": "created" + } + """); + }); + + orderCheck.assertIsSatisfied(); + responseCheck.assertIsSatisfied(); + + assertThat(orderCheck.getReceivedCounter()).isEqualTo(1); + assertThat(responseCheck.getReceivedCounter()).isEqualTo(1); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start") + .to("citrus:validate/orderCheck") + .process(exchange -> { + String body = exchange.getIn().getMandatoryBody(String.class); + exchange.getIn().setBody(body.replace("created", "processed")); + }) + .to("citrus:validate/responseCheck"); + } + }; + } +} diff --git a/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusReceiveErrorHandlingTest.java b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusReceiveErrorHandlingTest.java new file mode 100644 index 0000000000000..3b52c39aab954 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusReceiveErrorHandlingTest.java @@ -0,0 +1,88 @@ +/* + * 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.citrus; + +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CitrusReceiveErrorHandlingTest extends CamelTestSupport { + + @Test + void validateOperationDoesNotTriggerOnException() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + MockEndpoint handled = context.getEndpoint("mock:handled", MockEndpoint.class); + + citrus.validateAll().body("{\"action\": \"expected\"}"); + citrus.expectedMessageCount(1); + handled.expectedMessageCount(0); + + template.sendBody("direct:validate", "{\"action\": \"wrong\"}"); + + handled.assertIsSatisfied(); + assertThat(citrus.getReceivedCounter()).isEqualTo(1); + assertThat(citrus.getFailures()).hasSize(1); + assertThatThrownBy(citrus::assertIsSatisfied) + .isInstanceOf(AssertionError.class); + } + + @Test + void receiveOperationTriggersOnException() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + MockEndpoint handled = context.getEndpoint("mock:handled", MockEndpoint.class); + + citrus.when().receiveAll() + .body("{\"action\": \"expected\"}") + .then().respond() + .body("{\"result\": \"ok\"}"); + citrus.expectedMessageCount(1); + handled.expectedMessageCount(1); + + template.send("direct:receive", exchange -> { + exchange.getIn().setBody("{\"action\": \"wrong\"}"); + }); + + handled.assertIsSatisfied(); + assertThat(citrus.getReceivedCounter()).isEqualTo(1); + // receive mode does not add to failures — assertIsSatisfied passes because + // the error was handled by onException, not collected by the endpoint + citrus.assertIsSatisfied(); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + onException(Exception.class) + .handled(true) + .to("mock:handled"); + + from("direct:validate") + .to("citrus:validate/test?messageType=json"); + + from("direct:receive") + .to("citrus:receive/service?messageType=json"); + } + }; + } +} diff --git a/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusReceiveTest.java b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusReceiveTest.java new file mode 100644 index 0000000000000..a89579f7d841a --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusReceiveTest.java @@ -0,0 +1,239 @@ +/* + * 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.citrus; + +import org.apache.camel.Exchange; +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CitrusReceiveTest extends CamelTestSupport { + + @Test + void shouldValidateAndRespond() throws Exception { + CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + + mock.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() + .body("{\"greeting\": \"Hello!\"}"); + mock.expectedMessageCount(1); + + Exchange result = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"action\": \"greet\"}"); + }); + + mock.assertIsSatisfied(); + assertThat(result.getIn().getBody(String.class)).isEqualTo("{\"greeting\": \"Hello!\"}"); + } + + @Test + void shouldRespondWithHeaders() throws Exception { + CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + + mock.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() + .body("{\"greeting\": \"Hello!\"}") + .header("Content-Type", "application/json") + .header("X-Status", "ok"); + mock.expectedMessageCount(1); + + Exchange result = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"action\": \"greet\"}"); + }); + + mock.assertIsSatisfied(); + assertThat(result.getIn().getBody(String.class)).isEqualTo("{\"greeting\": \"Hello!\"}"); + assertThat(result.getIn().getHeader("Content-Type")).isEqualTo("application/json"); + assertThat(result.getIn().getHeader("X-Status")).isEqualTo("ok"); + } + + @Test + void shouldRespondWithCitrusVariables() throws Exception { + CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + + mock.when().receiveAll() + .body("{\"action\": \"greet\", \"name\": \"@ignore@\"}") + .then().respond() + .body("{\"greeting\": \"Hello ${userName}!\"}"); + mock.expectedMessageCount(1); + + Exchange result = template.send("direct:start", exchange -> { + exchange.setProperty("userName", "World"); + exchange.getIn().setBody("{\"action\": \"greet\", \"name\": \"World\"}"); + }); + + mock.assertIsSatisfied(); + assertThat(result.getIn().getBody(String.class)).isEqualTo("{\"greeting\": \"Hello World!\"}"); + } + + @Test + void shouldRespondWithVariablesInHeaders() throws Exception { + CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + + mock.when().receiveAll() + .body("{\"action\": \"check\"}") + .then().respond() + .body("{\"result\": \"ok\"}") + .header("X-Correlation-Id", "${correlationId}"); + mock.expectedMessageCount(1); + + Exchange result = template.send("direct:start", exchange -> { + exchange.setProperty("correlationId", "abc-123"); + exchange.getIn().setBody("{\"action\": \"check\"}"); + }); + + mock.assertIsSatisfied(); + assertThat(result.getIn().getBody(String.class)).isEqualTo("{\"result\": \"ok\"}"); + assertThat(result.getIn().getHeader("X-Correlation-Id")).isEqualTo("abc-123"); + } + + @Test + void shouldHandleSequentialReceiveAndRespond() throws Exception { + CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + + mock.when().receive(1).body("{\"action\": \"create\"}") + .then().respond().body("{\"id\": \"1\", \"status\": \"created\"}"); + + mock.when().receive(2).body("{\"action\": \"update\"}") + .then().respond().body("{\"id\": \"1\", \"status\": \"updated\"}"); + + mock.expectedMessageCount(2); + + Exchange result1 = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"action\": \"create\"}"); + }); + + Exchange result2 = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"action\": \"update\"}"); + }); + + mock.assertIsSatisfied(); + assertThat(result1.getIn().getBody(String.class)).isEqualTo("{\"id\": \"1\", \"status\": \"created\"}"); + assertThat(result2.getIn().getBody(String.class)).isEqualTo("{\"id\": \"1\", \"status\": \"updated\"}"); + } + + @Test + void shouldFallBackToAllMessagesResponse() throws Exception { + CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + + mock.when().receive(1).body("{\"action\": \"first\"}") + .then().respond().body("{\"result\": \"special\"}"); + + mock.when().receiveAll().body("{\"action\": \"@ignore@\"}") + .then().respond().body("{\"result\": \"default\"}"); + + mock.expectedMessageCount(3); + + Exchange result1 = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"action\": \"first\"}"); + }); + Exchange result2 = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"action\": \"second\"}"); + }); + Exchange result3 = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"action\": \"third\"}"); + }); + + mock.assertIsSatisfied(); + assertThat(result1.getIn().getBody(String.class)).isEqualTo("{\"result\": \"special\"}"); + assertThat(result2.getIn().getBody(String.class)).isEqualTo("{\"result\": \"default\"}"); + assertThat(result3.getIn().getBody(String.class)).isEqualTo("{\"result\": \"default\"}"); + } + + @Test + void shouldUseSendAliasForRespond() throws Exception { + CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + + mock.when().receive().body("{\"ping\": true}") + .then().send().body("{\"pong\": true}"); + mock.expectedMessageCount(1); + + Exchange result = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"ping\": true}"); + }); + + mock.assertIsSatisfied(); + assertThat(result.getIn().getBody(String.class)).isEqualTo("{\"pong\": true}"); + } + + @Test + void shouldRespondWithoutValidation() throws Exception { + CitrusEndpoint mock = context.getEndpoint("citrus:receive/responder", CitrusEndpoint.class); + mock.when().receive().then().respond() + .body("OK") + .header("status", "200"); + mock.expectedMessageCount(1); + + Exchange result = template.send("direct:responder", exchange -> { + exchange.getIn().setBody("any request"); + }); + + mock.assertIsSatisfied(); + assertThat(result.getIn().getBody(String.class)).isEqualTo("OK"); + assertThat(result.getIn().getHeader("status")).isEqualTo("200"); + } + + @Test + void shouldRespondWithPayloadBuilder() throws Exception { + CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + + mock.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() + .body(testContext -> "{\"greeting\": \"Hello from builder!\"}"); + mock.expectedMessageCount(1); + + Exchange result = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"action\": \"greet\"}"); + }); + + mock.assertIsSatisfied(); + assertThat(result.getIn().getBody(String.class)).isEqualTo("{\"greeting\": \"Hello from builder!\"}"); + } + + @Test + void shouldFailOnValidationMismatch() { + CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); + + mock.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() + .body("{\"greeting\": \"Hello!\"}"); + + Exchange result = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"action\": \"wrong\"}"); + }); + + assertThat(result.getException()).isNotNull(); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start").to("citrus:receive/service?messageType=json"); + from("direct:responder").to("citrus:receive/responder"); + } + }; + } +} diff --git a/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusSequentialValidationTest.java b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusSequentialValidationTest.java new file mode 100644 index 0000000000000..9b25baefa07ad --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusSequentialValidationTest.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.citrus; + +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.citrusframework.exceptions.ValidationException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CitrusSequentialValidationTest extends CamelTestSupport { + + @Test + void shouldValidateSequentialExchanges() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.validate().body(""" + {"event": "created"} + """); + citrus.validate().body(""" + {"event": "updated"} + """); + citrus.validate().body(""" + {"event": "deleted"} + """); + citrus.expectedMessageCount(3); + + template.sendBody("direct:start", "{\"event\": \"created\"}"); + template.sendBody("direct:start", "{\"event\": \"updated\"}"); + template.sendBody("direct:start", "{\"event\": \"deleted\"}"); + + citrus.assertIsSatisfied(); + assertThat(citrus.getReceivedCounter()).isEqualTo(3); + } + + @Test + void shouldValidateSequentialExchangesWithHeaders() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.validate() + .body("{\"status\": \"pending\"}") + .header("action", "create"); + citrus.validate() + .body("{\"status\": \"done\"}") + .header("action", "complete"); + citrus.expectedMessageCount(2); + + template.sendBodyAndHeader("direct:start", "{\"status\": \"pending\"}", "action", "create"); + template.sendBodyAndHeader("direct:start", "{\"status\": \"done\"}", "action", "complete"); + + citrus.assertIsSatisfied(); + } + + @Test + void shouldPassThroughExchangeWithoutMatchingSpec() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.validate(1).body("{\"event\": \"first\"}"); + citrus.expectedMessageCount(2); + + template.sendBody("direct:start", "{\"event\": \"first\"}"); + template.sendBody("direct:start", "{\"event\": \"second\"}"); + + citrus.assertIsSatisfied(); + assertThat(citrus.getReceivedCounter()).isEqualTo(2); + } + + @Test + void shouldFallBackToAllMessagesSpec() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.validate(1).body("{\"event\": \"first\"}"); + citrus.validateAll().body("{\"event\": \"default\"}"); + citrus.expectedMessageCount(3); + + template.sendBody("direct:start", "{\"event\": \"first\"}"); + template.sendBody("direct:start", "{\"event\": \"default\"}"); + template.sendBody("direct:start", "{\"event\": \"default\"}"); + + citrus.assertIsSatisfied(); + assertThat(citrus.getReceivedCounter()).isEqualTo(3); + } + + @Test + void shouldValidateAllMessagesWithSameSpec() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.validateAll() + .body("{\"status\": \"ok\"}") + .header("type", "health"); + citrus.expectedMessageCount(3); + + template.sendBodyAndHeader("direct:start", "{\"status\": \"ok\"}", "type", "health"); + template.sendBodyAndHeader("direct:start", "{\"status\": \"ok\"}", "type", "health"); + template.sendBodyAndHeader("direct:start", "{\"status\": \"ok\"}", "type", "health"); + + citrus.assertIsSatisfied(); + } + + @Test + void shouldFailSequentialValidationOnMismatch() { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.validate(1).body("{\"event\": \"created\"}"); + citrus.validate(2).body("{\"event\": \"updated\"}"); + citrus.expectedMessageCount(2); + + template.sendBody("direct:start", "{\"event\": \"created\"}"); + template.sendBody("direct:start", "{\"event\": \"wrong\"}"); + + assertThatThrownBy(citrus::assertIsSatisfied) + .isInstanceOf(AssertionError.class) + .hasCauseInstanceOf(ValidationException.class); + } + + @Test + void shouldFailWhenImpliedMinimumCountNotMet() { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.validate(1).body("{\"event\": \"first\"}"); + citrus.validate(2).body("{\"event\": \"second\"}"); + citrus.validate(3).body("{\"event\": \"third\"}"); + + template.sendBody("direct:start", "{\"event\": \"first\"}"); + + assertThatThrownBy(citrus::assertIsSatisfied) + .isInstanceOf(AssertionError.class); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start").to("citrus:validate/test?messageType=json"); + } + }; + } +} diff --git a/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateJsonTest.java b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateJsonTest.java new file mode 100644 index 0000000000000..c9e6543c694a7 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateJsonTest.java @@ -0,0 +1,180 @@ +/* + * 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.citrus; + +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.validation.DefaultValidations; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CitrusValidateJsonTest extends CamelTestSupport { + + @Test + void shouldValidateJsonBody() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); + citrus.validateAll().body(""" + { + "greeting": "Hello World!" + } + """); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", """ + { + "greeting": "Hello World!" + } + """); + + citrus.assertIsSatisfied(); + assertThat(citrus.getReceivedCounter()).isEqualTo(1); + } + + @Test + void shouldFailOnJsonBodyMismatch() { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); + citrus.validateAll().body(""" + { + "greeting": "Hello World!" + } + """); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", """ + { + "greeting": "Goodbye!" + } + """); + + assertThatThrownBy(citrus::assertIsSatisfied) + .isInstanceOf(AssertionError.class) + .hasCauseInstanceOf(ValidationException.class); + } + + @Test + void shouldValidateJsonPathExpressions() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); + citrus.validateAll().validate(new DefaultValidations().jsonPath() + .expression("$.user.name", "John") + .expression("$.user.age", "30")); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", """ + { + "user": { + "name": "John", + "age": 30 + } + } + """); + + citrus.assertIsSatisfied(); + } + + @Test + void shouldAutoDetectJsonMessageType() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate", CitrusEndpoint.class); + citrus.validateAll().body(""" + { + "greeting": "Hello World!" + } + """); + citrus.expectedMessageCount(1); + + template.sendBody("direct:auto", """ + { + "greeting": "Hello World!" + } + """); + + citrus.assertIsSatisfied(); + } + + @Test + void shouldValidateHeaders() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); + citrus.validateAll() + .body(""" + { + "result": "ok" + } + """) + .header("operation", "test"); + citrus.expectedMessageCount(1); + + template.sendBodyAndHeader("direct:start", + "{\"result\": \"ok\"}", "operation", "test"); + + citrus.assertIsSatisfied(); + } + + @Test + void shouldPropagateExchangePropertiesAsVariables() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); + citrus.validateAll().body(""" + { + "message": "Hello ${userName}!" + } + """); + citrus.expectedMessageCount(1); + + template.send("direct:start", exchange -> { + exchange.setProperty("userName", "World"); + exchange.getIn().setBody(""" + { + "message": "Hello World!" + } + """); + }); + + citrus.assertIsSatisfied(); + } + + @Test + void shouldValidateWithPayloadBuilder() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); + citrus.validateAll().body(testContext -> """ + { + "greeting": "Hello World!" + } + """); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", """ + { + "greeting": "Hello World!" + } + """); + + citrus.assertIsSatisfied(); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start").to("citrus:validate?messageType=json"); + from("direct:auto").to("citrus:validate"); + } + }; + } +} diff --git a/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidatePlainTextTest.java b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidatePlainTextTest.java new file mode 100644 index 0000000000000..04818d849ccf2 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidatePlainTextTest.java @@ -0,0 +1,76 @@ +/* + * 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.citrus; + +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.citrusframework.exceptions.ValidationException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CitrusValidatePlainTextTest extends CamelTestSupport { + + @Test + void shouldValidatePlainTextBody() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=plaintext", CitrusEndpoint.class); + citrus.validateAll().body("Hello Camel!"); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", "Hello Camel!"); + + citrus.assertIsSatisfied(); + assertThat(citrus.getReceivedCounter()).isEqualTo(1); + } + + @Test + void shouldFailOnPlainTextMismatch() { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=plaintext", CitrusEndpoint.class); + citrus.validateAll().body("Hello Camel!"); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", "Goodbye Camel!"); + + assertThatThrownBy(citrus::assertIsSatisfied) + .isInstanceOf(AssertionError.class) + .hasCauseInstanceOf(ValidationException.class); + } + + @Test + void shouldValidateDefaultMessageType() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate", CitrusEndpoint.class); + citrus.validateAll().body("Default text"); + citrus.expectedMessageCount(1); + + template.sendBody("direct:default", "Default text"); + + citrus.assertIsSatisfied(); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start").to("citrus:validate?messageType=plaintext"); + from("direct:default").to("citrus:validate"); + } + }; + } +} diff --git a/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateXmlTest.java b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateXmlTest.java new file mode 100644 index 0000000000000..bb82ea3bb7f95 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateXmlTest.java @@ -0,0 +1,96 @@ +/* + * 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.citrus; + +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.citrusframework.validation.DefaultValidations; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CitrusValidateXmlTest extends CamelTestSupport { + + @Test + void shouldValidateXmlBody() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=xml", CitrusEndpoint.class); + citrus.validateAll().body(""" + + Hello World! + + """); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", """ + + Hello World! + + """); + + citrus.assertIsSatisfied(); + assertThat(citrus.getReceivedCounter()).isEqualTo(1); + } + + @Test + void shouldValidateXpathExpressions() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=xml", CitrusEndpoint.class); + citrus.validateAll().validate(new DefaultValidations().xpath() + .expression("/greeting/text", "Hello World!") + .expression("/greeting/author", "Camel")); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", """ + + Hello World! + Camel + + """); + + citrus.assertIsSatisfied(); + } + + @Test + void shouldAutoDetectXmlMessageType() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate", CitrusEndpoint.class); + citrus.validateAll().body(""" + + Hello World! + + """); + citrus.expectedMessageCount(1); + + template.sendBody("direct:auto", """ + + Hello World! + + """); + + citrus.assertIsSatisfied(); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start").to("citrus:validate?messageType=xml"); + from("direct:auto").to("citrus:validate"); + } + }; + } +} diff --git a/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateYamlTest.java b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateYamlTest.java new file mode 100644 index 0000000000000..3520b85d3eb82 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateYamlTest.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.citrus; + +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.citrusframework.exceptions.ValidationException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CitrusValidateYamlTest extends CamelTestSupport { + + @Test + void shouldValidateYamlBody() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=yaml", CitrusEndpoint.class); + citrus.validateAll().body(""" + greeting: Hello World! + """); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", """ + greeting: Hello World! + """); + + citrus.assertIsSatisfied(); + assertThat(citrus.getReceivedCounter()).isEqualTo(1); + } + + @Test + void shouldAutoDetectYamlMessageType() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate", CitrusEndpoint.class); + citrus.validateAll().body(""" + greeting: Hello World! + items: + - name: first + - name: second + """); + citrus.expectedMessageCount(1); + + template.sendBody("direct:auto", """ + greeting: Hello World! + items: + - name: first + - name: second + """); + + citrus.assertIsSatisfied(); + } + + @Test + void shouldPropagateExchangePropertiesAsVariables() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=yaml", CitrusEndpoint.class); + citrus.validateAll().body(""" + greeting: Hello ${userName}! + """); + citrus.expectedMessageCount(1); + + template.send("direct:start", exchange -> { + exchange.setProperty("userName", "World"); + exchange.getIn().setBody(""" + greeting: Hello World! + """); + }); + + citrus.assertIsSatisfied(); + } + + @Test + void shouldFailOnYamlBodyMismatch() { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=yaml", CitrusEndpoint.class); + citrus.validateAll().body(""" + greeting: Hello World! + """); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", """ + greeting: Goodbye! + """); + + assertThatThrownBy(citrus::assertIsSatisfied) + .isInstanceOf(AssertionError.class) + .hasCauseInstanceOf(ValidationException.class); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start").to("citrus:validate?messageType=yaml"); + from("direct:auto").to("citrus:validate"); + } + }; + } +} diff --git a/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusVariableTest.java b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusVariableTest.java new file mode 100644 index 0000000000000..6c56ddf524e2e --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusVariableTest.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.citrus; + +import org.apache.camel.Exchange; +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CitrusVariableTest extends CamelTestSupport { + + @Test + void shouldPropagateExchangeVariableToCitrusContext() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.validateAll().body("{\"greeting\": \"Hello ${userName}!\"}"); + citrus.expectedMessageCount(1); + + template.send("direct:start", exchange -> { + exchange.setVariable("userName", "World"); + exchange.getIn().setBody("{\"greeting\": \"Hello World!\"}"); + }); + + citrus.assertIsSatisfied(); + } + + @Test + void shouldExtractVariableFromBody() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.validateAll().body("{\"orderId\": \"@variable('extractedId')@\", \"status\": \"created\"}"); + citrus.expectedMessageCount(1); + + Exchange result = template.send("direct:start", exchange -> { + exchange.getIn().setBody("{\"orderId\": \"ABC-123\", \"status\": \"created\"}"); + }); + + citrus.assertIsSatisfied(); + assertThat(result.getVariable("extractedId")).isEqualTo("ABC-123"); + } + + @Test + void shouldExtractVariableEvenWhenPropertyExists() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.validateAll().body("{\"orderId\": \"@variable('orderId')@\", \"status\": \"ok\"}"); + citrus.expectedMessageCount(1); + + Exchange result = template.send("direct:start", exchange -> { + exchange.setProperty("orderId", "old-value"); + exchange.getIn().setBody("{\"orderId\": \"new-value\", \"status\": \"ok\"}"); + }); + + citrus.assertIsSatisfied(); + assertThat(result.getVariable("orderId")).isEqualTo("new-value"); + assertThat(result.getProperty("orderId")).isEqualTo("old-value"); + } + + @Test + void shouldNotWriteBackPropagatedPropertiesAsVariables() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.validateAll().body("{\"status\": \"ok\"}"); + citrus.expectedMessageCount(1); + + Exchange result = template.send("direct:start", exchange -> { + exchange.setProperty("myProp", "propValue"); + exchange.getIn().setBody("{\"status\": \"ok\"}"); + }); + + citrus.assertIsSatisfied(); + assertThat(result.getProperty("myProp")).isEqualTo("propValue"); + assertThat(result.getVariables().containsKey("myProp")).isFalse(); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start").to("citrus:validate/test?messageType=json"); + } + }; + } +} diff --git a/components/camel-test/camel-test-citrus/src/test/resources/log4j2-test.properties b/components/camel-test/camel-test-citrus/src/test/resources/log4j2-test.properties new file mode 100644 index 0000000000000..c74d167261ff7 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/resources/log4j2-test.properties @@ -0,0 +1,28 @@ +## --------------------------------------------------------------------------- +## 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. +## --------------------------------------------------------------------------- + +appender.file.type = File +appender.file.name = file +appender.file.fileName = target/camel-test-citrus.log +appender.file.layout.type = PatternLayout +appender.file.layout.pattern = %d %-5p %c{1} - %m %n +appender.out.type = Console +appender.out.name = out +appender.out.layout.type = PatternLayout +appender.out.layout.pattern = [%30.30t] %-30.30c{1} %-5p %m%n +rootLogger.level = INFO +rootLogger.appenderRef.file.ref = file diff --git a/components/camel-test/pom.xml b/components/camel-test/pom.xml index 2ea3c0bf9cd3a..6b4c29ca17d29 100644 --- a/components/camel-test/pom.xml +++ b/components/camel-test/pom.xml @@ -33,6 +33,7 @@ Camel Test parent + camel-test-citrus camel-test-junit5 camel-test-junit6 camel-test-spring-junit5 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 b1ac8d318b553..a72d3a6c6980d 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 @@ -75,6 +75,7 @@ caffeine-loadcache camunda chatscript chunk +citrus class clickhouse clickup diff --git a/core/camel-util/src/main/java/org/apache/camel/util/ArtifactUtils.java b/core/camel-util/src/main/java/org/apache/camel/util/ArtifactUtils.java index a600dfc7a8f9b..70003989610ba 100644 --- a/core/camel-util/src/main/java/org/apache/camel/util/ArtifactUtils.java +++ b/core/camel-util/src/main/java/org/apache/camel/util/ArtifactUtils.java @@ -111,6 +111,7 @@ public final class ArtifactUtils { Map.entry("camunda", "camel-camunda"), Map.entry("chatscript", "camel-chatscript"), Map.entry("chunk", "camel-chunk"), + Map.entry("citrus", "camel-test-citrus"), Map.entry("class", "camel-bean"), Map.entry("clickhouse", "camel-clickhouse"), Map.entry("clickup", "camel-clickup"), diff --git a/docs/components/modules/ROOT/examples/json/citrus.json b/docs/components/modules/ROOT/examples/json/citrus.json new file mode 120000 index 0000000000000..6d3150af656c4 --- /dev/null +++ b/docs/components/modules/ROOT/examples/json/citrus.json @@ -0,0 +1 @@ +../../../../../../components/camel-test/camel-test-citrus/src/generated/resources/META-INF/org/apache/camel/component/citrus/citrus.json \ No newline at end of file diff --git a/docs/components/modules/ROOT/nav.adoc b/docs/components/modules/ROOT/nav.adoc index a64de6c327792..beb1d440dfe5a 100644 --- a/docs/components/modules/ROOT/nav.adoc +++ b/docs/components/modules/ROOT/nav.adoc @@ -109,6 +109,7 @@ ** xref:camunda-component.adoc[Camunda] ** xref:cql-component.adoc[Cassandra CQL] ** xref:chunk-component.adoc[Chunk] +** xref:citrus-component.adoc[Citrus] ** xref:class-component.adoc[Class] ** xref:clickhouse-component.adoc[ClickHouse] ** xref:clickup-component.adoc[ClickUp] diff --git a/docs/components/modules/ROOT/pages/citrus-component.adoc b/docs/components/modules/ROOT/pages/citrus-component.adoc new file mode 120000 index 0000000000000..c7ca60b9c16fd --- /dev/null +++ b/docs/components/modules/ROOT/pages/citrus-component.adoc @@ -0,0 +1 @@ +../../../../../components/camel-test/camel-test-citrus/src/main/docs/citrus-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 d938f3faa0b4f..0134a73fbe96e 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 @@ -1060,6 +1060,19 @@ static ChatscriptComponentBuilderFactory.ChatscriptComponentBuilder chatscript() static ChunkComponentBuilderFactory.ChunkComponentBuilder chunk() { return ChunkComponentBuilderFactory.chunk(); } + /** + * Citrus (camel-test-citrus) + * Citrus message validation component for testing + * + * Category: testing + * Since: 4.24 + * Maven coordinates: org.apache.camel:camel-test-citrus + * + * @return the dsl builder + */ + static CitrusComponentBuilderFactory.CitrusComponentBuilder citrus() { + return CitrusComponentBuilderFactory.citrus(); + } /** * Class (camel-bean) * Invoke methods of Java beans specified by class name. diff --git a/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/CitrusComponentBuilderFactory.java b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/CitrusComponentBuilderFactory.java new file mode 100644 index 0000000000000..084839da52e85 --- /dev/null +++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/CitrusComponentBuilderFactory.java @@ -0,0 +1,159 @@ +/* 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.citrus.CitrusComponent; + +/** + * Citrus message validation component for testing + * + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.ComponentDslMojo") +public interface CitrusComponentBuilderFactory { + + /** + * Citrus (camel-test-citrus) + * Citrus message validation component for testing + * + * Category: testing + * Since: 4.24 + * Maven coordinates: org.apache.camel:camel-test-citrus + * + * @return the dsl builder + */ + static CitrusComponentBuilder citrus() { + return new CitrusComponentBuilderImpl(); + } + + /** + * Builder for the Citrus component. + */ + interface CitrusComponentBuilder extends ComponentBuilder { + + + /** + * 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 CitrusComponentBuilder lazyStartProducer(boolean lazyStartProducer) { + doSetProperty("lazyStartProducer", lazyStartProducer); + return this; + } + + + /** + * To turn on logging when the mock receives an incoming message. This + * will log only one time at INFO level for the incoming message. For + * more detailed logging, then set the logger to DEBUG level for the + * org.apache.camel.component.mock.MockEndpoint class. + * + * The option is a: <code>boolean</code> type. + * + * Default: false + * Group: producer + * + * @param log the value to set + * @return the dsl builder + */ + default CitrusComponentBuilder log(boolean log) { + doSetProperty("log", log); + 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 CitrusComponentBuilder autowiredEnabled(boolean autowiredEnabled) { + doSetProperty("autowiredEnabled", autowiredEnabled); + return this; + } + + /** + * To use a custom ExchangeFormatter to format the Exchange into a + * String suitable for logging. + * + * The option is a: + * <code>org.apache.camel.spi.ExchangeFormatter</code> type. + * + * Group: advanced + * + * @param exchangeFormatter the value to set + * @return the dsl builder + */ + default CitrusComponentBuilder exchangeFormatter(org.apache.camel.spi.ExchangeFormatter exchangeFormatter) { + doSetProperty("exchangeFormatter", exchangeFormatter); + return this; + } + } + + class CitrusComponentBuilderImpl + extends AbstractComponentBuilder + implements CitrusComponentBuilder { + @Override + protected CitrusComponent buildConcreteComponent() { + return new CitrusComponent(); + } + @Override + protected boolean setPropertyOnComponent( + Component component, + String name, + Object value) { + switch (name) { + case "lazyStartProducer": ((CitrusComponent) component).setLazyStartProducer((boolean) value); return true; + case "log": ((CitrusComponent) component).setLog((boolean) value); return true; + case "autowiredEnabled": ((CitrusComponent) component).setAutowiredEnabled((boolean) value); return true; + case "exchangeFormatter": ((CitrusComponent) component).setExchangeFormatter((org.apache.camel.spi.ExchangeFormatter) 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 22e0fba1dd060..3b739d5ed91ac 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 @@ -67,6 +67,7 @@ public interface EndpointBuilderFactory org.apache.camel.builder.endpoint.dsl.ChatScriptEndpointBuilderFactory.ChatScriptBuilders, org.apache.camel.builder.endpoint.dsl.ChunkEndpointBuilderFactory.ChunkBuilders, org.apache.camel.builder.endpoint.dsl.CinderEndpointBuilderFactory.CinderBuilders, + org.apache.camel.builder.endpoint.dsl.CitrusEndpointBuilderFactory.CitrusBuilders, org.apache.camel.builder.endpoint.dsl.ClassEndpointBuilderFactory.ClassBuilders, org.apache.camel.builder.endpoint.dsl.ClickHouseEndpointBuilderFactory.ClickHouseBuilders, org.apache.camel.builder.endpoint.dsl.ClickUpEndpointBuilderFactory.ClickUpBuilders, 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 efc6e3411a4f7..4585eeaea46a2 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 @@ -64,6 +64,7 @@ public interface EndpointBuilders org.apache.camel.builder.endpoint.dsl.ChatScriptEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.ChunkEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.CinderEndpointBuilderFactory, + org.apache.camel.builder.endpoint.dsl.CitrusEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.ClassEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.ClickHouseEndpointBuilderFactory, org.apache.camel.builder.endpoint.dsl.ClickUpEndpointBuilderFactory, 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 85d6a1c5b689c..bef527f62ada6 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 @@ -3374,6 +3374,60 @@ public static ChunkEndpointBuilderFactory.ChunkEndpointBuilder chunk(String path public static ChunkEndpointBuilderFactory.ChunkEndpointBuilder chunk(String componentName, String path) { return ChunkEndpointBuilderFactory.endpointBuilder(componentName, path); } + /** + * Citrus (camel-test-citrus) + * Citrus message validation component for testing + * + * Category: testing + * Since: 4.24 + * Maven coordinates: org.apache.camel:camel-test-citrus + * + * Syntax: citrus:operation/name + * + * Path parameter: operation (required) + * The operation to perform. 'validate' validates exchanges without + * modifying them. 'receive' validates exchanges and returns a simulated + * response. + * There are 2 enums and the value can be one of: validate, receive + * + * Path parameter: name + * A unique name to identify this endpoint. Allows multiple Citrus endpoints + * with different validation expectations in the same route. + * + * @param path operation/name + * @return the dsl builder + */ + public static CitrusEndpointBuilderFactory.CitrusEndpointBuilder citrus(String path) { + return citrus("citrus", path); + } + /** + * Citrus (camel-test-citrus) + * Citrus message validation component for testing + * + * Category: testing + * Since: 4.24 + * Maven coordinates: org.apache.camel:camel-test-citrus + * + * Syntax: citrus:operation/name + * + * Path parameter: operation (required) + * The operation to perform. 'validate' validates exchanges without + * modifying them. 'receive' validates exchanges and returns a simulated + * response. + * There are 2 enums and the value can be one of: validate, receive + * + * Path parameter: name + * A unique name to identify this endpoint. Allows multiple Citrus endpoints + * with different validation expectations in the same route. + * + * @param componentName to use a custom component name for the endpoint + * instead of the default name + * @param path operation/name + * @return the dsl builder + */ + public static CitrusEndpointBuilderFactory.CitrusEndpointBuilder citrus(String componentName, String path) { + return CitrusEndpointBuilderFactory.endpointBuilder(componentName, path); + } /** * Class (camel-bean) * Invoke methods of Java beans specified by class name. diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/CitrusEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/CitrusEndpointBuilderFactory.java new file mode 100644 index 0000000000000..57bbadf83f6b2 --- /dev/null +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/CitrusEndpointBuilderFactory.java @@ -0,0 +1,643 @@ +/* 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; + +/** + * Citrus message validation component for testing + * + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.EndpointDslMojo") +public interface CitrusEndpointBuilderFactory { + + /** + * Builder for endpoint for the Citrus component. + */ + public interface CitrusEndpointBuilder + extends + EndpointProducerBuilder { + default AdvancedCitrusEndpointBuilder advanced() { + return (AdvancedCitrusEndpointBuilder) this; + } + + /** + * Sets a grace period after which the mock will re-assert to ensure the + * preliminary assertion is still valid. This is used, for example, to + * assert that exactly a number of messages arrive. For example, if the + * expected count was set to 5, then the assertion is satisfied when + * five or more messages arrive. To ensure that exactly 5 messages + * arrive, then you would need to wait a little period to ensure no + * further message arrives. This is what you can use this method for. By + * default, this period is disabled. + * + * The option is a: long type. + * + * Group: producer + * + * @param assertPeriod the value to set + * @return the dsl builder + */ + default CitrusEndpointBuilder assertPeriod(long assertPeriod) { + doSetProperty("assertPeriod", assertPeriod); + return this; + } + /** + * Sets a grace period after which the mock will re-assert to ensure the + * preliminary assertion is still valid. This is used, for example, to + * assert that exactly a number of messages arrive. For example, if the + * expected count was set to 5, then the assertion is satisfied when + * five or more messages arrive. To ensure that exactly 5 messages + * arrive, then you would need to wait a little period to ensure no + * further message arrives. This is what you can use this method for. By + * default, this period is disabled. + * + * The option will be converted to a long type. + * + * Group: producer + * + * @param assertPeriod the value to set + * @return the dsl builder + */ + default CitrusEndpointBuilder assertPeriod(String assertPeriod) { + doSetProperty("assertPeriod", assertPeriod); + return this; + } + /** + * Specifies the expected number of message exchanges that should be + * received by this mock. Beware: If you want to expect that 0 messages, + * then take extra care, as 0 matches when the tests starts, so you need + * to set a assert period time to let the test run for a while to make + * sure there are still no messages arrived; for that use the + * assertPeriod option. If you want to assert that exactly nth message + * arrives to this mock, then see also the assertPeriod option for + * further details. + * + * The option is a: int type. + * + * Default: -1 + * Group: producer + * + * @param expectedCount the value to set + * @return the dsl builder + */ + default CitrusEndpointBuilder expectedCount(int expectedCount) { + doSetProperty("expectedCount", expectedCount); + return this; + } + /** + * Specifies the expected number of message exchanges that should be + * received by this mock. Beware: If you want to expect that 0 messages, + * then take extra care, as 0 matches when the tests starts, so you need + * to set a assert period time to let the test run for a while to make + * sure there are still no messages arrived; for that use the + * assertPeriod option. If you want to assert that exactly nth message + * arrives to this mock, then see also the assertPeriod option for + * further details. + * + * The option will be converted to a int type. + * + * Default: -1 + * Group: producer + * + * @param expectedCount the value to set + * @return the dsl builder + */ + default CitrusEndpointBuilder expectedCount(String expectedCount) { + doSetProperty("expectedCount", expectedCount); + return this; + } + /** + * The message type for validation. Determines which Citrus validation + * module is used. Supported values: json, xml, yaml, plaintext. + * + * The option is a: java.lang.String type. + * + * Group: producer + * + * @param messageType the value to set + * @return the dsl builder + */ + default CitrusEndpointBuilder messageType(String messageType) { + doSetProperty("messageType", messageType); + return this; + } + } + + /** + * Advanced builder for endpoint for the Citrus component. + */ + public interface AdvancedCitrusEndpointBuilder + extends + EndpointProducerBuilder { + default CitrusEndpointBuilder basic() { + return (CitrusEndpointBuilder) this; + } + + /** + * Sets whether to make a deep copy of the incoming Exchange when + * received at this mock endpoint. + * + * The option is a: boolean type. + * + * Default: true + * Group: producer (advanced) + * + * @param copyOnExchange the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder copyOnExchange(boolean copyOnExchange) { + doSetProperty("copyOnExchange", copyOnExchange); + return this; + } + /** + * Sets whether to make a deep copy of the incoming Exchange when + * received at this mock endpoint. + * + * The option will be converted to a boolean type. + * + * Default: true + * Group: producer (advanced) + * + * @param copyOnExchange the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder copyOnExchange(String copyOnExchange) { + doSetProperty("copyOnExchange", copyOnExchange); + return this; + } + /** + * Sets whether assertIsSatisfied() should fail fast at the first + * detected failed expectation while it may otherwise wait for all + * expected messages to arrive before performing expectations + * verifications. Is by default true. Set to false to use behavior as in + * Camel 2.x. + * + * The option is a: boolean type. + * + * Default: true + * Group: producer (advanced) + * + * @param failFast the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder failFast(boolean failFast) { + doSetProperty("failFast", failFast); + return this; + } + /** + * Sets whether assertIsSatisfied() should fail fast at the first + * detected failed expectation while it may otherwise wait for all + * expected messages to arrive before performing expectations + * verifications. Is by default true. Set to false to use behavior as in + * Camel 2.x. + * + * The option will be converted to a boolean type. + * + * Default: true + * Group: producer (advanced) + * + * @param failFast the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder failFast(String failFast) { + doSetProperty("failFast", failFast); + 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 AdvancedCitrusEndpointBuilder 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 AdvancedCitrusEndpointBuilder lazyStartProducer(String lazyStartProducer) { + doSetProperty("lazyStartProducer", lazyStartProducer); + return this; + } + /** + * To turn on logging when the mock receives an incoming message. This + * will log only one time at INFO level for the incoming message. For + * more detailed logging, then set the logger to DEBUG level for the + * org.apache.camel.component.mock.MockEndpoint class. + * + * The option is a: boolean type. + * + * Default: false + * Group: producer (advanced) + * + * @param log the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder log(boolean log) { + doSetProperty("log", log); + return this; + } + /** + * To turn on logging when the mock receives an incoming message. This + * will log only one time at INFO level for the incoming message. For + * more detailed logging, then set the logger to DEBUG level for the + * org.apache.camel.component.mock.MockEndpoint class. + * + * The option will be converted to a boolean type. + * + * Default: false + * Group: producer (advanced) + * + * @param log the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder log(String log) { + doSetProperty("log", log); + return this; + } + /** + * A number that is used to turn on throughput logging based on groups + * of the size. + * + * The option is a: int type. + * + * Group: producer (advanced) + * + * @param reportGroup the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder reportGroup(int reportGroup) { + doSetProperty("reportGroup", reportGroup); + return this; + } + /** + * A number that is used to turn on throughput logging based on groups + * of the size. + * + * The option will be converted to a int type. + * + * Group: producer (advanced) + * + * @param reportGroup the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder reportGroup(String reportGroup) { + doSetProperty("reportGroup", reportGroup); + return this; + } + /** + * Sets the minimum expected amount of time the assertIsSatisfied() will + * wait on a latch until it is satisfied. + * + * The option is a: long type. + * + * Group: producer (advanced) + * + * @param resultMinimumWaitTime the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder resultMinimumWaitTime(long resultMinimumWaitTime) { + doSetProperty("resultMinimumWaitTime", resultMinimumWaitTime); + return this; + } + /** + * Sets the minimum expected amount of time the assertIsSatisfied() will + * wait on a latch until it is satisfied. + * + * The option will be converted to a long type. + * + * Group: producer (advanced) + * + * @param resultMinimumWaitTime the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder resultMinimumWaitTime(String resultMinimumWaitTime) { + doSetProperty("resultMinimumWaitTime", resultMinimumWaitTime); + return this; + } + /** + * Sets the maximum amount of time the assertIsSatisfied() will wait on + * a latch until it is satisfied. + * + * The option is a: long type. + * + * Group: producer (advanced) + * + * @param resultWaitTime the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder resultWaitTime(long resultWaitTime) { + doSetProperty("resultWaitTime", resultWaitTime); + return this; + } + /** + * Sets the maximum amount of time the assertIsSatisfied() will wait on + * a latch until it is satisfied. + * + * The option will be converted to a long type. + * + * Group: producer (advanced) + * + * @param resultWaitTime the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder resultWaitTime(String resultWaitTime) { + doSetProperty("resultWaitTime", resultWaitTime); + return this; + } + /** + * Specifies to only retain the first nth number of received Exchanges. + * This is used when testing with big data, to reduce memory consumption + * by not storing copies of every Exchange this mock endpoint receives. + * Important: When using this limitation, then the getReceivedCounter() + * will still return the actual number of received message. For example + * if we have received 5000 messages and have configured to only retain + * the first 10 Exchanges, then the getReceivedCounter() will still + * return 5000 but there is only the first 10 Exchanges in the + * getExchanges() and getReceivedExchanges() methods. When using this + * method, then some of the other expectation methods is not supported, + * for example the expectedBodiesReceived(Object...) sets a expectation + * on the first number of bodies received. You can configure both + * retainFirst and retainLast options, to limit both the first and last + * received. + * + * The option is a: int type. + * + * Default: -1 + * Group: producer (advanced) + * + * @param retainFirst the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder retainFirst(int retainFirst) { + doSetProperty("retainFirst", retainFirst); + return this; + } + /** + * Specifies to only retain the first nth number of received Exchanges. + * This is used when testing with big data, to reduce memory consumption + * by not storing copies of every Exchange this mock endpoint receives. + * Important: When using this limitation, then the getReceivedCounter() + * will still return the actual number of received message. For example + * if we have received 5000 messages and have configured to only retain + * the first 10 Exchanges, then the getReceivedCounter() will still + * return 5000 but there is only the first 10 Exchanges in the + * getExchanges() and getReceivedExchanges() methods. When using this + * method, then some of the other expectation methods is not supported, + * for example the expectedBodiesReceived(Object...) sets a expectation + * on the first number of bodies received. You can configure both + * retainFirst and retainLast options, to limit both the first and last + * received. + * + * The option will be converted to a int type. + * + * Default: -1 + * Group: producer (advanced) + * + * @param retainFirst the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder retainFirst(String retainFirst) { + doSetProperty("retainFirst", retainFirst); + return this; + } + /** + * Specifies to only retain the last nth number of received Exchanges. + * This is used when testing with big data, to reduce memory consumption + * by not storing copies of every Exchange this mock endpoint receives. + * Important: When using this limitation, then the getReceivedCounter() + * will still return the actual number of received message. For example + * if we have received 5000 messages and have configured to only retain + * the last 20 Exchanges, then the getReceivedCounter() will still + * return 5000 but there is only the last 20 Exchanges in the + * getExchanges() and getReceivedExchanges() methods. When using this + * method, then some of the other expectation methods is not supported, + * for example the expectedBodiesReceived(Object...) sets a expectation + * on the first number of bodies received. You can configure both + * retainFirst and retainLast options, to limit both the first and last + * received. + * + * The option is a: int type. + * + * Default: -1 + * Group: producer (advanced) + * + * @param retainLast the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder retainLast(int retainLast) { + doSetProperty("retainLast", retainLast); + return this; + } + /** + * Specifies to only retain the last nth number of received Exchanges. + * This is used when testing with big data, to reduce memory consumption + * by not storing copies of every Exchange this mock endpoint receives. + * Important: When using this limitation, then the getReceivedCounter() + * will still return the actual number of received message. For example + * if we have received 5000 messages and have configured to only retain + * the last 20 Exchanges, then the getReceivedCounter() will still + * return 5000 but there is only the last 20 Exchanges in the + * getExchanges() and getReceivedExchanges() methods. When using this + * method, then some of the other expectation methods is not supported, + * for example the expectedBodiesReceived(Object...) sets a expectation + * on the first number of bodies received. You can configure both + * retainFirst and retainLast options, to limit both the first and last + * received. + * + * The option will be converted to a int type. + * + * Default: -1 + * Group: producer (advanced) + * + * @param retainLast the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder retainLast(String retainLast) { + doSetProperty("retainLast", retainLast); + return this; + } + /** + * Allows a sleep to be specified to wait to check that this mock really + * is empty when expectedMessageCount(int) is called with zero value. + * + * The option is a: long type. + * + * Group: producer (advanced) + * + * @param sleepForEmptyTest the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder sleepForEmptyTest(long sleepForEmptyTest) { + doSetProperty("sleepForEmptyTest", sleepForEmptyTest); + return this; + } + /** + * Allows a sleep to be specified to wait to check that this mock really + * is empty when expectedMessageCount(int) is called with zero value. + * + * The option will be converted to a long type. + * + * Group: producer (advanced) + * + * @param sleepForEmptyTest the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder sleepForEmptyTest(String sleepForEmptyTest) { + doSetProperty("sleepForEmptyTest", sleepForEmptyTest); + return this; + } + /** + * Maximum number of messages to keep in memory available for browsing. + * Use 0 for unlimited. + * + * The option is a: int type. + * + * Default: 100 + * Group: advanced + * + * @param browseLimit the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder browseLimit(int browseLimit) { + doSetProperty("browseLimit", browseLimit); + return this; + } + /** + * Maximum number of messages to keep in memory available for browsing. + * Use 0 for unlimited. + * + * The option will be converted to a int type. + * + * Default: 100 + * Group: advanced + * + * @param browseLimit the value to set + * @return the dsl builder + */ + default AdvancedCitrusEndpointBuilder browseLimit(String browseLimit) { + doSetProperty("browseLimit", browseLimit); + return this; + } + } + + public interface CitrusBuilders { + /** + * Citrus (camel-test-citrus) + * Citrus message validation component for testing + * + * Category: testing + * Since: 4.24 + * Maven coordinates: org.apache.camel:camel-test-citrus + * + * Syntax: citrus:operation/name + * + * Path parameter: operation (required) + * The operation to perform. 'validate' validates exchanges without + * modifying them. 'receive' validates exchanges and returns a simulated + * response. + * There are 2 enums and the value can be one of: validate, receive + * + * Path parameter: name + * A unique name to identify this endpoint. Allows multiple Citrus + * endpoints with different validation expectations in the same route. + * + * @param path operation/name + * @return the dsl builder + */ + default CitrusEndpointBuilder citrus(String path) { + return CitrusEndpointBuilderFactory.endpointBuilder("citrus", path); + } + /** + * Citrus (camel-test-citrus) + * Citrus message validation component for testing + * + * Category: testing + * Since: 4.24 + * Maven coordinates: org.apache.camel:camel-test-citrus + * + * Syntax: citrus:operation/name + * + * Path parameter: operation (required) + * The operation to perform. 'validate' validates exchanges without + * modifying them. 'receive' validates exchanges and returns a simulated + * response. + * There are 2 enums and the value can be one of: validate, receive + * + * Path parameter: name + * A unique name to identify this endpoint. Allows multiple Citrus + * endpoints with different validation expectations in the same route. + * + * @param componentName to use a custom component name for the endpoint + * instead of the default name + * @param path operation/name + * @return the dsl builder + */ + default CitrusEndpointBuilder citrus(String componentName, String path) { + return CitrusEndpointBuilderFactory.endpointBuilder(componentName, path); + } + + } + static CitrusEndpointBuilder endpointBuilder(String componentName, String path) { + class CitrusEndpointBuilderImpl extends AbstractEndpointBuilder implements CitrusEndpointBuilder, AdvancedCitrusEndpointBuilder { + public CitrusEndpointBuilderImpl(String path) { + super(componentName, path); + } + } + return new CitrusEndpointBuilderImpl(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 ee70d3850ecef..46308c3243ed3 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 @@ -96,6 +96,7 @@ org.apache.camel.component.camunda.CamundaComponent=camel:camunda org.apache.camel.component.cassandra.CassandraComponent=camel:cassandraql org.apache.camel.component.chatscript.ChatScriptComponent=camel:chatscript org.apache.camel.component.chunk.ChunkComponent=camel:chunk +org.apache.camel.component.citrus.CitrusComponent=camel:test-citrus org.apache.camel.component.clickhouse.ClickHouseComponent=camel:clickhouse org.apache.camel.component.clickup.ClickUpComponent=camel:clickup org.apache.camel.component.cm.CMComponent=camel:cm-sms diff --git a/parent/pom.xml b/parent/pom.xml index 1f072fa7b645a..221f0124cbeb6 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -2959,6 +2959,11 @@ camel-tensorflow-serving ${project.version} + + org.apache.camel + camel-test-citrus + ${project.version} + org.apache.camel camel-test-junit5 diff --git a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java index e6debb8088a2a..707e00167c249 100644 --- a/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java +++ b/tooling/maven/camel-package-maven-plugin/src/main/java/org/apache/camel/maven/packaging/MojoHelper.java @@ -117,7 +117,8 @@ public static List getComponentPath(Path dir) { dir.resolve("camel-spring-ai").resolve("camel-spring-ai-image"), dir.resolve("camel-spring-ai").resolve("camel-spring-ai-vector-store")); case "camel-test": - return Arrays.asList(dir.resolve("camel-test-junit5"), + return Arrays.asList(dir.resolve("camel-test-citrus"), + dir.resolve("camel-test-junit5"), dir.resolve("camel-test-junit6"), dir.resolve("camel-test-spring-junit5"), dir.resolve("camel-test-spring-junit6"),