From c9b514d6b9f6d637bdc560f576220dec8a2cabac Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Tue, 6 Oct 2026 21:42:56 +0200 Subject: [PATCH 01/17] CAMEL-25378: Add camel-test-citrus component for Citrus message validation Introduces a new camel-test-citrus component that integrates with the Citrus integration test framework for message validation in Camel route tests. Validate operation: - Template-based message validation using Citrus validation modules (JSON, XML, YAML, plain text, binary, Hamcrest) - Fluent API: allMessages() for uniform validation, message(index) for per-exchange sequential validation with automatic fallback - Support for Citrus ignore expressions, validation matchers, and test variable resolution via exchange properties - Path validation (JsonPath/XPath) through Citrus validation contexts - Auto-detection of message type from body content Receive operation (request/response simulation): - Gherkin-style fluent API: when().message(n).receive() for expected request validation, then().message(n).respond() for simulated response definition - Response bodies support Citrus test variables and functions - Per-exchange and allMessages() fallback for both request validation and response specs - send() alias for respond() - Response-only mode (no request validation) Message payload builders: - body(MessagePayloadBuilder) overload on both validation and response specs, accepting Citrus payload builders (GroovyScriptPayloadBuilder, marshalling builders, custom implementations) and lambdas Co-Authored-By: Claude Opus 4.6 --- bom/camel-bom/pom.xml | 5 + catalog/camel-allcomponents/pom.xml | 5 + .../camel/catalog/components.properties | 1 + .../camel/catalog/components/citrus.json | 36 ++ .../org/apache/camel/catalog/docs.properties | 1 + .../camel/catalog/docs/citrus-component.adoc | 378 ++++++++++++++++ .../camel-test/camel-test-citrus/pom.xml | 119 +++++ .../citrus/CitrusComponentConfigurer.java | 57 +++ .../citrus/CitrusEndpointConfigurer.java | 57 +++ .../citrus/CitrusEndpointUriFactory.java | 81 ++++ .../apache/camel/component/citrus/citrus.json | 36 ++ .../org/apache/camel/component.properties | 7 + .../org/apache/camel/component/citrus | 2 + .../apache/camel/configurer/citrus-component | 2 + .../apache/camel/configurer/citrus-endpoint | 2 + .../apache/camel/urifactory/citrus-endpoint | 2 + .../src/main/docs/citrus-component.adoc | 378 ++++++++++++++++ .../component/citrus/CitrusComponent.java | 47 ++ .../component/citrus/CitrusEndpoint.java | 405 ++++++++++++++++++ .../component/citrus/CitrusProducer.java | 221 ++++++++++ .../citrus/CitrusMultipleEndpointsTest.java | 82 ++++ .../component/citrus/CitrusReceiveTest.java | 240 +++++++++++ .../CitrusSequentialValidationTest.java | 140 ++++++ .../citrus/CitrusValidateJsonTest.java | 175 ++++++++ .../citrus/CitrusValidatePlainTextTest.java | 72 ++++ .../citrus/CitrusValidateXmlTest.java | 96 +++++ .../citrus/CitrusValidateYamlTest.java | 108 +++++ .../src/test/resources/log4j2-test.properties | 28 ++ components/camel-test/pom.xml | 1 + .../apache/camel/main/components.properties | 1 + .../org/apache/camel/util/ArtifactUtils.java | 1 + .../modules/ROOT/examples/json/citrus.json | 1 + docs/components/modules/ROOT/nav.adoc | 1 + .../modules/ROOT/pages/citrus-component.adoc | 1 + .../endpoint/EndpointBuilderFactory.java | 1 + .../builder/endpoint/EndpointBuilders.java | 1 + .../endpoint/StaticEndpointBuilders.java | 52 +++ .../dsl/CitrusEndpointBuilderFactory.java | 185 ++++++++ ...el-component-known-dependencies.properties | 1 + parent/pom.xml | 5 + .../camel/maven/packaging/MojoHelper.java | 3 +- 41 files changed, 3036 insertions(+), 1 deletion(-) create mode 100644 catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/citrus.json create mode 100644 catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/citrus-component.adoc create mode 100644 components/camel-test/camel-test-citrus/pom.xml create mode 100644 components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusComponentConfigurer.java create mode 100644 components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusEndpointConfigurer.java create mode 100644 components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusEndpointUriFactory.java create mode 100644 components/camel-test/camel-test-citrus/src/generated/resources/META-INF/org/apache/camel/component/citrus/citrus.json create mode 100644 components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/component.properties create mode 100644 components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/component/citrus create mode 100644 components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/configurer/citrus-component create mode 100644 components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/configurer/citrus-endpoint create mode 100644 components/camel-test/camel-test-citrus/src/generated/resources/META-INF/services/org/apache/camel/urifactory/citrus-endpoint create mode 100644 components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc create mode 100644 components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusComponent.java create mode 100644 components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusEndpoint.java create mode 100644 components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusProducer.java create mode 100644 components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusMultipleEndpointsTest.java create mode 100644 components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusReceiveTest.java create mode 100644 components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusSequentialValidationTest.java create mode 100644 components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateJsonTest.java create mode 100644 components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidatePlainTextTest.java create mode 100644 components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateXmlTest.java create mode 100644 components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateYamlTest.java create mode 100644 components/camel-test/camel-test-citrus/src/test/resources/log4j2-test.properties create mode 120000 docs/components/modules/ROOT/examples/json/citrus.json create mode 120000 docs/components/modules/ROOT/pages/citrus-component.adoc create mode 100644 dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/CitrusEndpointBuilderFactory.java diff --git a/bom/camel-bom/pom.xml b/bom/camel-bom/pom.xml index bd3e0a1aaea82..369a1040e16d3 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.23.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..72f7bf6464916 --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/citrus.json @@ -0,0 +1,36 @@ +{ + "component": { + "kind": "component", + "name": "citrus", + "title": "Citrus", + "description": "Citrus message validation component for testing", + "deprecated": false, + "firstVersion": "4.23.0", + "label": "testing", + "javaType": "org.apache.camel.component.citrus.CitrusComponent", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-test-citrus", + "version": "4.23.0-SNAPSHOT", + "scheme": "citrus", + "extendsScheme": "", + "syntax": "citrus:operation\/name", + "async": false, + "api": false, + "consumerOnly": false, + "producerOnly": true, + "lenientProperties": false, + "browsable": false, + "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." }, + "autowiredEnabled": { "index": 1, "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." } + }, + "properties": { + "operation": { "index": 0, "kind": "path", "displayName": "Operation", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "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." }, + "messageType": { "index": 2, "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." }, + "lazyStartProducer": { "index": 3, "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." } + } +} 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..2a385b38d6bec --- /dev/null +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/citrus-component.adoc @@ -0,0 +1,378 @@ += Citrus Component +:doctitle: Citrus +:shortname: citrus +:artifactid: camel-test-citrus +:description: Citrus message validation component for testing +:since: 4.23 +: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. + +== 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). +* `receive` — validates exchanges and returns a simulated response (request/response simulation). + +// 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. + +=== 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 `allMessages()` 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.allMessages() + .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 `message()` to specify different validation expectations for the 1st, 2nd, 3rd, etc. +exchange. Each call to `message()` 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.message().body("{\"event\": \"created\"}").header("action", "create"); +citrus.message().body("{\"event\": \"updated\"}").header("action", "update"); +citrus.message().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 `message(index)` to target a specific exchange by its 0-based position. + +When both `message(index)` and `allMessages()` are configured, the indexed spec takes priority — +it replaces `allMessages()` for that exchange entirely (the two are never combined). The +`allMessages()` 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 `allMessages()` fallback, +the endpoint fails with an error. + +[source,java] +---- +citrus.message(0).body("{\"event\": \"start\"}"); +citrus.allMessages().body("{\"event\": \"progress\"}").header("type", "status"); + +// Exchange 0 → validated against message(0) only (allMessages() is NOT applied) +// Exchange 1, 2, ... → validated against allMessages() (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().allMessages().receive() + .body("{\"action\": \"greet\"}"); +citrus.then().allMessages().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()` and `then()` methods provide a natural-language-like syntax: + +* `when().message(index).receive()` — define validation expectations for a specific exchange +* `when().allMessages().receive()` — define default validation for all exchanges +* `then().message(index).respond()` — define the response for a specific exchange +* `then().allMessages().respond()` — define the default response for all exchanges +* `then().message(index).send()` — alias for `respond()` + +Both the `receive()` spec and the `respond()` 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().message(0).receive().body("{\"action\": \"create\"}"); +citrus.then().message(0).respond().body("{\"id\": \"1\", \"status\": \"created\"}"); + +citrus.when().message(1).receive().body("{\"action\": \"update\"}"); +citrus.then().message(1).respond().body("{\"id\": \"1\", \"status\": \"updated\"}"); + +citrus.expectedMessageCount(2); +---- + +Indexed specs take priority over `allMessages()` for both request validation and response +simulation. When a specific exchange has no indexed spec, the `allMessages()` spec is used as +a fallback. + +==== Response with headers + +[source,java] +---- +citrus.then().allMessages().respond() + .body("{\"greeting\": \"Hello!\"}") + .header("Content-Type", "application/json") + .header("X-Status", "ok"); +---- + +==== Citrus variables in responses + +Exchange properties are available as Citrus test variables in response templates. This enables +dynamic response generation based on the incoming request context: + +[source,java] +---- +citrus.then().allMessages().respond() + .body("{\"greeting\": \"Hello ${userName}!\"}") + .header("X-Correlation-Id", "${correlationId}"); +---- + +==== Response-only mode + +When you only need response simulation without request validation, omit the `when()` specification: + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:receive/responder", CitrusEndpoint.class); + +citrus.then().allMessages().respond() + .body("OK") + .header("status", "200"); +citrus.expectedMessageCount(1); +---- + +=== Exchange properties as Citrus variables + +All exchange properties are automatically propagated to the Citrus test context as test variables. +This enables the use of Citrus functions and validation matchers in expected message templates. + +=== Path validation + +Path validation expressions are automatically classified: + +* Expressions starting with `$` are treated as JsonPath +* Expressions starting with `/` are treated as XPath 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..7ce080a3ff849 --- /dev/null +++ b/components/camel-test/camel-test-citrus/pom.xml @@ -0,0 +1,119 @@ + + + + 4.0.0 + + + org.apache.camel + camel-test-parent + 4.23.0-SNAPSHOT + + + camel-test-citrus + jar + + Camel :: Test :: Citrus + Citrus message validation component for testing + + + 4.23.0 + + Test Citrus + + + + + org.apache.camel + camel-support + + + + + org.citrusframework + citrus-api + ${citrus-version} + + + org.citrusframework + citrus-base + ${citrus-version} + + + + + org.apache.camel + camel-test-junit5 + test + + + org.apache.camel + camel-direct + test + + + org.apache.camel + camel-mock + 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.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + ${assertj-version} + 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..4042100f8627a --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusComponentConfigurer.java @@ -0,0 +1,57 @@ +/* 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.support.component.PropertyConfigurerSupport; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.EndpointSchemaGeneratorMojo") +@SuppressWarnings("unchecked") +public class CitrusComponentConfigurer extends PropertyConfigurerSupport implements GeneratedPropertyConfigurer, PropertyConfigurerGetter { + + @Override + public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { + CitrusComponent target = (CitrusComponent) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "autowiredenabled": + case "autowiredEnabled": target.setAutowiredEnabled(property(camelContext, boolean.class, value)); return true; + case "lazystartproducer": + case "lazyStartProducer": target.setLazyStartProducer(property(camelContext, boolean.class, value)); return true; + default: return false; + } + } + + @Override + public Class getOptionType(String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "autowiredenabled": + case "autowiredEnabled": return boolean.class; + case "lazystartproducer": + case "lazyStartProducer": return boolean.class; + default: return null; + } + } + + @Override + public Object getOptionValue(Object obj, String name, boolean ignoreCase) { + CitrusComponent target = (CitrusComponent) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "autowiredenabled": + case "autowiredEnabled": return target.isAutowiredEnabled(); + case "lazystartproducer": + case "lazyStartProducer": return target.isLazyStartProducer(); + default: return null; + } + } +} + 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..28fa0eae32820 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusEndpointConfigurer.java @@ -0,0 +1,57 @@ +/* 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.support.component.PropertyConfigurerSupport; + +/** + * Generated by camel build tools - do NOT edit this file! + */ +@Generated("org.apache.camel.maven.packaging.EndpointSchemaGeneratorMojo") +@SuppressWarnings("unchecked") +public class CitrusEndpointConfigurer extends PropertyConfigurerSupport 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 "lazystartproducer": + case "lazyStartProducer": target.setLazyStartProducer(property(camelContext, boolean.class, value)); return true; + case "messagetype": + case "messageType": target.setMessageType(property(camelContext, java.lang.String.class, value)); return true; + default: return false; + } + } + + @Override + public Class getOptionType(String name, boolean ignoreCase) { + switch (ignoreCase ? name.toLowerCase() : name) { + case "lazystartproducer": + case "lazyStartProducer": return boolean.class; + case "messagetype": + case "messageType": return java.lang.String.class; + default: return null; + } + } + + @Override + public Object getOptionValue(Object obj, String name, boolean ignoreCase) { + CitrusEndpoint target = (CitrusEndpoint) obj; + switch (ignoreCase ? name.toLowerCase() : name) { + case "lazystartproducer": + case "lazyStartProducer": return target.isLazyStartProducer(); + case "messagetype": + case "messageType": return target.getMessageType(); + default: return null; + } + } +} + 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..7255e03cdc6e4 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/generated/java/org/apache/camel/component/citrus/CitrusEndpointUriFactory.java @@ -0,0 +1,81 @@ +/* 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<>(4); + props.add("lazyStartProducer"); + props.add("messageType"); + props.add("name"); + props.add("operation"); + 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..72f7bf6464916 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/generated/resources/META-INF/org/apache/camel/component/citrus/citrus.json @@ -0,0 +1,36 @@ +{ + "component": { + "kind": "component", + "name": "citrus", + "title": "Citrus", + "description": "Citrus message validation component for testing", + "deprecated": false, + "firstVersion": "4.23.0", + "label": "testing", + "javaType": "org.apache.camel.component.citrus.CitrusComponent", + "supportLevel": "Preview", + "groupId": "org.apache.camel", + "artifactId": "camel-test-citrus", + "version": "4.23.0-SNAPSHOT", + "scheme": "citrus", + "extendsScheme": "", + "syntax": "citrus:operation\/name", + "async": false, + "api": false, + "consumerOnly": false, + "producerOnly": true, + "lenientProperties": false, + "browsable": false, + "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." }, + "autowiredEnabled": { "index": 1, "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." } + }, + "properties": { + "operation": { "index": 0, "kind": "path", "displayName": "Operation", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "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." }, + "messageType": { "index": 2, "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." }, + "lazyStartProducer": { "index": 3, "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." } + } +} 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..2ecb2a503903d --- /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.23.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..2a385b38d6bec --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc @@ -0,0 +1,378 @@ += Citrus Component +:doctitle: Citrus +:shortname: citrus +:artifactid: camel-test-citrus +:description: Citrus message validation component for testing +:since: 4.23 +: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. + +== 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). +* `receive` — validates exchanges and returns a simulated response (request/response simulation). + +// 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. + +=== 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 `allMessages()` 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.allMessages() + .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 `message()` to specify different validation expectations for the 1st, 2nd, 3rd, etc. +exchange. Each call to `message()` 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.message().body("{\"event\": \"created\"}").header("action", "create"); +citrus.message().body("{\"event\": \"updated\"}").header("action", "update"); +citrus.message().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 `message(index)` to target a specific exchange by its 0-based position. + +When both `message(index)` and `allMessages()` are configured, the indexed spec takes priority — +it replaces `allMessages()` for that exchange entirely (the two are never combined). The +`allMessages()` 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 `allMessages()` fallback, +the endpoint fails with an error. + +[source,java] +---- +citrus.message(0).body("{\"event\": \"start\"}"); +citrus.allMessages().body("{\"event\": \"progress\"}").header("type", "status"); + +// Exchange 0 → validated against message(0) only (allMessages() is NOT applied) +// Exchange 1, 2, ... → validated against allMessages() (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().allMessages().receive() + .body("{\"action\": \"greet\"}"); +citrus.then().allMessages().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()` and `then()` methods provide a natural-language-like syntax: + +* `when().message(index).receive()` — define validation expectations for a specific exchange +* `when().allMessages().receive()` — define default validation for all exchanges +* `then().message(index).respond()` — define the response for a specific exchange +* `then().allMessages().respond()` — define the default response for all exchanges +* `then().message(index).send()` — alias for `respond()` + +Both the `receive()` spec and the `respond()` 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().message(0).receive().body("{\"action\": \"create\"}"); +citrus.then().message(0).respond().body("{\"id\": \"1\", \"status\": \"created\"}"); + +citrus.when().message(1).receive().body("{\"action\": \"update\"}"); +citrus.then().message(1).respond().body("{\"id\": \"1\", \"status\": \"updated\"}"); + +citrus.expectedMessageCount(2); +---- + +Indexed specs take priority over `allMessages()` for both request validation and response +simulation. When a specific exchange has no indexed spec, the `allMessages()` spec is used as +a fallback. + +==== Response with headers + +[source,java] +---- +citrus.then().allMessages().respond() + .body("{\"greeting\": \"Hello!\"}") + .header("Content-Type", "application/json") + .header("X-Status", "ok"); +---- + +==== Citrus variables in responses + +Exchange properties are available as Citrus test variables in response templates. This enables +dynamic response generation based on the incoming request context: + +[source,java] +---- +citrus.then().allMessages().respond() + .body("{\"greeting\": \"Hello ${userName}!\"}") + .header("X-Correlation-Id", "${correlationId}"); +---- + +==== Response-only mode + +When you only need response simulation without request validation, omit the `when()` specification: + +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:receive/responder", CitrusEndpoint.class); + +citrus.then().allMessages().respond() + .body("OK") + .header("status", "200"); +citrus.expectedMessageCount(1); +---- + +=== Exchange properties as Citrus variables + +All exchange properties are automatically propagated to the Citrus test context as test variables. +This enables the use of Citrus functions and validation matchers in expected message templates. + +=== Path validation + +Path validation expressions are automatically classified: + +* Expressions starting with `$` are treated as JsonPath +* Expressions starting with `/` are treated as XPath 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..46930c00acada --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusComponent.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.citrus; + +import java.util.Map; + +import org.apache.camel.Endpoint; +import org.apache.camel.spi.annotations.Component; +import org.apache.camel.support.DefaultComponent; + +@Component("citrus") +public class CitrusComponent extends DefaultComponent { + + @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; + } + + 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..db8361e85780b --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusEndpoint.java @@ -0,0 +1,405 @@ +/* + * 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 java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.camel.Category; +import org.apache.camel.Consumer; +import org.apache.camel.Exchange; +import org.apache.camel.Processor; +import org.apache.camel.Producer; +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.apache.camel.support.DefaultEndpoint; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.message.MessagePayloadBuilder; +import org.citrusframework.validation.context.ValidationContext; + +@UriEndpoint(firstVersion = "4.23.0", scheme = "citrus", title = "Citrus", syntax = "citrus:operation/name", + producerOnly = true, remote = false, category = { Category.TESTING }) +public class CitrusEndpoint extends DefaultEndpoint { + + private final AtomicInteger exchangeIndex = new AtomicInteger(); + private final AtomicInteger receivedCount = new AtomicInteger(); + private final List receivedExchanges = new CopyOnWriteArrayList<>(); + private final List failures = new CopyOnWriteArrayList<>(); + + private volatile CountDownLatch latch; + + @UriPath(description = "The operation to perform. 'validate' validates exchanges without modifying them." + + " 'receive' validates exchanges and returns a simulated response.") + @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 MessageValidationSpec allMessagesSpec; + private final Map indexedSpecs = new LinkedHashMap<>(); + private int nextSpecIndex; + + private MessageResponseSpec allMessagesResponseSpec; + private final Map indexedResponseSpecs = new LinkedHashMap<>(); + private int nextResponseSpecIndex; + + private int expectedCount = -1; + + public CitrusEndpoint(String endpointUri, CitrusComponent component) { + super(endpointUri, component); + } + + @Override + public Consumer createConsumer(Processor processor) throws Exception { + throw new UnsupportedOperationException("You cannot consume from this endpoint"); + } + + @Override + public Producer createProducer() throws Exception { + return new CitrusProducer(this); + } + + // --- Fluent validation specification API (for 'validate' operation) --- + + public MessageValidationSpec allMessages() { + if (allMessagesSpec == null) { + allMessagesSpec = new MessageValidationSpec(); + } + return allMessagesSpec; + } + + public MessageValidationSpec message() { + return message(nextSpecIndex++); + } + + public MessageValidationSpec message(int index) { + return indexedSpecs.computeIfAbsent(index, k -> new MessageValidationSpec()); + } + + // --- Gherkin-style API (for 'receive' operation) --- + + public WhenSpec when() { + return new WhenSpec(); + } + + public ThenSpec then() { + return new ThenSpec(); + } + + public CitrusEndpoint expectedMessageCount(int count) { + this.expectedCount = count; + this.latch = new CountDownLatch(count); + return this; + } + + // --- Spec resolution --- + + int nextExchangeIndex() { + return exchangeIndex.getAndIncrement(); + } + + MessageValidationSpec resolveValidationSpec(int index) { + MessageValidationSpec indexed = indexedSpecs.get(index); + if (indexed != null) { + return indexed; + } + if (allMessagesSpec != null) { + return allMessagesSpec; + } + if (!indexedSpecs.isEmpty()) { + throw new IllegalStateException( + "No validation specification for exchange at index " + index + + " on endpoint: " + getEndpointUri() + + ". Validation specs are configured for indexes " + indexedSpecs.keySet() + + ". Use allMessages() to provide a default" + + " or message(" + index + ") for this specific exchange."); + } + return null; + } + + MessageResponseSpec resolveResponseSpec(int index) { + MessageResponseSpec indexed = indexedResponseSpecs.get(index); + if (indexed != null) { + return indexed; + } + return allMessagesResponseSpec; + } + + // --- Assertion API --- + + public void assertIsSatisfied() throws InterruptedException { + assertIsSatisfied(10, TimeUnit.SECONDS); + } + + public void assertIsSatisfied(long timeout, TimeUnit unit) throws InterruptedException { + if (latch != null && !latch.await(timeout, unit)) { + throw new ValidationException( + "Timed out waiting for %d messages on endpoint: %s" + .formatted(expectedCount, getEndpointUri())); + } + + if (expectedCount >= 0 && receivedCount.get() != expectedCount) { + throw new ValidationException( + "Expected %d messages but received %d on endpoint: %s" + .formatted(expectedCount, receivedCount.get(), getEndpointUri())); + } + + if (!failures.isEmpty()) { + ValidationException validationException = new ValidationException( + "Citrus validation failed on endpoint: %s. Has %d failure(s)." + .formatted(getEndpointUri(), failures.size())); + failures.forEach(validationException::addSuppressed); + throw validationException; + } + } + + void onExchangeReceived(Exchange exchange) { + receivedCount.incrementAndGet(); + receivedExchanges.add(exchange); + if (latch != null) { + latch.countDown(); + } + } + + void addFailure(Throwable failure) { + failures.add(failure); + } + + public void reset() { + exchangeIndex.set(0); + receivedCount.set(0); + receivedExchanges.clear(); + failures.clear(); + allMessagesSpec = null; + indexedSpecs.clear(); + nextSpecIndex = 0; + allMessagesResponseSpec = null; + indexedResponseSpecs.clear(); + nextResponseSpecIndex = 0; + expectedCount = -1; + latch = null; + } + + // --- Getters and setters --- + + public String getOperation() { + return operation; + } + + public void setOperation(String operation) { + this.operation = operation; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getMessageType() { + return messageType; + } + + public void setMessageType(String messageType) { + this.messageType = messageType; + } + + public int getReceivedCount() { + return receivedCount.get(); + } + + public List getReceivedExchanges() { + return new ArrayList<>(receivedExchanges); + } + + // --- Gherkin-style inner classes --- + + public class WhenSpec { + + public WhenMessageSelector message() { + return message(nextSpecIndex++); + } + + public WhenMessageSelector message(int index) { + return new WhenMessageSelector(index, false); + } + + public WhenMessageSelector allMessages() { + return new WhenMessageSelector(-1, true); + } + } + + public class WhenMessageSelector { + + private final int index; + private final boolean all; + + WhenMessageSelector(int index, boolean all) { + this.index = index; + this.all = all; + } + + public MessageValidationSpec receive() { + if (all) { + return CitrusEndpoint.this.allMessages(); + } + return CitrusEndpoint.this.message(index); + } + } + + public class ThenSpec { + + public ThenMessageSelector message() { + return message(nextResponseSpecIndex++); + } + + public ThenMessageSelector message(int index) { + return new ThenMessageSelector(index, false); + } + + public ThenMessageSelector allMessages() { + return new ThenMessageSelector(-1, true); + } + } + + public class ThenMessageSelector { + + private final int index; + private final boolean all; + + ThenMessageSelector(int index, boolean all) { + this.index = index; + this.all = all; + } + + public MessageResponseSpec respond() { + if (all) { + if (allMessagesResponseSpec == null) { + allMessagesResponseSpec = new MessageResponseSpec(); + } + return allMessagesResponseSpec; + } + return indexedResponseSpecs.computeIfAbsent(index, k -> new MessageResponseSpec()); + } + + public MessageResponseSpec send() { + return respond(); + } + } + + // --- Validation spec for expected messages --- + + public static 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; + } + } + + // --- Response spec for simulated responses --- + + public static 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/CitrusProducer.java b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusProducer.java new file mode 100644 index 0000000000000..407767faebfa7 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusProducer.java @@ -0,0 +1,221 @@ +/* + * 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.apache.camel.Exchange; +import org.apache.camel.component.citrus.CitrusEndpoint.MessageResponseSpec; +import org.apache.camel.component.citrus.CitrusEndpoint.MessageValidationSpec; +import org.apache.camel.support.DefaultProducer; +import org.citrusframework.CitrusContext; +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; + +public class CitrusProducer extends DefaultProducer { + + private static final Logger LOG = LoggerFactory.getLogger(CitrusProducer.class); + + private volatile CitrusContext citrusContext; + + public CitrusProducer(CitrusEndpoint endpoint) { + super(endpoint); + } + + @Override + public CitrusEndpoint getEndpoint() { + return (CitrusEndpoint) super.getEndpoint(); + } + + @Override + protected void doStart() throws Exception { + super.doStart(); + citrusContext = CitrusContext.create(); + } + + @Override + public void process(Exchange exchange) throws Exception { + CitrusEndpoint endpoint = getEndpoint(); + + try { + int index = endpoint.nextExchangeIndex(); + MessageValidationSpec spec = endpoint.resolveValidationSpec(index); + TestContext testContext = citrusContext.createTestContext(); + propagateExchangeProperties(exchange, testContext); + + if (spec != null) { + DefaultMessage receivedMessage = buildReceivedMessage(exchange); + DefaultMessage controlMessage = buildControlMessage(spec, testContext); + + String messageType = resolveMessageType(endpoint, spec); + controlMessage.setType(messageType); + + List validationContexts = buildValidationContexts(spec); + + performValidation(receivedMessage, controlMessage, testContext, validationContexts, messageType); + } + + if ("receive".equals(endpoint.getOperation())) { + MessageResponseSpec responseSpec = endpoint.resolveResponseSpec(index); + if (responseSpec != null) { + applyResponse(exchange, responseSpec, testContext); + } + } + } catch (Exception e) { + endpoint.addFailure(e); + throw e; + } finally { + endpoint.onExchangeReceived(exchange); + } + } + + private void propagateExchangeProperties(Exchange exchange, TestContext testContext) { + Map properties = exchange.getProperties(); + for (Map.Entry entry : properties.entrySet()) { + if (entry.getValue() != null) { + testContext.setVariable(entry.getKey(), entry.getValue()); + } + } + } + + private DefaultMessage buildReceivedMessage(Exchange exchange) { + Object body = exchange.getIn().getBody(); + Map headers = new LinkedHashMap<>(exchange.getIn().getHeaders()); + return new DefaultMessage(body, headers); + } + + private DefaultMessage buildControlMessage(MessageValidationSpec spec, TestContext testContext) { + DefaultMessage controlMessage; + Object resolvedBody = resolveBody(spec.getBody(), spec.getBodyBuilder(), testContext); + + if (resolvedBody != null) { + controlMessage = new DefaultMessage(resolvedBody); + } else { + controlMessage = new DefaultMessage(); + } + + Map expectedHeaders = spec.getHeaders(); + for (Map.Entry entry : expectedHeaders.entrySet()) { + Object value = entry.getValue(); + if (value instanceof String s) { + value = testContext.replaceDynamicContentInString(s); + } + controlMessage.setHeader(entry.getKey(), value); + } + + return controlMessage; + } + + private String resolveMessageType(CitrusEndpoint endpoint, MessageValidationSpec spec) { + String type = endpoint.getMessageType(); + if (type != null) { + if (MessageType.knows(type)) { + return MessageType.valueOf(type.toUpperCase()).name(); + } else { + return type.toUpperCase(); + } + } + + 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()); + } + + for (ValidationContext.Builder builder : spec.getValidationContextBuilders()) { + contexts.add(builder.build()); + } + + return contexts; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void performValidation( + DefaultMessage receivedMessage, DefaultMessage controlMessage, + TestContext testContext, List validationContexts, + String messageType) { + + MessageValidatorRegistry registry = testContext.getMessageValidatorRegistry(); + List> validators + = registry.findMessageValidators(messageType, receivedMessage); + + if (validators.isEmpty()) { + throw new IllegalStateException( + "No Citrus message validator found for message type '" + messageType + + "'. 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(), messageType); + + 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; + } +} 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..46e4d762d932b --- /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.junit5.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.allMessages().body(""" + { + "orderId": "${orderId}", + "status": "created" + } + """); + orderCheck.expectedMessageCount(1); + + CitrusEndpoint responseCheck + = context.getEndpoint("citrus:validate/responseCheck", CitrusEndpoint.class); + responseCheck.allMessages().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.getReceivedCount()).isEqualTo(1); + assertThat(responseCheck.getReceivedCount()).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/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..101950cedcefe --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusReceiveTest.java @@ -0,0 +1,240 @@ +/* + * 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.junit5.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().allMessages().receive() + .body("{\"action\": \"greet\"}"); + mock.then().allMessages().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().allMessages().receive() + .body("{\"action\": \"greet\"}"); + mock.then().allMessages().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().allMessages().receive() + .body("{\"action\": \"greet\", \"name\": \"@ignore@\"}"); + mock.then().allMessages().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().allMessages().receive() + .body("{\"action\": \"check\"}"); + mock.then().allMessages().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().message(0).receive().body("{\"action\": \"create\"}"); + mock.then().message(0).respond().body("{\"id\": \"1\", \"status\": \"created\"}"); + + mock.when().message(1).receive().body("{\"action\": \"update\"}"); + mock.then().message(1).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().message(0).receive().body("{\"action\": \"first\"}"); + mock.then().message(0).respond().body("{\"result\": \"special\"}"); + + mock.when().allMessages().receive().body("{\"action\": \"@ignore@\"}"); + mock.then().allMessages().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().allMessages().receive().body("{\"ping\": true}"); + mock.then().allMessages().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.then().allMessages().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().allMessages().receive() + .body("{\"action\": \"greet\"}"); + mock.then().allMessages().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().allMessages().receive() + .body("{\"action\": \"greet\"}"); + mock.then().allMessages().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..07e27a9d7d35b --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusSequentialValidationTest.java @@ -0,0 +1,140 @@ +/* + * 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.CamelExecutionException; +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit5.CamelTestSupport; +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.message().body(""" + {"event": "created"} + """); + citrus.message().body(""" + {"event": "updated"} + """); + citrus.message().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.getReceivedCount()).isEqualTo(3); + } + + @Test + void shouldValidateSequentialExchangesWithHeaders() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.message() + .body("{\"status\": \"pending\"}") + .header("action", "create"); + citrus.message() + .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 shouldFailWhenExchangeExceedsIndexedSpecs() { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.message(0).body("{\"event\": \"first\"}"); + + template.sendBody("direct:start", "{\"event\": \"first\"}"); + + assertThatThrownBy(() -> template.sendBody("direct:start", "{\"event\": \"second\"}")) + .isInstanceOf(CamelExecutionException.class) + .cause() + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No validation specification for exchange at index 1"); + } + + @Test + void shouldFallBackToAllMessagesSpec() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.message(0).body("{\"event\": \"first\"}"); + citrus.allMessages().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.getReceivedCount()).isEqualTo(3); + } + + @Test + void shouldValidateAllMessagesWithSameSpec() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); + + citrus.allMessages() + .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.message(0).body("{\"event\": \"created\"}"); + citrus.message(1).body("{\"event\": \"updated\"}"); + + template.sendBody("direct:start", "{\"event\": \"created\"}"); + + assertThatThrownBy(() -> template.sendBody("direct:start", "{\"event\": \"wrong\"}")) + .isInstanceOf(CamelExecutionException.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..def2607d68aa0 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateJsonTest.java @@ -0,0 +1,175 @@ +/* + * 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.junit5.CamelTestSupport; +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.allMessages().body(""" + { + "greeting": "Hello World!" + } + """); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", """ + { + "greeting": "Hello World!" + } + """); + + citrus.assertIsSatisfied(); + assertThat(citrus.getReceivedCount()).isEqualTo(1); + } + + @Test + void shouldFailOnJsonBodyMismatch() { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); + citrus.allMessages().body(""" + { + "greeting": "Hello World!" + } + """); + + assertThatThrownBy(() -> template.sendBody("direct:start", """ + { + "greeting": "Goodbye!" + } + """)) + .isInstanceOf(org.apache.camel.CamelExecutionException.class); + } + + @Test + void shouldValidateJsonPathExpressions() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); + citrus.allMessages().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.allMessages().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.allMessages() + .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.allMessages().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.allMessages().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..a603f4ce6e15f --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidatePlainTextTest.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 org.apache.camel.CamelExecutionException; +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit5.CamelTestSupport; +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.allMessages().body("Hello Camel!"); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", "Hello Camel!"); + + citrus.assertIsSatisfied(); + assertThat(citrus.getReceivedCount()).isEqualTo(1); + } + + @Test + void shouldFailOnPlainTextMismatch() { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=plaintext", CitrusEndpoint.class); + citrus.allMessages().body("Hello Camel!"); + + assertThatThrownBy(() -> template.sendBody("direct:start", "Goodbye Camel!")) + .isInstanceOf(CamelExecutionException.class); + } + + @Test + void shouldValidateDefaultMessageType() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate", CitrusEndpoint.class); + citrus.allMessages().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..9fff75cc03581 --- /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.junit5.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.allMessages().body(""" + + Hello World! + + """); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", """ + + Hello World! + + """); + + citrus.assertIsSatisfied(); + assertThat(citrus.getReceivedCount()).isEqualTo(1); + } + + @Test + void shouldValidateXpathExpressions() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=xml", CitrusEndpoint.class); + citrus.allMessages().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.allMessages().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..da4906f337ffd --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusValidateYamlTest.java @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.citrus; + +import org.apache.camel.CamelExecutionException; +import org.apache.camel.RoutesBuilder; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit5.CamelTestSupport; +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.allMessages().body(""" + greeting: Hello World! + """); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", """ + greeting: Hello World! + """); + + citrus.assertIsSatisfied(); + assertThat(citrus.getReceivedCount()).isEqualTo(1); + } + + @Test + void shouldAutoDetectYamlMessageType() throws Exception { + CitrusEndpoint citrus = context.getEndpoint("citrus:validate", CitrusEndpoint.class); + citrus.allMessages().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.allMessages().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.allMessages().body(""" + greeting: Hello World! + """); + + assertThatThrownBy(() -> template.sendBody("direct:start", """ + greeting: Goodbye! + """)) + .isInstanceOf(CamelExecutionException.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/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-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..e56480791066d 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,58 @@ 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.23 + * 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. + * + * 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.23 + * 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. + * + * 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..1cff0c52ed044 --- /dev/null +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/CitrusEndpointBuilderFactory.java @@ -0,0 +1,185 @@ +/* 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; + } + + /** + * 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; + } + + /** + * 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; + } + } + + public interface CitrusBuilders { + /** + * Citrus (camel-test-citrus) + * Citrus message validation component for testing + * + * Category: testing + * Since: 4.23 + * 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. + * + * 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.23 + * 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. + * + * 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"), From 8ab7c186add87b01f6b1f4401e2c8dfe1283758d Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 13 Oct 2026 21:35:46 +0000 Subject: [PATCH 02/17] Regen --- .../component/ComponentsBuilderFactory.java | 13 ++ .../dsl/CitrusComponentBuilderFactory.java | 120 ++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/CitrusComponentBuilderFactory.java 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..076cb3160a828 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.23 + * 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..2fb446f5cf366 --- /dev/null +++ b/dsl/camel-componentdsl/src/generated/java/org/apache/camel/builder/component/dsl/CitrusComponentBuilderFactory.java @@ -0,0 +1,120 @@ +/* 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.23 + * 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; + } + + + /** + * 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; + } + } + + 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 "autowiredEnabled": ((CitrusComponent) component).setAutowiredEnabled((boolean) value); return true; + default: return false; + } + } + } +} \ No newline at end of file From dabf27d2fd434d3722a7c369da1ddf213746d1b3 Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Wed, 14 Oct 2026 20:04:34 +0200 Subject: [PATCH 03/17] CAMEL-25378: Address review feedback on camel-test-citrus - Validate operation errors: record failures instead of throwing, reported via assertIsSatisfied() (MockEndpoint pattern) - Receive operation: still throws on validation failure - Add operation validation in CitrusComponent (reject invalid ops) - Add volatile to shared mutable fields for thread safety - Reset counters in expectedMessageCount() - Auto-derive expected count from indexed specs - Add CitrusContext lifecycle management (doStop) - Propagate exchange variables (both directions) between Camel/Citrus - Use Locale.ROOT for case conversions - Switch test dependency to camel-test-junit6 - Update firstVersion to 4.24.0 - Add operation enum constraint - Improve documentation: Maven dependency snippet, path validation API usage, missing spec error behavior, exchange variables - Regenerate catalog and endpoint DSL artifacts Co-Authored-By: Claude Opus 4.6 --- .../camel/catalog/components/citrus.json | 4 +- .../camel/catalog/docs/citrus-component.adoc | 47 +++++++++++++++---- .../camel-test/camel-test-citrus/pom.xml | 10 +--- .../apache/camel/component/citrus/citrus.json | 4 +- .../src/main/docs/citrus-component.adoc | 47 +++++++++++++++---- .../component/citrus/CitrusComponent.java | 4 ++ .../component/citrus/CitrusEndpoint.java | 28 +++++++---- .../component/citrus/CitrusProducer.java | 46 ++++++++++++++---- .../citrus/CitrusMultipleEndpointsTest.java | 2 +- .../component/citrus/CitrusReceiveTest.java | 2 +- .../CitrusSequentialValidationTest.java | 22 +++++---- .../citrus/CitrusValidateJsonTest.java | 12 +++-- .../citrus/CitrusValidatePlainTextTest.java | 11 +++-- .../citrus/CitrusValidateXmlTest.java | 2 +- .../citrus/CitrusValidateYamlTest.java | 13 +++-- .../endpoint/StaticEndpointBuilders.java | 6 ++- .../dsl/CitrusEndpointBuilderFactory.java | 6 ++- 17 files changed, 189 insertions(+), 77 deletions(-) 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 index 72f7bf6464916..ced95b3a80fb2 100644 --- 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 @@ -5,7 +5,7 @@ "title": "Citrus", "description": "Citrus message validation component for testing", "deprecated": false, - "firstVersion": "4.23.0", + "firstVersion": "4.24.0", "label": "testing", "javaType": "org.apache.camel.component.citrus.CitrusComponent", "supportLevel": "Preview", @@ -28,7 +28,7 @@ "autowiredEnabled": { "index": 1, "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." } }, "properties": { - "operation": { "index": 0, "kind": "path", "displayName": "Operation", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "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." }, + "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." }, "messageType": { "index": 2, "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." }, "lazyStartProducer": { "index": 3, "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." } 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 index 2a385b38d6bec..280ee52e74a4c 100644 --- 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 @@ -3,7 +3,7 @@ :shortname: citrus :artifactid: camel-test-citrus :description: Citrus message validation component for testing -:since: 4.23 +:since: 4.24 :supportlevel: Preview :tabs-sync-option: :component-header: Only producer is supported @@ -19,6 +19,17 @@ 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 ---- @@ -257,7 +268,9 @@ it replaces `allMessages()` for that exchange entirely (the two are never combin `allMessages()` 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 `allMessages()` fallback, -the endpoint fails with an error. +the endpoint records a failure. For the `validate` operation, the failure is collected and reported +when `assertIsSatisfied()` is called (similar to MockEndpoint). For the `receive` operation, the +failure is thrown immediately as an exception. [source,java] ---- @@ -365,14 +378,32 @@ citrus.then().allMessages().respond() citrus.expectedMessageCount(1); ---- -=== Exchange properties as Citrus variables +=== Exchange properties and variables as Citrus variables -All exchange properties are automatically propagated to the Citrus test context as test variables. -This enables the use of Citrus functions and validation matchers in expected message templates. +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 (`${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 -Path validation expressions are automatically classified: +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: -* Expressions starting with `$` are treated as JsonPath -* Expressions starting with `/` are treated as XPath +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); +citrus.allMessages().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.allMessages().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 index 7ce080a3ff849..58ca14a9c68b5 100644 --- a/components/camel-test/camel-test-citrus/pom.xml +++ b/components/camel-test/camel-test-citrus/pom.xml @@ -33,7 +33,7 @@ Citrus message validation component for testing - 4.23.0 + 4.24.0 Test Citrus @@ -59,7 +59,7 @@ org.apache.camel - camel-test-junit5 + camel-test-junit6 test @@ -99,15 +99,9 @@ test - - org.junit.jupiter - junit-jupiter - test - org.assertj assertj-core - ${assertj-version} test 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 index 72f7bf6464916..ced95b3a80fb2 100644 --- 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 @@ -5,7 +5,7 @@ "title": "Citrus", "description": "Citrus message validation component for testing", "deprecated": false, - "firstVersion": "4.23.0", + "firstVersion": "4.24.0", "label": "testing", "javaType": "org.apache.camel.component.citrus.CitrusComponent", "supportLevel": "Preview", @@ -28,7 +28,7 @@ "autowiredEnabled": { "index": 1, "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." } }, "properties": { - "operation": { "index": 0, "kind": "path", "displayName": "Operation", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "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." }, + "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." }, "messageType": { "index": 2, "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." }, "lazyStartProducer": { "index": 3, "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." } 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 index 2a385b38d6bec..280ee52e74a4c 100644 --- 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 @@ -3,7 +3,7 @@ :shortname: citrus :artifactid: camel-test-citrus :description: Citrus message validation component for testing -:since: 4.23 +:since: 4.24 :supportlevel: Preview :tabs-sync-option: :component-header: Only producer is supported @@ -19,6 +19,17 @@ 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 ---- @@ -257,7 +268,9 @@ it replaces `allMessages()` for that exchange entirely (the two are never combin `allMessages()` 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 `allMessages()` fallback, -the endpoint fails with an error. +the endpoint records a failure. For the `validate` operation, the failure is collected and reported +when `assertIsSatisfied()` is called (similar to MockEndpoint). For the `receive` operation, the +failure is thrown immediately as an exception. [source,java] ---- @@ -365,14 +378,32 @@ citrus.then().allMessages().respond() citrus.expectedMessageCount(1); ---- -=== Exchange properties as Citrus variables +=== Exchange properties and variables as Citrus variables -All exchange properties are automatically propagated to the Citrus test context as test variables. -This enables the use of Citrus functions and validation matchers in expected message templates. +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 (`${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 -Path validation expressions are automatically classified: +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: -* Expressions starting with `$` are treated as JsonPath -* Expressions starting with `/` are treated as XPath +[source,java] +---- +CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); +citrus.allMessages().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.allMessages().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 index 46930c00acada..57e3d47e10756 100644 --- 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 @@ -39,6 +39,10 @@ protected Endpoint createEndpoint(String uri, String remaining, Map indexedSpecs = new LinkedHashMap<>(); - private int nextSpecIndex; + private volatile int nextSpecIndex; - private MessageResponseSpec allMessagesResponseSpec; + private volatile MessageResponseSpec allMessagesResponseSpec; private final Map indexedResponseSpecs = new LinkedHashMap<>(); - private int nextResponseSpecIndex; + private volatile int nextResponseSpecIndex; - private int expectedCount = -1; + private volatile int expectedCount = -1; public CitrusEndpoint(String endpointUri, CitrusComponent component) { super(endpointUri, component); @@ -116,6 +117,8 @@ public ThenSpec then() { public CitrusEndpoint expectedMessageCount(int count) { this.expectedCount = count; + this.receivedCount.set(0); + this.receivedExchanges.clear(); this.latch = new CountDownLatch(count); return this; } @@ -160,16 +163,21 @@ public void assertIsSatisfied() throws InterruptedException { } public void assertIsSatisfied(long timeout, TimeUnit unit) throws InterruptedException { + int effectiveExpected = expectedCount; + if (effectiveExpected < 0 && !indexedSpecs.isEmpty()) { + effectiveExpected = indexedSpecs.size(); + } + if (latch != null && !latch.await(timeout, unit)) { throw new ValidationException( "Timed out waiting for %d messages on endpoint: %s" - .formatted(expectedCount, getEndpointUri())); + .formatted(effectiveExpected, getEndpointUri())); } - if (expectedCount >= 0 && receivedCount.get() != expectedCount) { + if (effectiveExpected >= 0 && receivedCount.get() != effectiveExpected) { throw new ValidationException( "Expected %d messages but received %d on endpoint: %s" - .formatted(expectedCount, receivedCount.get(), getEndpointUri())); + .formatted(effectiveExpected, receivedCount.get(), getEndpointUri())); } if (!failures.isEmpty()) { diff --git a/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusProducer.java b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusProducer.java index 407767faebfa7..c565db25cba7a 100644 --- a/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusProducer.java +++ b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusProducer.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import org.apache.camel.Exchange; @@ -60,16 +61,25 @@ protected void doStart() throws Exception { citrusContext = CitrusContext.create(); } + @Override + protected void doStop() throws Exception { + super.doStop(); + if (citrusContext != null) { + citrusContext.close(); + citrusContext = null; + } + } + @Override public void process(Exchange exchange) throws Exception { CitrusEndpoint endpoint = getEndpoint(); + int index = endpoint.nextExchangeIndex(); + TestContext testContext = citrusContext.createTestContext(); + propagateExchangeVariables(exchange, testContext); + try { - int index = endpoint.nextExchangeIndex(); MessageValidationSpec spec = endpoint.resolveValidationSpec(index); - TestContext testContext = citrusContext.createTestContext(); - propagateExchangeProperties(exchange, testContext); - if (spec != null) { DefaultMessage receivedMessage = buildReceivedMessage(exchange); DefaultMessage controlMessage = buildControlMessage(spec, testContext); @@ -90,19 +100,35 @@ public void process(Exchange exchange) throws Exception { } } catch (Exception e) { endpoint.addFailure(e); - throw e; + if ("receive".equals(endpoint.getOperation())) { + throw e; + } } finally { + writeBackExtractedVariables(exchange, testContext); endpoint.onExchangeReceived(exchange); } } - private void propagateExchangeProperties(Exchange exchange, TestContext testContext) { - Map properties = exchange.getProperties(); - for (Map.Entry entry : properties.entrySet()) { + 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) { + for (Map.Entry entry : testContext.getVariables().entrySet()) { + String key = entry.getKey(); + if (!exchange.getProperties().containsKey(key) && !exchange.getVariables().containsKey(key)) { + exchange.setVariable(key, entry.getValue()); + } + } } private DefaultMessage buildReceivedMessage(Exchange exchange) { @@ -137,9 +163,9 @@ private String resolveMessageType(CitrusEndpoint endpoint, MessageValidationSpec String type = endpoint.getMessageType(); if (type != null) { if (MessageType.knows(type)) { - return MessageType.valueOf(type.toUpperCase()).name(); + return MessageType.valueOf(type.toUpperCase(Locale.ROOT)).name(); } else { - return type.toUpperCase(); + return type.toUpperCase(Locale.ROOT); } } 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 index 46e4d762d932b..dbac357254afd 100644 --- 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 @@ -18,7 +18,7 @@ import org.apache.camel.RoutesBuilder; import org.apache.camel.builder.RouteBuilder; -import org.apache.camel.test.junit5.CamelTestSupport; +import org.apache.camel.test.junit6.CamelTestSupport; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; 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 index 101950cedcefe..3c0e9a3b2f68f 100644 --- 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 @@ -19,7 +19,7 @@ import org.apache.camel.Exchange; import org.apache.camel.RoutesBuilder; import org.apache.camel.builder.RouteBuilder; -import org.apache.camel.test.junit5.CamelTestSupport; +import org.apache.camel.test.junit6.CamelTestSupport; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; 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 index 07e27a9d7d35b..1ce218d5c3c1b 100644 --- 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 @@ -16,10 +16,10 @@ */ package org.apache.camel.component.citrus; -import org.apache.camel.CamelExecutionException; import org.apache.camel.RoutesBuilder; import org.apache.camel.builder.RouteBuilder; -import org.apache.camel.test.junit5.CamelTestSupport; +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; @@ -73,14 +73,16 @@ void shouldFailWhenExchangeExceedsIndexedSpecs() { CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); citrus.message(0).body("{\"event\": \"first\"}"); + citrus.expectedMessageCount(2); template.sendBody("direct:start", "{\"event\": \"first\"}"); + template.sendBody("direct:start", "{\"event\": \"second\"}"); - assertThatThrownBy(() -> template.sendBody("direct:start", "{\"event\": \"second\"}")) - .isInstanceOf(CamelExecutionException.class) - .cause() - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("No validation specification for exchange at index 1"); + assertThatThrownBy(citrus::assertIsSatisfied) + .isInstanceOf(ValidationException.class) + .satisfies(e -> assertThat(e.getSuppressed()[0]) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No validation specification for exchange at index 1")); } @Test @@ -121,11 +123,13 @@ void shouldFailSequentialValidationOnMismatch() { citrus.message(0).body("{\"event\": \"created\"}"); citrus.message(1).body("{\"event\": \"updated\"}"); + citrus.expectedMessageCount(2); template.sendBody("direct:start", "{\"event\": \"created\"}"); + template.sendBody("direct:start", "{\"event\": \"wrong\"}"); - assertThatThrownBy(() -> template.sendBody("direct:start", "{\"event\": \"wrong\"}")) - .isInstanceOf(CamelExecutionException.class); + assertThatThrownBy(citrus::assertIsSatisfied) + .isInstanceOf(ValidationException.class); } @Override 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 index def2607d68aa0..30241c3e5e81e 100644 --- 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 @@ -18,7 +18,8 @@ import org.apache.camel.RoutesBuilder; import org.apache.camel.builder.RouteBuilder; -import org.apache.camel.test.junit5.CamelTestSupport; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.citrusframework.exceptions.ValidationException; import org.citrusframework.validation.DefaultValidations; import org.junit.jupiter.api.Test; @@ -55,13 +56,16 @@ void shouldFailOnJsonBodyMismatch() { "greeting": "Hello World!" } """); + citrus.expectedMessageCount(1); - assertThatThrownBy(() -> template.sendBody("direct:start", """ + template.sendBody("direct:start", """ { "greeting": "Goodbye!" } - """)) - .isInstanceOf(org.apache.camel.CamelExecutionException.class); + """); + + assertThatThrownBy(citrus::assertIsSatisfied) + .isInstanceOf(ValidationException.class); } @Test 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 index a603f4ce6e15f..f030a5061a854 100644 --- 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 @@ -16,10 +16,10 @@ */ package org.apache.camel.component.citrus; -import org.apache.camel.CamelExecutionException; import org.apache.camel.RoutesBuilder; import org.apache.camel.builder.RouteBuilder; -import org.apache.camel.test.junit5.CamelTestSupport; +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; @@ -43,9 +43,12 @@ void shouldValidatePlainTextBody() throws Exception { void shouldFailOnPlainTextMismatch() { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=plaintext", CitrusEndpoint.class); citrus.allMessages().body("Hello Camel!"); + citrus.expectedMessageCount(1); + + template.sendBody("direct:start", "Goodbye Camel!"); - assertThatThrownBy(() -> template.sendBody("direct:start", "Goodbye Camel!")) - .isInstanceOf(CamelExecutionException.class); + assertThatThrownBy(citrus::assertIsSatisfied) + .isInstanceOf(ValidationException.class); } @Test 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 index 9fff75cc03581..4b734fc3b65a5 100644 --- 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 @@ -18,7 +18,7 @@ import org.apache.camel.RoutesBuilder; import org.apache.camel.builder.RouteBuilder; -import org.apache.camel.test.junit5.CamelTestSupport; +import org.apache.camel.test.junit6.CamelTestSupport; import org.citrusframework.validation.DefaultValidations; import org.junit.jupiter.api.Test; 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 index da4906f337ffd..58680624f7cec 100644 --- 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 @@ -16,10 +16,10 @@ */ package org.apache.camel.component.citrus; -import org.apache.camel.CamelExecutionException; import org.apache.camel.RoutesBuilder; import org.apache.camel.builder.RouteBuilder; -import org.apache.camel.test.junit5.CamelTestSupport; +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; @@ -88,11 +88,14 @@ void shouldFailOnYamlBodyMismatch() { citrus.allMessages().body(""" greeting: Hello World! """); + citrus.expectedMessageCount(1); - assertThatThrownBy(() -> template.sendBody("direct:start", """ + template.sendBody("direct:start", """ greeting: Goodbye! - """)) - .isInstanceOf(CamelExecutionException.class); + """); + + assertThatThrownBy(citrus::assertIsSatisfied) + .isInstanceOf(ValidationException.class); } @Override 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 e56480791066d..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 @@ -3379,7 +3379,7 @@ public static ChunkEndpointBuilderFactory.ChunkEndpointBuilder chunk(String comp * Citrus message validation component for testing * * Category: testing - * Since: 4.23 + * Since: 4.24 * Maven coordinates: org.apache.camel:camel-test-citrus * * Syntax: citrus:operation/name @@ -3388,6 +3388,7 @@ public static ChunkEndpointBuilderFactory.ChunkEndpointBuilder chunk(String comp * 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 @@ -3404,7 +3405,7 @@ public static CitrusEndpointBuilderFactory.CitrusEndpointBuilder citrus(String p * Citrus message validation component for testing * * Category: testing - * Since: 4.23 + * Since: 4.24 * Maven coordinates: org.apache.camel:camel-test-citrus * * Syntax: citrus:operation/name @@ -3413,6 +3414,7 @@ public static CitrusEndpointBuilderFactory.CitrusEndpointBuilder citrus(String p * 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 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 index 1cff0c52ed044..8deac9e3c7a79 100644 --- 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 @@ -125,7 +125,7 @@ public interface CitrusBuilders { * Citrus message validation component for testing * * Category: testing - * Since: 4.23 + * Since: 4.24 * Maven coordinates: org.apache.camel:camel-test-citrus * * Syntax: citrus:operation/name @@ -134,6 +134,7 @@ public interface CitrusBuilders { * 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 @@ -150,7 +151,7 @@ default CitrusEndpointBuilder citrus(String path) { * Citrus message validation component for testing * * Category: testing - * Since: 4.23 + * Since: 4.24 * Maven coordinates: org.apache.camel:camel-test-citrus * * Syntax: citrus:operation/name @@ -159,6 +160,7 @@ default CitrusEndpointBuilder citrus(String path) { * 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 From 391c583782256d05ac6c3dd74c25a7869d559adf Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Wed, 14 Oct 2026 19:56:16 +0000 Subject: [PATCH 04/17] Regen --- .../camel/builder/component/ComponentsBuilderFactory.java | 2 +- .../builder/component/dsl/CitrusComponentBuilderFactory.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 076cb3160a828..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 @@ -1065,7 +1065,7 @@ static ChunkComponentBuilderFactory.ChunkComponentBuilder chunk() { * Citrus message validation component for testing * * Category: testing - * Since: 4.23 + * Since: 4.24 * Maven coordinates: org.apache.camel:camel-test-citrus * * @return the dsl builder 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 index 2fb446f5cf366..17a81cd162322 100644 --- 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 @@ -36,7 +36,7 @@ public interface CitrusComponentBuilderFactory { * Citrus message validation component for testing * * Category: testing - * Since: 4.23 + * Since: 4.24 * Maven coordinates: org.apache.camel:camel-test-citrus * * @return the dsl builder From a05ba95b72ebe6e259839c991acee971d90fdef1 Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Wed, 14 Oct 2026 22:30:17 +0200 Subject: [PATCH 05/17] CAMEL-25378: Use MockEndpoint processor infrastructure for Citrus validation Leverage whenExchangeReceived() and whenAnyExchangeReceived() from MockEndpoint to dispatch Citrus validation and response logic via CitrusValidationProcessor. This replaces the previous custom spec resolution and indexing approach. Benefits: - Eliminates custom spec resolution, indexing and state tracking - Reuses MockEndpoint's built-in processor dispatch (indexed and default) for both validation and response handling - Simplifies the Gherkin-style API with a fluent when().receive().body().then().respond().body() chain - Failure reporting uses performAssertions() to capture processor exceptions from the exchange and add them to the failures list; for validate mode the exception is cleared so it does not propagate through the route Co-Authored-By: Claude Opus 4.6 --- .../camel/catalog/components/citrus.json | 24 +- .../camel/catalog/docs/citrus-component.adoc | 36 +- .../camel-test/camel-test-citrus/pom.xml | 10 +- .../citrus/CitrusComponentConfigurer.java | 39 +- .../citrus/CitrusEndpointConfigurer.java | 16 +- .../citrus/CitrusEndpointUriFactory.java | 16 +- .../apache/camel/component/citrus/citrus.json | 24 +- .../src/main/docs/citrus-component.adoc | 36 +- .../component/citrus/CitrusComponent.java | 4 +- .../component/citrus/CitrusEndpoint.java | 370 ++++---------- ...er.java => CitrusValidationProcessor.java} | 154 +++--- .../component/citrus/MessageResponseSpec.java | 59 +++ .../citrus/MessageValidationSpec.java | 72 +++ .../citrus/CitrusMultipleEndpointsTest.java | 8 +- .../component/citrus/CitrusReceiveTest.java | 59 ++- .../CitrusSequentialValidationTest.java | 38 +- .../citrus/CitrusValidateJsonTest.java | 19 +- .../citrus/CitrusValidatePlainTextTest.java | 11 +- .../citrus/CitrusValidateXmlTest.java | 8 +- .../citrus/CitrusValidateYamlTest.java | 13 +- .../endpoint/StaticEndpointBuilders.java | 10 +- .../dsl/CitrusEndpointBuilderFactory.java | 466 +++++++++++++++++- 22 files changed, 930 insertions(+), 562 deletions(-) rename components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/{CitrusProducer.java => CitrusValidationProcessor.java} (72%) create mode 100644 components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/MessageResponseSpec.java create mode 100644 components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/MessageValidationSpec.java 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 index ced95b3a80fb2..76941d15c7f33 100644 --- 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 @@ -20,17 +20,31 @@ "consumerOnly": false, "producerOnly": true, "lenientProperties": false, - "browsable": 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." }, - "autowiredEnabled": { "index": 1, "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." } + "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." }, - "messageType": { "index": 2, "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." }, - "lazyStartProducer": { "index": 3, "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." } + "name": { "index": 1, "kind": "path", "displayName": "Name", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Name of mock endpoint" }, + "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/citrus-component.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/citrus-component.adoc index 280ee52e74a4c..2d914f66c330f 100644 --- 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 @@ -219,14 +219,14 @@ exchange in sequence. ==== Single validation for all exchanges -Use `allMessages()` to define one validation specification that is applied to every exchange. +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.allMessages() +citrus.validateAll() .body("{\"status\": \"ok\"}") .header("operation", "test"); citrus.expectedMessageCount(3); @@ -241,17 +241,17 @@ citrus.assertIsSatisfied(); ==== Per-exchange sequential validation -Use `message()` to specify different validation expectations for the 1st, 2nd, 3rd, etc. -exchange. Each call to `message()` auto-increments the index, so specs are applied in order. +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.message().body("{\"event\": \"created\"}").header("action", "create"); -citrus.message().body("{\"event\": \"updated\"}").header("action", "update"); -citrus.message().body("{\"event\": \"deleted\"}").header("action", "delete"); +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"); @@ -261,24 +261,24 @@ template.sendBodyAndHeader("direct:start", "{\"event\": \"deleted\"}", "action", citrus.assertIsSatisfied(); ---- -You can also use `message(index)` to target a specific exchange by its 0-based position. +You can also use `validate(index)` to target a specific exchange by its 0-based position. -When both `message(index)` and `allMessages()` are configured, the indexed spec takes priority — -it replaces `allMessages()` for that exchange entirely (the two are never combined). The -`allMessages()` spec acts as a fallback for exchanges that have no matching indexed spec. +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 `allMessages()` fallback, +If an exchange arrives at an index with no matching indexed spec and no `validateAll()` fallback, the endpoint records a failure. For the `validate` operation, the failure is collected and reported when `assertIsSatisfied()` is called (similar to MockEndpoint). For the `receive` operation, the failure is thrown immediately as an exception. [source,java] ---- -citrus.message(0).body("{\"event\": \"start\"}"); -citrus.allMessages().body("{\"event\": \"progress\"}").header("type", "status"); +citrus.validate(0).body("{\"event\": \"start\"}"); +citrus.validateAll().body("{\"event\": \"progress\"}").header("type", "status"); -// Exchange 0 → validated against message(0) only (allMessages() is NOT applied) -// Exchange 1, 2, ... → validated against allMessages() (body + header) +// Exchange 0 → validated against validate(0) only (validateAll() is NOT applied) +// Exchange 1, 2, ... → validated against validateAll() (body + header) ---- === Receive operation (request/response simulation) @@ -395,7 +395,7 @@ such as JsonPath or XPath expressions. These are provided by the Citrus `Default [source,java] ---- CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); -citrus.allMessages().validate(new DefaultValidations().jsonPath() +citrus.validateAll().validate(new DefaultValidations().jsonPath() .expression("$.user.name", "John") .expression("$.user.age", "30")); ---- @@ -403,7 +403,7 @@ citrus.allMessages().validate(new DefaultValidations().jsonPath() [source,java] ---- CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=xml", CitrusEndpoint.class); -citrus.allMessages().validate(new DefaultValidations().xpath() +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 index 58ca14a9c68b5..278ed31394869 100644 --- a/components/camel-test/camel-test-citrus/pom.xml +++ b/components/camel-test/camel-test-citrus/pom.xml @@ -43,6 +43,10 @@ org.apache.camel camel-support + + org.apache.camel + camel-mock + @@ -67,12 +71,6 @@ camel-direct test - - org.apache.camel - camel-mock - test - - org.citrusframework 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 index 4042100f8627a..74bcd2cc166f0 100644 --- 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 @@ -10,48 +10,13 @@ import org.apache.camel.spi.ConfigurerStrategy; import org.apache.camel.spi.GeneratedPropertyConfigurer; import org.apache.camel.util.CaseInsensitiveMap; -import org.apache.camel.support.component.PropertyConfigurerSupport; +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 PropertyConfigurerSupport implements GeneratedPropertyConfigurer, PropertyConfigurerGetter { - - @Override - public boolean configure(CamelContext camelContext, Object obj, String name, Object value, boolean ignoreCase) { - CitrusComponent target = (CitrusComponent) obj; - switch (ignoreCase ? name.toLowerCase() : name) { - case "autowiredenabled": - case "autowiredEnabled": target.setAutowiredEnabled(property(camelContext, boolean.class, value)); return true; - case "lazystartproducer": - case "lazyStartProducer": target.setLazyStartProducer(property(camelContext, boolean.class, value)); return true; - default: return false; - } - } - - @Override - public Class getOptionType(String name, boolean ignoreCase) { - switch (ignoreCase ? name.toLowerCase() : name) { - case "autowiredenabled": - case "autowiredEnabled": return boolean.class; - case "lazystartproducer": - case "lazyStartProducer": return boolean.class; - default: return null; - } - } - - @Override - public Object getOptionValue(Object obj, String name, boolean ignoreCase) { - CitrusComponent target = (CitrusComponent) obj; - switch (ignoreCase ? name.toLowerCase() : name) { - case "autowiredenabled": - case "autowiredEnabled": return target.isAutowiredEnabled(); - case "lazystartproducer": - case "lazyStartProducer": return target.isLazyStartProducer(); - default: return null; - } - } +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 index 28fa0eae32820..bdaf11cc6a385 100644 --- 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 @@ -10,35 +10,31 @@ import org.apache.camel.spi.ConfigurerStrategy; import org.apache.camel.spi.GeneratedPropertyConfigurer; import org.apache.camel.util.CaseInsensitiveMap; -import org.apache.camel.support.component.PropertyConfigurerSupport; +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 PropertyConfigurerSupport implements GeneratedPropertyConfigurer, PropertyConfigurerGetter { +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 "lazystartproducer": - case "lazyStartProducer": target.setLazyStartProducer(property(camelContext, boolean.class, value)); return true; case "messagetype": case "messageType": target.setMessageType(property(camelContext, java.lang.String.class, value)); return true; - default: return false; + default: return super.configure(camelContext, obj, name, value, ignoreCase); } } @Override public Class getOptionType(String name, boolean ignoreCase) { switch (ignoreCase ? name.toLowerCase() : name) { - case "lazystartproducer": - case "lazyStartProducer": return boolean.class; case "messagetype": case "messageType": return java.lang.String.class; - default: return null; + default: return super.getOptionType(name, ignoreCase); } } @@ -46,11 +42,9 @@ public Class getOptionType(String name, boolean ignoreCase) { public Object getOptionValue(Object obj, String name, boolean ignoreCase) { CitrusEndpoint target = (CitrusEndpoint) obj; switch (ignoreCase ? name.toLowerCase() : name) { - case "lazystartproducer": - case "lazyStartProducer": return target.isLazyStartProducer(); case "messagetype": case "messageType": return target.getMessageType(); - default: return null; + 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 index 7255e03cdc6e4..64ff55b2ec76f 100644 --- 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 @@ -24,11 +24,23 @@ public class CitrusEndpointUriFactory extends org.apache.camel.support.component private static final Set ENDPOINT_IDENTITY_PROPERTY_NAMES; private static final Map MULTI_VALUE_PREFIXES; static { - Set props = new HashSet<>(4); + 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(); @@ -48,7 +60,7 @@ public String buildUri(String scheme, Map properties, boolean en Map copy = copyParameters(properties); uri = buildPathParameter(syntax, uri, "operation", null, true, copy); - uri = buildPathParameter(syntax, uri, "name", null, false, copy); + uri = buildPathParameter(syntax, uri, "name", null, true, copy); uri = buildQueryParameters(uri, copy, encode); return uri; } 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 index ced95b3a80fb2..76941d15c7f33 100644 --- 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 @@ -20,17 +20,31 @@ "consumerOnly": false, "producerOnly": true, "lenientProperties": false, - "browsable": 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." }, - "autowiredEnabled": { "index": 1, "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." } + "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." }, - "messageType": { "index": 2, "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." }, - "lazyStartProducer": { "index": 3, "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." } + "name": { "index": 1, "kind": "path", "displayName": "Name", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Name of mock endpoint" }, + "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/main/docs/citrus-component.adoc b/components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc index 280ee52e74a4c..2d914f66c330f 100644 --- 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 @@ -219,14 +219,14 @@ exchange in sequence. ==== Single validation for all exchanges -Use `allMessages()` to define one validation specification that is applied to every exchange. +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.allMessages() +citrus.validateAll() .body("{\"status\": \"ok\"}") .header("operation", "test"); citrus.expectedMessageCount(3); @@ -241,17 +241,17 @@ citrus.assertIsSatisfied(); ==== Per-exchange sequential validation -Use `message()` to specify different validation expectations for the 1st, 2nd, 3rd, etc. -exchange. Each call to `message()` auto-increments the index, so specs are applied in order. +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.message().body("{\"event\": \"created\"}").header("action", "create"); -citrus.message().body("{\"event\": \"updated\"}").header("action", "update"); -citrus.message().body("{\"event\": \"deleted\"}").header("action", "delete"); +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"); @@ -261,24 +261,24 @@ template.sendBodyAndHeader("direct:start", "{\"event\": \"deleted\"}", "action", citrus.assertIsSatisfied(); ---- -You can also use `message(index)` to target a specific exchange by its 0-based position. +You can also use `validate(index)` to target a specific exchange by its 0-based position. -When both `message(index)` and `allMessages()` are configured, the indexed spec takes priority — -it replaces `allMessages()` for that exchange entirely (the two are never combined). The -`allMessages()` spec acts as a fallback for exchanges that have no matching indexed spec. +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 `allMessages()` fallback, +If an exchange arrives at an index with no matching indexed spec and no `validateAll()` fallback, the endpoint records a failure. For the `validate` operation, the failure is collected and reported when `assertIsSatisfied()` is called (similar to MockEndpoint). For the `receive` operation, the failure is thrown immediately as an exception. [source,java] ---- -citrus.message(0).body("{\"event\": \"start\"}"); -citrus.allMessages().body("{\"event\": \"progress\"}").header("type", "status"); +citrus.validate(0).body("{\"event\": \"start\"}"); +citrus.validateAll().body("{\"event\": \"progress\"}").header("type", "status"); -// Exchange 0 → validated against message(0) only (allMessages() is NOT applied) -// Exchange 1, 2, ... → validated against allMessages() (body + header) +// Exchange 0 → validated against validate(0) only (validateAll() is NOT applied) +// Exchange 1, 2, ... → validated against validateAll() (body + header) ---- === Receive operation (request/response simulation) @@ -395,7 +395,7 @@ such as JsonPath or XPath expressions. These are provided by the Citrus `Default [source,java] ---- CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); -citrus.allMessages().validate(new DefaultValidations().jsonPath() +citrus.validateAll().validate(new DefaultValidations().jsonPath() .expression("$.user.name", "John") .expression("$.user.age", "30")); ---- @@ -403,7 +403,7 @@ citrus.allMessages().validate(new DefaultValidations().jsonPath() [source,java] ---- CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=xml", CitrusEndpoint.class); -citrus.allMessages().validate(new DefaultValidations().xpath() +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 index 57e3d47e10756..f41e7e400a839 100644 --- 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 @@ -19,11 +19,11 @@ import java.util.Map; import org.apache.camel.Endpoint; +import org.apache.camel.component.mock.MockComponent; import org.apache.camel.spi.annotations.Component; -import org.apache.camel.support.DefaultComponent; @Component("citrus") -public class CitrusComponent extends DefaultComponent { +public class CitrusComponent extends MockComponent { @Override protected Endpoint createEndpoint(String uri, String remaining, Map parameters) throws Exception { 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 index b5d5cf59224d7..10ef50df6e328 100644 --- 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 @@ -16,39 +16,22 @@ */ package org.apache.camel.component.citrus; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.apache.camel.Category; -import org.apache.camel.Consumer; import org.apache.camel.Exchange; -import org.apache.camel.Processor; -import org.apache.camel.Producer; +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.apache.camel.support.DefaultEndpoint; -import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.CitrusContext; 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 DefaultEndpoint { - - private final AtomicInteger exchangeIndex = new AtomicInteger(); - private final AtomicInteger receivedCount = new AtomicInteger(); - private final List receivedExchanges = new CopyOnWriteArrayList<>(); - private final List failures = new CopyOnWriteArrayList<>(); - - private volatile CountDownLatch latch; +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.", @@ -56,166 +39,92 @@ public class CitrusEndpoint extends DefaultEndpoint { @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 MessageValidationSpec allMessagesSpec; - private final Map indexedSpecs = new LinkedHashMap<>(); - private volatile int nextSpecIndex; - - private volatile MessageResponseSpec allMessagesResponseSpec; - private final Map indexedResponseSpecs = new LinkedHashMap<>(); - private volatile int nextResponseSpecIndex; - - private volatile int expectedCount = -1; + private volatile CitrusContext citrusContext; + private volatile AtomicInteger nextSpecIndex = new AtomicInteger(0); public CitrusEndpoint(String endpointUri, CitrusComponent component) { super(endpointUri, component); } - @Override - public Consumer createConsumer(Processor processor) throws Exception { - throw new UnsupportedOperationException("You cannot consume from this endpoint"); - } + // --- Lifecycle --- @Override - public Producer createProducer() throws Exception { - return new CitrusProducer(this); + protected void doStart() throws Exception { + super.doStart(); + citrusContext = CitrusContext.create(); } - // --- Fluent validation specification API (for 'validate' operation) --- - - public MessageValidationSpec allMessages() { - if (allMessagesSpec == null) { - allMessagesSpec = new MessageValidationSpec(); + @Override + protected void doStop() throws Exception { + if (citrusContext != null) { + citrusContext.close(); + citrusContext = null; } - return allMessagesSpec; - } - - public MessageValidationSpec message() { - return message(nextSpecIndex++); - } - - public MessageValidationSpec message(int index) { - return indexedSpecs.computeIfAbsent(index, k -> new MessageValidationSpec()); + super.doStop(); } - // --- Gherkin-style API (for 'receive' operation) --- + // --- Fluent validation specification API --- - public WhenSpec when() { - return new WhenSpec(); + public MessageValidationSpec validateAll() { + MessageValidationSpec messageValidationSpec = new MessageValidationSpec(); + whenAnyExchangeReceived(createProcessor(messageValidationSpec)); + return messageValidationSpec; } - public ThenSpec then() { - return new ThenSpec(); + public MessageValidationSpec validate() { + return validate(nextSpecIndex.incrementAndGet()); } - public CitrusEndpoint expectedMessageCount(int count) { - this.expectedCount = count; - this.receivedCount.set(0); - this.receivedExchanges.clear(); - this.latch = new CountDownLatch(count); - return this; + public MessageValidationSpec validate(int index) { + MessageValidationSpec messageValidationSpec = new MessageValidationSpec(); + whenExchangeReceived(index, createProcessor(messageValidationSpec)); + return messageValidationSpec; } - // --- Spec resolution --- - - int nextExchangeIndex() { - return exchangeIndex.getAndIncrement(); + private CitrusValidationProcessor createProcessor(MessageValidationSpec validationSpec) { + return new CitrusValidationProcessor(citrusContext, messageType, validationSpec); } - MessageValidationSpec resolveValidationSpec(int index) { - MessageValidationSpec indexed = indexedSpecs.get(index); - if (indexed != null) { - return indexed; - } - if (allMessagesSpec != null) { - return allMessagesSpec; - } - if (!indexedSpecs.isEmpty()) { - throw new IllegalStateException( - "No validation specification for exchange at index " + index - + " on endpoint: " + getEndpointUri() - + ". Validation specs are configured for indexes " + indexedSpecs.keySet() - + ". Use allMessages() to provide a default" - + " or message(" + index + ") for this specific exchange."); - } - return null; + private CitrusValidationProcessor createProcessor( + MessageValidationSpec validationSpec, + MessageResponseSpec responseSpec) { + return new CitrusValidationProcessor(citrusContext, messageType, validationSpec, responseSpec); } - MessageResponseSpec resolveResponseSpec(int index) { - MessageResponseSpec indexed = indexedResponseSpecs.get(index); - if (indexed != null) { - return indexed; - } - return allMessagesResponseSpec; - } - - // --- Assertion API --- + // --- Gherkin-style API (for 'receive' operation) --- - public void assertIsSatisfied() throws InterruptedException { - assertIsSatisfied(10, TimeUnit.SECONDS); + public WhenSpec when() { + return new WhenSpec(); } - public void assertIsSatisfied(long timeout, TimeUnit unit) throws InterruptedException { - int effectiveExpected = expectedCount; - if (effectiveExpected < 0 && !indexedSpecs.isEmpty()) { - effectiveExpected = indexedSpecs.size(); - } - - if (latch != null && !latch.await(timeout, unit)) { - throw new ValidationException( - "Timed out waiting for %d messages on endpoint: %s" - .formatted(effectiveExpected, getEndpointUri())); - } - - if (effectiveExpected >= 0 && receivedCount.get() != effectiveExpected) { - throw new ValidationException( - "Expected %d messages but received %d on endpoint: %s" - .formatted(effectiveExpected, receivedCount.get(), getEndpointUri())); - } - - if (!failures.isEmpty()) { - ValidationException validationException = new ValidationException( - "Citrus validation failed on endpoint: %s. Has %d failure(s)." - .formatted(getEndpointUri(), failures.size())); - failures.forEach(validationException::addSuppressed); - throw validationException; - } + @Override + public void reset() { + super.reset(); + nextSpecIndex = new AtomicInteger(0); } - void onExchangeReceived(Exchange exchange) { - receivedCount.incrementAndGet(); - receivedExchanges.add(exchange); - if (latch != null) { - latch.countDown(); + @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)) { + getFailures().add(e); + if ("validate".equals(operation)) { + // For validate-only mode, clear the exception so it does not propagate + // through the route — validation errors are collected, not thrown. + exchange.setException(null); + } } } - void addFailure(Throwable failure) { - failures.add(failure); - } - - public void reset() { - exchangeIndex.set(0); - receivedCount.set(0); - receivedExchanges.clear(); - failures.clear(); - allMessagesSpec = null; - indexedSpecs.clear(); - nextSpecIndex = 0; - allMessagesResponseSpec = null; - indexedResponseSpecs.clear(); - nextResponseSpecIndex = 0; - expectedCount = -1; - latch = null; - } - // --- Getters and setters --- public String getOperation() { @@ -226,14 +135,6 @@ public void setOperation(String operation) { this.operation = operation; } - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - public String getMessageType() { return messageType; } @@ -242,82 +143,82 @@ public void setMessageType(String messageType) { this.messageType = messageType; } - public int getReceivedCount() { - return receivedCount.get(); - } - - public List getReceivedExchanges() { - return new ArrayList<>(receivedExchanges); - } - // --- Gherkin-style inner classes --- public class WhenSpec { - public WhenMessageSelector message() { - return message(nextSpecIndex++); + public WhenMessageValidationSpec receive() { + return receive(nextSpecIndex.incrementAndGet()); } - public WhenMessageSelector message(int index) { - return new WhenMessageSelector(index, false); + public WhenMessageValidationSpec receive(int index) { + WhenMessageValidationSpec messageValidationSpec = new WhenMessageValidationSpec(index); + whenExchangeReceived(index, createProcessor(messageValidationSpec)); + return messageValidationSpec; } - public WhenMessageSelector allMessages() { - return new WhenMessageSelector(-1, true); + public WhenMessageValidationSpec receiveAll() { + WhenMessageValidationSpec messageValidationSpec = new WhenMessageValidationSpec(-1); + whenAnyExchangeReceived(createProcessor(messageValidationSpec)); + return messageValidationSpec; } } - public class WhenMessageSelector { + public class WhenMessageValidationSpec extends MessageValidationSpec { private final int index; - private final boolean all; - WhenMessageSelector(int index, boolean all) { + WhenMessageValidationSpec(int index) { this.index = index; - this.all = all; } - public MessageValidationSpec receive() { - if (all) { - return CitrusEndpoint.this.allMessages(); - } - return CitrusEndpoint.this.message(index); + @Override + public WhenMessageValidationSpec body(MessagePayloadBuilder bodyBuilder) { + super.body(bodyBuilder); + return this; } - } - public class ThenSpec { + @Override + public WhenMessageValidationSpec body(String body) { + super.body(body); + return this; + } - public ThenMessageSelector message() { - return message(nextResponseSpecIndex++); + @Override + public WhenMessageValidationSpec header(String headerName, Object value) { + super.header(headerName, value); + return this; } - public ThenMessageSelector message(int index) { - return new ThenMessageSelector(index, false); + @Override + public MessageValidationSpec validate(ValidationContext.Builder... validationContexts) { + super.validate(validationContexts); + return this; } - public ThenMessageSelector allMessages() { - return new ThenMessageSelector(-1, true); + public ThenSpec then() { + return new ThenSpec(index, this); } } - public class ThenMessageSelector { + public class ThenSpec { private final int index; - private final boolean all; + private final WhenMessageValidationSpec messageValidationSpec; - ThenMessageSelector(int index, boolean all) { + public ThenSpec(int index, WhenMessageValidationSpec messageValidationSpec) { this.index = index; - this.all = all; + this.messageValidationSpec = messageValidationSpec; } public MessageResponseSpec respond() { - if (all) { - if (allMessagesResponseSpec == null) { - allMessagesResponseSpec = new MessageResponseSpec(); - } - return allMessagesResponseSpec; + MessageResponseSpec messageResponseSpec = new MessageResponseSpec(); + if (index < 0) { + whenAnyExchangeReceived(createProcessor(messageValidationSpec, messageResponseSpec)); + } else { + whenExchangeReceived(index, createProcessor(messageValidationSpec, messageResponseSpec)); } - return indexedResponseSpecs.computeIfAbsent(index, k -> new MessageResponseSpec()); + return messageResponseSpec; } public MessageResponseSpec send() { @@ -327,87 +228,6 @@ public MessageResponseSpec send() { // --- Validation spec for expected messages --- - public static 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; - } - } - // --- Response spec for simulated responses --- - public static 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/CitrusProducer.java b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusValidationProcessor.java similarity index 72% rename from components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusProducer.java rename to components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusValidationProcessor.java index c565db25cba7a..6b76f5d1966ff 100644 --- a/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusProducer.java +++ b/components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusValidationProcessor.java @@ -23,9 +23,7 @@ import java.util.Map; import org.apache.camel.Exchange; -import org.apache.camel.component.citrus.CitrusEndpoint.MessageResponseSpec; -import org.apache.camel.component.citrus.CitrusEndpoint.MessageValidationSpec; -import org.apache.camel.support.DefaultProducer; +import org.apache.camel.Processor; import org.citrusframework.CitrusContext; import org.citrusframework.context.TestContext; import org.citrusframework.message.DefaultMessage; @@ -40,96 +38,55 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class CitrusProducer extends DefaultProducer { +class CitrusValidationProcessor implements Processor { - private static final Logger LOG = LoggerFactory.getLogger(CitrusProducer.class); + private static final Logger LOG = LoggerFactory.getLogger(CitrusValidationProcessor.class); - private volatile CitrusContext citrusContext; + private final CitrusContext citrusContext; + private final String messageType; + private final MessageValidationSpec spec; + private final MessageResponseSpec responseSpec; - public CitrusProducer(CitrusEndpoint endpoint) { - super(endpoint); + CitrusValidationProcessor(CitrusContext citrusContext, String messageType, + MessageValidationSpec validationSpec) { + this(citrusContext, messageType, validationSpec, null); } - @Override - public CitrusEndpoint getEndpoint() { - return (CitrusEndpoint) super.getEndpoint(); - } - - @Override - protected void doStart() throws Exception { - super.doStart(); - citrusContext = CitrusContext.create(); - } - - @Override - protected void doStop() throws Exception { - super.doStop(); - if (citrusContext != null) { - citrusContext.close(); - citrusContext = null; - } + CitrusValidationProcessor(CitrusContext citrusContext, String messageType, + MessageValidationSpec validationSpec, + MessageResponseSpec responseSpec) { + this.citrusContext = citrusContext; + this.messageType = messageType; + this.spec = validationSpec; + this.responseSpec = responseSpec; } @Override public void process(Exchange exchange) throws Exception { - CitrusEndpoint endpoint = getEndpoint(); - - int index = endpoint.nextExchangeIndex(); TestContext testContext = citrusContext.createTestContext(); propagateExchangeVariables(exchange, testContext); try { - MessageValidationSpec spec = endpoint.resolveValidationSpec(index); if (spec != null) { DefaultMessage receivedMessage = buildReceivedMessage(exchange); DefaultMessage controlMessage = buildControlMessage(spec, testContext); - String messageType = resolveMessageType(endpoint, spec); - controlMessage.setType(messageType); + String resolvedType = resolveMessageType(spec); + controlMessage.setType(resolvedType); List validationContexts = buildValidationContexts(spec); - - performValidation(receivedMessage, controlMessage, testContext, validationContexts, messageType); + performCitrusValidation(receivedMessage, controlMessage, testContext, validationContexts, resolvedType); } - if ("receive".equals(endpoint.getOperation())) { - MessageResponseSpec responseSpec = endpoint.resolveResponseSpec(index); - if (responseSpec != null) { - applyResponse(exchange, responseSpec, testContext); - } - } - } catch (Exception e) { - endpoint.addFailure(e); - if ("receive".equals(endpoint.getOperation())) { - throw e; + if (responseSpec != null) { + applyResponse(exchange, responseSpec, testContext); } } finally { writeBackExtractedVariables(exchange, testContext); - endpoint.onExchangeReceived(exchange); } } - 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) { - for (Map.Entry entry : testContext.getVariables().entrySet()) { - String key = entry.getKey(); - if (!exchange.getProperties().containsKey(key) && !exchange.getVariables().containsKey(key)) { - exchange.setVariable(key, entry.getValue()); - } - } - } + // --- Citrus validation helpers --- private DefaultMessage buildReceivedMessage(Exchange exchange) { Object body = exchange.getIn().getBody(); @@ -138,34 +95,27 @@ private DefaultMessage buildReceivedMessage(Exchange exchange) { } private DefaultMessage buildControlMessage(MessageValidationSpec spec, TestContext testContext) { - DefaultMessage controlMessage; Object resolvedBody = resolveBody(spec.getBody(), spec.getBodyBuilder(), testContext); + DefaultMessage controlMessage = resolvedBody != null + ? new DefaultMessage(resolvedBody) + : new DefaultMessage(); - if (resolvedBody != null) { - controlMessage = new DefaultMessage(resolvedBody); - } else { - controlMessage = new DefaultMessage(); - } - - Map expectedHeaders = spec.getHeaders(); - for (Map.Entry entry : expectedHeaders.entrySet()) { + 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(CitrusEndpoint endpoint, MessageValidationSpec spec) { - String type = endpoint.getMessageType(); - if (type != null) { - if (MessageType.knows(type)) { - return MessageType.valueOf(type.toUpperCase(Locale.ROOT)).name(); + private String resolveMessageType(MessageValidationSpec spec) { + if (messageType != null) { + if (MessageType.knows(messageType)) { + return MessageType.valueOf(messageType.toUpperCase(Locale.ROOT)).name(); } else { - return type.toUpperCase(Locale.ROOT); + return messageType.toUpperCase(Locale.ROOT); } } @@ -179,46 +129,41 @@ private String resolveMessageType(CitrusEndpoint endpoint, MessageValidationSpec 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()); } - for (ValidationContext.Builder builder : spec.getValidationContextBuilders()) { contexts.add(builder.build()); } - return contexts; } @SuppressWarnings({ "unchecked", "rawtypes" }) - private void performValidation( + private void performCitrusValidation( DefaultMessage receivedMessage, DefaultMessage controlMessage, TestContext testContext, List validationContexts, - String messageType) { + String resolvedType) { MessageValidatorRegistry registry = testContext.getMessageValidatorRegistry(); List> validators - = registry.findMessageValidators(messageType, receivedMessage); + = registry.findMessageValidators(resolvedType, receivedMessage); if (validators.isEmpty()) { throw new IllegalStateException( - "No Citrus message validator found for message type '" + messageType + "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(), messageType); + validators.size(), resolvedType); for (MessageValidator validator : validators) { validator.validateMessage(receivedMessage, controlMessage, testContext, validationContexts); @@ -230,7 +175,6 @@ private void applyResponse(Exchange exchange, MessageResponseSpec responseSpec, if (resolvedBody != null) { exchange.getIn().setBody(resolvedBody); } - Map resolvedHeaders = testContext.resolveDynamicValuesInMap(responseSpec.getHeaders()); resolvedHeaders.forEach((key, value) -> exchange.getIn().setHeader(key, value)); } @@ -244,4 +188,28 @@ private Object resolveBody(String body, MessagePayloadBuilder bodyBuilder, TestC } 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) { + for (Map.Entry entry : testContext.getVariables().entrySet()) { + String key = entry.getKey(); + if (!exchange.getProperties().containsKey(key) && !exchange.getVariables().containsKey(key)) { + exchange.setVariable(key, entry.getValue()); + } + } + } } 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 index dbac357254afd..8e2524b522486 100644 --- 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 @@ -29,7 +29,7 @@ class CitrusMultipleEndpointsTest extends CamelTestSupport { void shouldValidateMultipleEndpointsInRoute() throws Exception { CitrusEndpoint orderCheck = context.getEndpoint("citrus:validate/orderCheck", CitrusEndpoint.class); - orderCheck.allMessages().body(""" + orderCheck.validateAll().body(""" { "orderId": "${orderId}", "status": "created" @@ -39,7 +39,7 @@ void shouldValidateMultipleEndpointsInRoute() throws Exception { CitrusEndpoint responseCheck = context.getEndpoint("citrus:validate/responseCheck", CitrusEndpoint.class); - responseCheck.allMessages().body(""" + responseCheck.validateAll().body(""" { "orderId": "${orderId}", "status": "processed" @@ -60,8 +60,8 @@ void shouldValidateMultipleEndpointsInRoute() throws Exception { orderCheck.assertIsSatisfied(); responseCheck.assertIsSatisfied(); - assertThat(orderCheck.getReceivedCount()).isEqualTo(1); - assertThat(responseCheck.getReceivedCount()).isEqualTo(1); + assertThat(orderCheck.getReceivedCounter()).isEqualTo(1); + assertThat(responseCheck.getReceivedCounter()).isEqualTo(1); } @Override 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 index 3c0e9a3b2f68f..a89579f7d841a 100644 --- 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 @@ -30,9 +30,9 @@ class CitrusReceiveTest extends CamelTestSupport { void shouldValidateAndRespond() throws Exception { CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); - mock.when().allMessages().receive() - .body("{\"action\": \"greet\"}"); - mock.then().allMessages().respond() + mock.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() .body("{\"greeting\": \"Hello!\"}"); mock.expectedMessageCount(1); @@ -48,9 +48,9 @@ void shouldValidateAndRespond() throws Exception { void shouldRespondWithHeaders() throws Exception { CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); - mock.when().allMessages().receive() - .body("{\"action\": \"greet\"}"); - mock.then().allMessages().respond() + mock.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() .body("{\"greeting\": \"Hello!\"}") .header("Content-Type", "application/json") .header("X-Status", "ok"); @@ -70,9 +70,9 @@ void shouldRespondWithHeaders() throws Exception { void shouldRespondWithCitrusVariables() throws Exception { CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); - mock.when().allMessages().receive() - .body("{\"action\": \"greet\", \"name\": \"@ignore@\"}"); - mock.then().allMessages().respond() + mock.when().receiveAll() + .body("{\"action\": \"greet\", \"name\": \"@ignore@\"}") + .then().respond() .body("{\"greeting\": \"Hello ${userName}!\"}"); mock.expectedMessageCount(1); @@ -89,9 +89,9 @@ void shouldRespondWithCitrusVariables() throws Exception { void shouldRespondWithVariablesInHeaders() throws Exception { CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); - mock.when().allMessages().receive() - .body("{\"action\": \"check\"}"); - mock.then().allMessages().respond() + mock.when().receiveAll() + .body("{\"action\": \"check\"}") + .then().respond() .body("{\"result\": \"ok\"}") .header("X-Correlation-Id", "${correlationId}"); mock.expectedMessageCount(1); @@ -110,11 +110,11 @@ void shouldRespondWithVariablesInHeaders() throws Exception { void shouldHandleSequentialReceiveAndRespond() throws Exception { CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); - mock.when().message(0).receive().body("{\"action\": \"create\"}"); - mock.then().message(0).respond().body("{\"id\": \"1\", \"status\": \"created\"}"); + mock.when().receive(1).body("{\"action\": \"create\"}") + .then().respond().body("{\"id\": \"1\", \"status\": \"created\"}"); - mock.when().message(1).receive().body("{\"action\": \"update\"}"); - mock.then().message(1).respond().body("{\"id\": \"1\", \"status\": \"updated\"}"); + mock.when().receive(2).body("{\"action\": \"update\"}") + .then().respond().body("{\"id\": \"1\", \"status\": \"updated\"}"); mock.expectedMessageCount(2); @@ -135,11 +135,11 @@ void shouldHandleSequentialReceiveAndRespond() throws Exception { void shouldFallBackToAllMessagesResponse() throws Exception { CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); - mock.when().message(0).receive().body("{\"action\": \"first\"}"); - mock.then().message(0).respond().body("{\"result\": \"special\"}"); + mock.when().receive(1).body("{\"action\": \"first\"}") + .then().respond().body("{\"result\": \"special\"}"); - mock.when().allMessages().receive().body("{\"action\": \"@ignore@\"}"); - mock.then().allMessages().respond().body("{\"result\": \"default\"}"); + mock.when().receiveAll().body("{\"action\": \"@ignore@\"}") + .then().respond().body("{\"result\": \"default\"}"); mock.expectedMessageCount(3); @@ -163,8 +163,8 @@ void shouldFallBackToAllMessagesResponse() throws Exception { void shouldUseSendAliasForRespond() throws Exception { CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); - mock.when().allMessages().receive().body("{\"ping\": true}"); - mock.then().allMessages().send().body("{\"pong\": true}"); + mock.when().receive().body("{\"ping\": true}") + .then().send().body("{\"pong\": true}"); mock.expectedMessageCount(1); Exchange result = template.send("direct:start", exchange -> { @@ -178,8 +178,7 @@ void shouldUseSendAliasForRespond() throws Exception { @Test void shouldRespondWithoutValidation() throws Exception { CitrusEndpoint mock = context.getEndpoint("citrus:receive/responder", CitrusEndpoint.class); - - mock.then().allMessages().respond() + mock.when().receive().then().respond() .body("OK") .header("status", "200"); mock.expectedMessageCount(1); @@ -197,9 +196,9 @@ void shouldRespondWithoutValidation() throws Exception { void shouldRespondWithPayloadBuilder() throws Exception { CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); - mock.when().allMessages().receive() - .body("{\"action\": \"greet\"}"); - mock.then().allMessages().respond() + mock.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() .body(testContext -> "{\"greeting\": \"Hello from builder!\"}"); mock.expectedMessageCount(1); @@ -215,9 +214,9 @@ void shouldRespondWithPayloadBuilder() throws Exception { void shouldFailOnValidationMismatch() { CitrusEndpoint mock = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); - mock.when().allMessages().receive() - .body("{\"action\": \"greet\"}"); - mock.then().allMessages().respond() + mock.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() .body("{\"greeting\": \"Hello!\"}"); Exchange result = template.send("direct:start", exchange -> { 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 index 1ce218d5c3c1b..09f98f7e8ae9c 100644 --- 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 @@ -31,13 +31,13 @@ class CitrusSequentialValidationTest extends CamelTestSupport { void shouldValidateSequentialExchanges() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); - citrus.message().body(""" + citrus.validate().body(""" {"event": "created"} """); - citrus.message().body(""" + citrus.validate().body(""" {"event": "updated"} """); - citrus.message().body(""" + citrus.validate().body(""" {"event": "deleted"} """); citrus.expectedMessageCount(3); @@ -47,17 +47,17 @@ void shouldValidateSequentialExchanges() throws Exception { template.sendBody("direct:start", "{\"event\": \"deleted\"}"); citrus.assertIsSatisfied(); - assertThat(citrus.getReceivedCount()).isEqualTo(3); + assertThat(citrus.getReceivedCounter()).isEqualTo(3); } @Test void shouldValidateSequentialExchangesWithHeaders() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); - citrus.message() + citrus.validate() .body("{\"status\": \"pending\"}") .header("action", "create"); - citrus.message() + citrus.validate() .body("{\"status\": \"done\"}") .header("action", "complete"); citrus.expectedMessageCount(2); @@ -69,28 +69,25 @@ void shouldValidateSequentialExchangesWithHeaders() throws Exception { } @Test - void shouldFailWhenExchangeExceedsIndexedSpecs() { + void shouldPassThroughExchangeWithoutMatchingSpec() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); - citrus.message(0).body("{\"event\": \"first\"}"); + citrus.validate(1).body("{\"event\": \"first\"}"); citrus.expectedMessageCount(2); template.sendBody("direct:start", "{\"event\": \"first\"}"); template.sendBody("direct:start", "{\"event\": \"second\"}"); - assertThatThrownBy(citrus::assertIsSatisfied) - .isInstanceOf(ValidationException.class) - .satisfies(e -> assertThat(e.getSuppressed()[0]) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("No validation specification for exchange at index 1")); + citrus.assertIsSatisfied(); + assertThat(citrus.getReceivedCounter()).isEqualTo(2); } @Test void shouldFallBackToAllMessagesSpec() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); - citrus.message(0).body("{\"event\": \"first\"}"); - citrus.allMessages().body("{\"event\": \"default\"}"); + citrus.validate(1).body("{\"event\": \"first\"}"); + citrus.validateAll().body("{\"event\": \"default\"}"); citrus.expectedMessageCount(3); template.sendBody("direct:start", "{\"event\": \"first\"}"); @@ -98,14 +95,14 @@ void shouldFallBackToAllMessagesSpec() throws Exception { template.sendBody("direct:start", "{\"event\": \"default\"}"); citrus.assertIsSatisfied(); - assertThat(citrus.getReceivedCount()).isEqualTo(3); + assertThat(citrus.getReceivedCounter()).isEqualTo(3); } @Test void shouldValidateAllMessagesWithSameSpec() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); - citrus.allMessages() + citrus.validateAll() .body("{\"status\": \"ok\"}") .header("type", "health"); citrus.expectedMessageCount(3); @@ -121,15 +118,16 @@ void shouldValidateAllMessagesWithSameSpec() throws Exception { void shouldFailSequentialValidationOnMismatch() { CitrusEndpoint citrus = context.getEndpoint("citrus:validate/test?messageType=json", CitrusEndpoint.class); - citrus.message(0).body("{\"event\": \"created\"}"); - citrus.message(1).body("{\"event\": \"updated\"}"); + 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(ValidationException.class); + .isInstanceOf(AssertionError.class) + .hasCauseInstanceOf(ValidationException.class); } @Override 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 index 30241c3e5e81e..c9e6543c694a7 100644 --- 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 @@ -31,7 +31,7 @@ class CitrusValidateJsonTest extends CamelTestSupport { @Test void shouldValidateJsonBody() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); - citrus.allMessages().body(""" + citrus.validateAll().body(""" { "greeting": "Hello World!" } @@ -45,13 +45,13 @@ void shouldValidateJsonBody() throws Exception { """); citrus.assertIsSatisfied(); - assertThat(citrus.getReceivedCount()).isEqualTo(1); + assertThat(citrus.getReceivedCounter()).isEqualTo(1); } @Test void shouldFailOnJsonBodyMismatch() { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); - citrus.allMessages().body(""" + citrus.validateAll().body(""" { "greeting": "Hello World!" } @@ -65,13 +65,14 @@ void shouldFailOnJsonBodyMismatch() { """); assertThatThrownBy(citrus::assertIsSatisfied) - .isInstanceOf(ValidationException.class); + .isInstanceOf(AssertionError.class) + .hasCauseInstanceOf(ValidationException.class); } @Test void shouldValidateJsonPathExpressions() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); - citrus.allMessages().validate(new DefaultValidations().jsonPath() + citrus.validateAll().validate(new DefaultValidations().jsonPath() .expression("$.user.name", "John") .expression("$.user.age", "30")); citrus.expectedMessageCount(1); @@ -91,7 +92,7 @@ void shouldValidateJsonPathExpressions() throws Exception { @Test void shouldAutoDetectJsonMessageType() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate", CitrusEndpoint.class); - citrus.allMessages().body(""" + citrus.validateAll().body(""" { "greeting": "Hello World!" } @@ -110,7 +111,7 @@ void shouldAutoDetectJsonMessageType() throws Exception { @Test void shouldValidateHeaders() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); - citrus.allMessages() + citrus.validateAll() .body(""" { "result": "ok" @@ -128,7 +129,7 @@ void shouldValidateHeaders() throws Exception { @Test void shouldPropagateExchangePropertiesAsVariables() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); - citrus.allMessages().body(""" + citrus.validateAll().body(""" { "message": "Hello ${userName}!" } @@ -150,7 +151,7 @@ void shouldPropagateExchangePropertiesAsVariables() throws Exception { @Test void shouldValidateWithPayloadBuilder() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=json", CitrusEndpoint.class); - citrus.allMessages().body(testContext -> """ + citrus.validateAll().body(testContext -> """ { "greeting": "Hello World!" } 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 index f030a5061a854..04818d849ccf2 100644 --- 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 @@ -30,31 +30,32 @@ class CitrusValidatePlainTextTest extends CamelTestSupport { @Test void shouldValidatePlainTextBody() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=plaintext", CitrusEndpoint.class); - citrus.allMessages().body("Hello Camel!"); + citrus.validateAll().body("Hello Camel!"); citrus.expectedMessageCount(1); template.sendBody("direct:start", "Hello Camel!"); citrus.assertIsSatisfied(); - assertThat(citrus.getReceivedCount()).isEqualTo(1); + assertThat(citrus.getReceivedCounter()).isEqualTo(1); } @Test void shouldFailOnPlainTextMismatch() { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=plaintext", CitrusEndpoint.class); - citrus.allMessages().body("Hello Camel!"); + citrus.validateAll().body("Hello Camel!"); citrus.expectedMessageCount(1); template.sendBody("direct:start", "Goodbye Camel!"); assertThatThrownBy(citrus::assertIsSatisfied) - .isInstanceOf(ValidationException.class); + .isInstanceOf(AssertionError.class) + .hasCauseInstanceOf(ValidationException.class); } @Test void shouldValidateDefaultMessageType() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate", CitrusEndpoint.class); - citrus.allMessages().body("Default text"); + citrus.validateAll().body("Default text"); citrus.expectedMessageCount(1); template.sendBody("direct:default", "Default text"); 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 index 4b734fc3b65a5..bb82ea3bb7f95 100644 --- 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 @@ -29,7 +29,7 @@ class CitrusValidateXmlTest extends CamelTestSupport { @Test void shouldValidateXmlBody() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=xml", CitrusEndpoint.class); - citrus.allMessages().body(""" + citrus.validateAll().body(""" Hello World! @@ -43,13 +43,13 @@ void shouldValidateXmlBody() throws Exception { """); citrus.assertIsSatisfied(); - assertThat(citrus.getReceivedCount()).isEqualTo(1); + assertThat(citrus.getReceivedCounter()).isEqualTo(1); } @Test void shouldValidateXpathExpressions() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=xml", CitrusEndpoint.class); - citrus.allMessages().validate(new DefaultValidations().xpath() + citrus.validateAll().validate(new DefaultValidations().xpath() .expression("/greeting/text", "Hello World!") .expression("/greeting/author", "Camel")); citrus.expectedMessageCount(1); @@ -67,7 +67,7 @@ void shouldValidateXpathExpressions() throws Exception { @Test void shouldAutoDetectXmlMessageType() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate", CitrusEndpoint.class); - citrus.allMessages().body(""" + citrus.validateAll().body(""" Hello World! 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 index 58680624f7cec..3520b85d3eb82 100644 --- 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 @@ -30,7 +30,7 @@ class CitrusValidateYamlTest extends CamelTestSupport { @Test void shouldValidateYamlBody() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=yaml", CitrusEndpoint.class); - citrus.allMessages().body(""" + citrus.validateAll().body(""" greeting: Hello World! """); citrus.expectedMessageCount(1); @@ -40,13 +40,13 @@ void shouldValidateYamlBody() throws Exception { """); citrus.assertIsSatisfied(); - assertThat(citrus.getReceivedCount()).isEqualTo(1); + assertThat(citrus.getReceivedCounter()).isEqualTo(1); } @Test void shouldAutoDetectYamlMessageType() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate", CitrusEndpoint.class); - citrus.allMessages().body(""" + citrus.validateAll().body(""" greeting: Hello World! items: - name: first @@ -67,7 +67,7 @@ void shouldAutoDetectYamlMessageType() throws Exception { @Test void shouldPropagateExchangePropertiesAsVariables() throws Exception { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=yaml", CitrusEndpoint.class); - citrus.allMessages().body(""" + citrus.validateAll().body(""" greeting: Hello ${userName}! """); citrus.expectedMessageCount(1); @@ -85,7 +85,7 @@ void shouldPropagateExchangePropertiesAsVariables() throws Exception { @Test void shouldFailOnYamlBodyMismatch() { CitrusEndpoint citrus = context.getEndpoint("citrus:validate?messageType=yaml", CitrusEndpoint.class); - citrus.allMessages().body(""" + citrus.validateAll().body(""" greeting: Hello World! """); citrus.expectedMessageCount(1); @@ -95,7 +95,8 @@ void shouldFailOnYamlBodyMismatch() { """); assertThatThrownBy(citrus::assertIsSatisfied) - .isInstanceOf(ValidationException.class); + .isInstanceOf(AssertionError.class) + .hasCauseInstanceOf(ValidationException.class); } @Override 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 bef527f62ada6..9d1bf697ccc6e 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 @@ -3390,9 +3390,8 @@ public static ChunkEndpointBuilderFactory.ChunkEndpointBuilder chunk(String comp * 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. + * Path parameter: name (required) + * Name of mock endpoint * * @param path operation/name * @return the dsl builder @@ -3416,9 +3415,8 @@ public static CitrusEndpointBuilderFactory.CitrusEndpointBuilder citrus(String p * 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. + * Path parameter: name (required) + * Name of mock endpoint * * @param componentName to use a custom component name for the endpoint * instead of the default 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 index 8deac9e3c7a79..5c1bcbe5036b5 100644 --- 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 @@ -44,6 +44,92 @@ 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. @@ -71,6 +157,76 @@ 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 @@ -117,6 +273,306 @@ 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 { @@ -136,9 +592,8 @@ public interface CitrusBuilders { * 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. + * Path parameter: name (required) + * Name of mock endpoint * * @param path operation/name * @return the dsl builder @@ -162,9 +617,8 @@ default CitrusEndpointBuilder citrus(String path) { * 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. + * Path parameter: name (required) + * Name of mock endpoint * * @param componentName to use a custom component name for the endpoint * instead of the default name From 59ba38b2003a8e40c90ca97c014cd0faa49aef30 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 15 Oct 2026 06:35:48 +0000 Subject: [PATCH 06/17] Regen --- .../dsl/CitrusComponentBuilderFactory.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) 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 index 17a81cd162322..084839da52e85 100644 --- 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 @@ -76,6 +76,26 @@ default CitrusComponentBuilder lazyStartProducer(boolean lazyStartProducer) { } + /** + * 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 @@ -96,6 +116,23 @@ 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 @@ -112,7 +149,9 @@ protected boolean setPropertyOnComponent( 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; } } From f0466834890884a7a62864f81b476c6ed3317b01 Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Thu, 15 Oct 2026 08:35:33 +0200 Subject: [PATCH 07/17] CAMEL-25378: Escape Citrus variable placeholder in adoc to fix website build Co-Authored-By: Claude Opus 4.6 --- .../org/apache/camel/catalog/docs/citrus-component.adoc | 2 +- .../camel-test-citrus/src/main/docs/citrus-component.adoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 2d914f66c330f..4400a11cfa3b1 100644 --- 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 @@ -381,7 +381,7 @@ citrus.expectedMessageCount(1); === 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 (`${variableName}`) +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 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 index 2d914f66c330f..4400a11cfa3b1 100644 --- 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 @@ -381,7 +381,7 @@ citrus.expectedMessageCount(1); === 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 (`${variableName}`) +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 From d6e178a32a0fbcea13d9ddf0f0402f3cb63cd39c Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Thu, 15 Oct 2026 08:56:40 +0200 Subject: [PATCH 08/17] CAMEL-25378: Address review feedback on camel-test-citrus - Handle StreamCache body in buildReceivedMessage to prevent stream drain on pass-through validate operation - Filter Citrus-internal variables (citrus.*, CITRUS_*) in writeBackExtractedVariables to avoid polluting exchange variables - Fix documentation to use 1-based indexing (matching MockEndpoint) - Rewrite receive operation docs to match actual chained API Co-Authored-By: Claude Opus 4.6 --- .../camel/catalog/docs/citrus-component.adoc | 55 ++++++++++--------- .../src/main/docs/citrus-component.adoc | 55 ++++++++++--------- .../citrus/CitrusValidationProcessor.java | 8 +++ 3 files changed, 66 insertions(+), 52 deletions(-) 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 index 4400a11cfa3b1..6866e5868f1ee 100644 --- 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 @@ -261,7 +261,7 @@ template.sendBodyAndHeader("direct:start", "{\"event\": \"deleted\"}", "action", citrus.assertIsSatisfied(); ---- -You can also use `validate(index)` to target a specific exchange by its 0-based position. +You can also use `validate(index)` to target a specific exchange by its 1-based position. 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 @@ -274,11 +274,11 @@ failure is thrown immediately as an exception. [source,java] ---- -citrus.validate(0).body("{\"event\": \"start\"}"); +citrus.validate(1).body("{\"event\": \"start\"}"); citrus.validateAll().body("{\"event\": \"progress\"}").header("type", "status"); -// Exchange 0 → validated against validate(0) only (validateAll() is NOT applied) -// Exchange 1, 2, ... → validated against validateAll() (body + header) +// Exchange 1 → validated against validate(1) only (validateAll() is NOT applied) +// Exchange 2, 3, ... → validated against validateAll() (body + header) ---- === Receive operation (request/response simulation) @@ -294,9 +294,9 @@ should respond with (`then`). This is useful for simulating backend services in CitrusEndpoint citrus = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); // Define expected request and simulated response -citrus.when().allMessages().receive() - .body("{\"action\": \"greet\"}"); -citrus.then().allMessages().respond() +citrus.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() .body("{\"greeting\": \"Hello!\"}"); citrus.expectedMessageCount(1); @@ -311,15 +311,14 @@ assertThat(result.getIn().getBody(String.class)).isEqualTo("{\"greeting\": \"Hel ==== Gherkin-style API -The `when()` and `then()` methods provide a natural-language-like syntax: +The `when()` method returns a `WhenSpec` that provides a chained fluent syntax: -* `when().message(index).receive()` — define validation expectations for a specific exchange -* `when().allMessages().receive()` — define default validation for all exchanges -* `then().message(index).respond()` — define the response for a specific exchange -* `then().allMessages().respond()` — define the default response for all exchanges -* `then().message(index).send()` — alias for `respond()` +* `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()` spec and the `respond()` spec support `body()` and `header()` methods. +Both the `receive()` / `receiveAll()` spec and the `respond()` / `send()` spec support `body()` and `header()` methods. ==== Per-exchange request/response @@ -329,24 +328,26 @@ You can define different request expectations and responses for each exchange in ---- CitrusEndpoint citrus = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); -citrus.when().message(0).receive().body("{\"action\": \"create\"}"); -citrus.then().message(0).respond().body("{\"id\": \"1\", \"status\": \"created\"}"); +citrus.when().receive(1).body("{\"action\": \"create\"}") + .then().respond().body("{\"id\": \"1\", \"status\": \"created\"}"); -citrus.when().message(1).receive().body("{\"action\": \"update\"}"); -citrus.then().message(1).respond().body("{\"id\": \"1\", \"status\": \"updated\"}"); +citrus.when().receive(2).body("{\"action\": \"update\"}") + .then().respond().body("{\"id\": \"1\", \"status\": \"updated\"}"); citrus.expectedMessageCount(2); ---- -Indexed specs take priority over `allMessages()` for both request validation and response -simulation. When a specific exchange has no indexed spec, the `allMessages()` spec is used as +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.then().allMessages().respond() +citrus.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() .body("{\"greeting\": \"Hello!\"}") .header("Content-Type", "application/json") .header("X-Status", "ok"); @@ -354,25 +355,27 @@ citrus.then().allMessages().respond() ==== Citrus variables in responses -Exchange properties are available as Citrus test variables in response templates. This enables -dynamic response generation based on the incoming request context: +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.then().allMessages().respond() +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 `when()` specification: +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.then().allMessages().respond() +citrus.when().receive().then().respond() .body("OK") .header("status", "200"); citrus.expectedMessageCount(1); 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 index 4400a11cfa3b1..6866e5868f1ee 100644 --- 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 @@ -261,7 +261,7 @@ template.sendBodyAndHeader("direct:start", "{\"event\": \"deleted\"}", "action", citrus.assertIsSatisfied(); ---- -You can also use `validate(index)` to target a specific exchange by its 0-based position. +You can also use `validate(index)` to target a specific exchange by its 1-based position. 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 @@ -274,11 +274,11 @@ failure is thrown immediately as an exception. [source,java] ---- -citrus.validate(0).body("{\"event\": \"start\"}"); +citrus.validate(1).body("{\"event\": \"start\"}"); citrus.validateAll().body("{\"event\": \"progress\"}").header("type", "status"); -// Exchange 0 → validated against validate(0) only (validateAll() is NOT applied) -// Exchange 1, 2, ... → validated against validateAll() (body + header) +// Exchange 1 → validated against validate(1) only (validateAll() is NOT applied) +// Exchange 2, 3, ... → validated against validateAll() (body + header) ---- === Receive operation (request/response simulation) @@ -294,9 +294,9 @@ should respond with (`then`). This is useful for simulating backend services in CitrusEndpoint citrus = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); // Define expected request and simulated response -citrus.when().allMessages().receive() - .body("{\"action\": \"greet\"}"); -citrus.then().allMessages().respond() +citrus.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() .body("{\"greeting\": \"Hello!\"}"); citrus.expectedMessageCount(1); @@ -311,15 +311,14 @@ assertThat(result.getIn().getBody(String.class)).isEqualTo("{\"greeting\": \"Hel ==== Gherkin-style API -The `when()` and `then()` methods provide a natural-language-like syntax: +The `when()` method returns a `WhenSpec` that provides a chained fluent syntax: -* `when().message(index).receive()` — define validation expectations for a specific exchange -* `when().allMessages().receive()` — define default validation for all exchanges -* `then().message(index).respond()` — define the response for a specific exchange -* `then().allMessages().respond()` — define the default response for all exchanges -* `then().message(index).send()` — alias for `respond()` +* `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()` spec and the `respond()` spec support `body()` and `header()` methods. +Both the `receive()` / `receiveAll()` spec and the `respond()` / `send()` spec support `body()` and `header()` methods. ==== Per-exchange request/response @@ -329,24 +328,26 @@ You can define different request expectations and responses for each exchange in ---- CitrusEndpoint citrus = context.getEndpoint("citrus:receive/service?messageType=json", CitrusEndpoint.class); -citrus.when().message(0).receive().body("{\"action\": \"create\"}"); -citrus.then().message(0).respond().body("{\"id\": \"1\", \"status\": \"created\"}"); +citrus.when().receive(1).body("{\"action\": \"create\"}") + .then().respond().body("{\"id\": \"1\", \"status\": \"created\"}"); -citrus.when().message(1).receive().body("{\"action\": \"update\"}"); -citrus.then().message(1).respond().body("{\"id\": \"1\", \"status\": \"updated\"}"); +citrus.when().receive(2).body("{\"action\": \"update\"}") + .then().respond().body("{\"id\": \"1\", \"status\": \"updated\"}"); citrus.expectedMessageCount(2); ---- -Indexed specs take priority over `allMessages()` for both request validation and response -simulation. When a specific exchange has no indexed spec, the `allMessages()` spec is used as +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.then().allMessages().respond() +citrus.when().receiveAll() + .body("{\"action\": \"greet\"}") + .then().respond() .body("{\"greeting\": \"Hello!\"}") .header("Content-Type", "application/json") .header("X-Status", "ok"); @@ -354,25 +355,27 @@ citrus.then().allMessages().respond() ==== Citrus variables in responses -Exchange properties are available as Citrus test variables in response templates. This enables -dynamic response generation based on the incoming request context: +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.then().allMessages().respond() +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 `when()` specification: +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.then().allMessages().respond() +citrus.when().receive().then().respond() .body("OK") .header("status", "200"); citrus.expectedMessageCount(1); 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 index 6b76f5d1966ff..436aa0a6409e8 100644 --- 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 @@ -24,6 +24,7 @@ import org.apache.camel.Exchange; import org.apache.camel.Processor; +import org.apache.camel.StreamCache; import org.citrusframework.CitrusContext; import org.citrusframework.context.TestContext; import org.citrusframework.message.DefaultMessage; @@ -90,6 +91,10 @@ public void process(Exchange exchange) throws Exception { 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); } @@ -207,6 +212,9 @@ private void propagateExchangeVariables(Exchange exchange, TestContext testConte private void writeBackExtractedVariables(Exchange exchange, TestContext testContext) { for (Map.Entry entry : testContext.getVariables().entrySet()) { String key = entry.getKey(); + if (key.startsWith("citrus.") || key.startsWith("CITRUS_")) { + continue; + } if (!exchange.getProperties().containsKey(key) && !exchange.getVariables().containsKey(key)) { exchange.setVariable(key, entry.getValue()); } From 28410b8eddae190e3822949ce2b78bea71ed5af8 Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Thu, 15 Oct 2026 09:11:24 +0200 Subject: [PATCH 09/17] CAMEL-25378: Use case-insensitive header validation for Citrus Camel uses CaseInsensitiveMap for exchange headers, so Citrus header validation should ignore case to match Camel's behavior. Co-Authored-By: Claude Opus 4.6 --- .../camel/component/citrus/CitrusValidationProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 436aa0a6409e8..96765b7caa5d0 100644 --- 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 @@ -141,7 +141,7 @@ private List buildValidationContexts(MessageValidationSpec sp List contexts = new ArrayList<>(); contexts.add(new DefaultMessageValidationContext()); if (!spec.getHeaders().isEmpty()) { - contexts.add(new HeaderValidationContext()); + contexts.add(new HeaderValidationContext.Builder().ignoreCase(true).build()); } for (ValidationContext.Builder builder : spec.getValidationContextBuilders()) { contexts.add(builder.build()); From 39708322f9336cc22f2cbc2ebfb39be2bb1ca7b6 Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Thu, 15 Oct 2026 09:34:19 +0200 Subject: [PATCH 10/17] CAMEL-25378: Fix validate() return type and doc inaccuracy - Fix WhenMessageValidationSpec.validate() to return the correct covariant type so .validate(...).then().respond() chains compile - Fix docs: unmatched exchanges pass through silently, not as failures Co-Authored-By: Claude Opus 4.6 --- .../org/apache/camel/catalog/docs/citrus-component.adoc | 6 +++--- .../camel-test-citrus/src/main/docs/citrus-component.adoc | 6 +++--- .../org/apache/camel/component/citrus/CitrusEndpoint.java | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) 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 index 6866e5868f1ee..faf89bdbd5c5d 100644 --- 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 @@ -268,9 +268,9 @@ it replaces `validateAll()` for that exchange entirely (the two are never combin `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 endpoint records a failure. For the `validate` operation, the failure is collected and reported -when `assertIsSatisfied()` is called (similar to MockEndpoint). For the `receive` operation, the -failure is thrown immediately as an exception. +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] ---- 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 index 6866e5868f1ee..faf89bdbd5c5d 100644 --- 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 @@ -268,9 +268,9 @@ it replaces `validateAll()` for that exchange entirely (the two are never combin `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 endpoint records a failure. For the `validate` operation, the failure is collected and reported -when `assertIsSatisfied()` is called (similar to MockEndpoint). For the `receive` operation, the -failure is thrown immediately as an exception. +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] ---- 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 index 10ef50df6e328..71bb618e37b64 100644 --- 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 @@ -191,7 +191,7 @@ public WhenMessageValidationSpec header(String headerName, Object value) { } @Override - public MessageValidationSpec validate(ValidationContext.Builder... validationContexts) { + public WhenMessageValidationSpec validate(ValidationContext.Builder... validationContexts) { super.validate(validationContexts); return this; } From 910f1a102e638468335a19ed10ff0ac6afcf25fc Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Thu, 15 Oct 2026 10:15:02 +0200 Subject: [PATCH 11/17] CAMEL-25378: Make nextSpecIndex final and allow variable overwrite - Make nextSpecIndex field final with set(0) in reset() instead of replacing the AtomicInteger reference - Allow Citrus-extracted variables to overwrite existing exchange variables so @variable()@ extractions are not silently dropped Co-Authored-By: Claude Opus 4.6 --- .../org/apache/camel/component/citrus/CitrusEndpoint.java | 7 +++++-- .../camel/component/citrus/CitrusValidationProcessor.java | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) 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 index 71bb618e37b64..a1d6cb58f32a8 100644 --- 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 @@ -44,7 +44,7 @@ public class CitrusEndpoint extends MockEndpoint { private String messageType; private volatile CitrusContext citrusContext; - private volatile AtomicInteger nextSpecIndex = new AtomicInteger(0); + private final AtomicInteger nextSpecIndex = new AtomicInteger(0); public CitrusEndpoint(String endpointUri, CitrusComponent component) { super(endpointUri, component); @@ -104,7 +104,10 @@ public WhenSpec when() { @Override public void reset() { super.reset(); - nextSpecIndex = new AtomicInteger(0); + // guard against super constructor calling reset() before field initializer runs + if (nextSpecIndex != null) { + nextSpecIndex.set(0); + } } @Override 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 index 96765b7caa5d0..092944def6060 100644 --- 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 @@ -215,7 +215,7 @@ private void writeBackExtractedVariables(Exchange exchange, TestContext testCont if (key.startsWith("citrus.") || key.startsWith("CITRUS_")) { continue; } - if (!exchange.getProperties().containsKey(key) && !exchange.getVariables().containsKey(key)) { + if (!exchange.getProperties().containsKey(key)) { exchange.setVariable(key, entry.getValue()); } } From 827c1dff9d9c59044ad8abbc4a235e7c570de052 Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Thu, 15 Oct 2026 10:44:19 +0200 Subject: [PATCH 12/17] CAMEL-25378: Address davsclaus review feedback - Override @UriPath name with Citrus-specific description to prevent inheriting MockEndpoint's metadata - Inject Supplier into CitrusValidationProcessor so specs configured before doStart() (e.g. isUseAdviceWith tests) work correctly - Indexed specs imply a minimum expected message count when the test has not set one explicitly Co-Authored-By: Claude Opus 4.6 --- .../apache/camel/catalog/components/citrus.json | 2 +- .../citrus/CitrusEndpointUriFactory.java | 2 +- .../apache/camel/component/citrus/citrus.json | 2 +- .../camel/component/citrus/CitrusEndpoint.java | 17 +++++++++++++++-- .../citrus/CitrusValidationProcessor.java | 14 +++++++------- .../endpoint/StaticEndpointBuilders.java | 10 ++++++---- .../dsl/CitrusEndpointBuilderFactory.java | 10 ++++++---- 7 files changed, 37 insertions(+), 20 deletions(-) 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 index 76941d15c7f33..058471240c86c 100644 --- 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 @@ -31,7 +31,7 @@ }, "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": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Name of mock endpoint" }, + "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." }, 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 index 64ff55b2ec76f..54ed2323f209b 100644 --- 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 @@ -60,7 +60,7 @@ public String buildUri(String scheme, Map properties, boolean en Map copy = copyParameters(properties); uri = buildPathParameter(syntax, uri, "operation", null, true, copy); - uri = buildPathParameter(syntax, uri, "name", null, true, copy); + uri = buildPathParameter(syntax, uri, "name", null, false, copy); uri = buildQueryParameters(uri, copy, encode); return uri; } 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 index 76941d15c7f33..058471240c86c 100644 --- 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 @@ -31,7 +31,7 @@ }, "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": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Name of mock endpoint" }, + "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." }, 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 index a1d6cb58f32a8..04f33ae3e9314 100644 --- 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 @@ -39,6 +39,10 @@ public class CitrusEndpoint extends MockEndpoint { @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; @@ -82,17 +86,19 @@ public MessageValidationSpec validate() { 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(citrusContext, messageType, validationSpec); + return new CitrusValidationProcessor(() -> citrusContext.createTestContext(), messageType, validationSpec); } private CitrusValidationProcessor createProcessor( MessageValidationSpec validationSpec, MessageResponseSpec responseSpec) { - return new CitrusValidationProcessor(citrusContext, messageType, validationSpec, responseSpec); + return new CitrusValidationProcessor( + () -> citrusContext.createTestContext(), messageType, validationSpec, responseSpec); } // --- Gherkin-style API (for 'receive' operation) --- @@ -101,6 +107,12 @@ public WhenSpec when() { return new WhenSpec(); } + private void applyImpliedMinimumCount(int index) { + if (getExpectedCount() < 0 && getExpectedMinimumCount() < index) { + expectedMinimumMessageCount(index); + } + } + @Override public void reset() { super.reset(); @@ -157,6 +169,7 @@ public WhenMessageValidationSpec receive() { public WhenMessageValidationSpec receive(int index) { WhenMessageValidationSpec messageValidationSpec = new WhenMessageValidationSpec(index); whenExchangeReceived(index, createProcessor(messageValidationSpec)); + applyImpliedMinimumCount(index); return messageValidationSpec; } 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 index 092944def6060..c9e139140c6e9 100644 --- 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 @@ -21,11 +21,11 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.function.Supplier; import org.apache.camel.Exchange; import org.apache.camel.Processor; import org.apache.camel.StreamCache; -import org.citrusframework.CitrusContext; import org.citrusframework.context.TestContext; import org.citrusframework.message.DefaultMessage; import org.citrusframework.message.MessagePayloadBuilder; @@ -43,20 +43,20 @@ class CitrusValidationProcessor implements Processor { private static final Logger LOG = LoggerFactory.getLogger(CitrusValidationProcessor.class); - private final CitrusContext citrusContext; + private final Supplier testContextSupplier; private final String messageType; private final MessageValidationSpec spec; private final MessageResponseSpec responseSpec; - CitrusValidationProcessor(CitrusContext citrusContext, String messageType, + CitrusValidationProcessor(Supplier testContextSupplier, String messageType, MessageValidationSpec validationSpec) { - this(citrusContext, messageType, validationSpec, null); + this(testContextSupplier, messageType, validationSpec, null); } - CitrusValidationProcessor(CitrusContext citrusContext, String messageType, + CitrusValidationProcessor(Supplier testContextSupplier, String messageType, MessageValidationSpec validationSpec, MessageResponseSpec responseSpec) { - this.citrusContext = citrusContext; + this.testContextSupplier = testContextSupplier; this.messageType = messageType; this.spec = validationSpec; this.responseSpec = responseSpec; @@ -64,7 +64,7 @@ class CitrusValidationProcessor implements Processor { @Override public void process(Exchange exchange) throws Exception { - TestContext testContext = citrusContext.createTestContext(); + TestContext testContext = testContextSupplier.get(); propagateExchangeVariables(exchange, testContext); try { 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 9d1bf697ccc6e..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 @@ -3390,8 +3390,9 @@ public static ChunkEndpointBuilderFactory.ChunkEndpointBuilder chunk(String comp * response. * There are 2 enums and the value can be one of: validate, receive * - * Path parameter: name (required) - * Name of mock endpoint + * 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 @@ -3415,8 +3416,9 @@ public static CitrusEndpointBuilderFactory.CitrusEndpointBuilder citrus(String p * response. * There are 2 enums and the value can be one of: validate, receive * - * Path parameter: name (required) - * Name of mock endpoint + * 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 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 index 5c1bcbe5036b5..57bbadf83f6b2 100644 --- 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 @@ -592,8 +592,9 @@ public interface CitrusBuilders { * response. * There are 2 enums and the value can be one of: validate, receive * - * Path parameter: name (required) - * Name of mock endpoint + * 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 @@ -617,8 +618,9 @@ default CitrusEndpointBuilder citrus(String path) { * response. * There are 2 enums and the value can be one of: validate, receive * - * Path parameter: name (required) - * Name of mock endpoint + * 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 From 5aaaeb12f6e44eb94eea8b419c1eea4d70a9ac03 Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Thu, 15 Oct 2026 11:15:43 +0200 Subject: [PATCH 13/17] CAMEL-25378: Remove dead name field and guard supplier against NPE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove shadowing `name` field from CitrusEndpoint — MockEndpoint already declares @UriPath name, and the inherited getName()/setName() handle the runtime value. The local field was never written to. - Extract createTestContext() method with null guard to prevent NPE if the supplier is invoked after doStop() nulls citrusContext. Co-Authored-By: Claude Opus 4.6 --- .../camel/catalog/components/citrus.json | 2 +- .../citrus/CitrusEndpointUriFactory.java | 2 +- .../apache/camel/component/citrus/citrus.json | 2 +- .../camel/component/citrus/CitrusEndpoint.java | 18 +++++++++++------- .../endpoint/StaticEndpointBuilders.java | 10 ++++------ .../dsl/CitrusEndpointBuilderFactory.java | 10 ++++------ 6 files changed, 22 insertions(+), 22 deletions(-) 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 index 058471240c86c..76941d15c7f33 100644 --- 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 @@ -31,7 +31,7 @@ }, "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." }, + "name": { "index": 1, "kind": "path", "displayName": "Name", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Name of mock endpoint" }, "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." }, 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 index 54ed2323f209b..64ff55b2ec76f 100644 --- 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 @@ -60,7 +60,7 @@ public String buildUri(String scheme, Map properties, boolean en Map copy = copyParameters(properties); uri = buildPathParameter(syntax, uri, "operation", null, true, copy); - uri = buildPathParameter(syntax, uri, "name", null, false, copy); + uri = buildPathParameter(syntax, uri, "name", null, true, copy); uri = buildQueryParameters(uri, copy, encode); return uri; } 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 index 058471240c86c..76941d15c7f33 100644 --- 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 @@ -31,7 +31,7 @@ }, "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." }, + "name": { "index": 1, "kind": "path", "displayName": "Name", "group": "producer", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Name of mock endpoint" }, "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." }, 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 index 04f33ae3e9314..614059cc76260 100644 --- 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 @@ -26,6 +26,7 @@ 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; @@ -39,10 +40,6 @@ public class CitrusEndpoint extends MockEndpoint { @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; @@ -91,14 +88,21 @@ public MessageValidationSpec validate(int index) { } private CitrusValidationProcessor createProcessor(MessageValidationSpec validationSpec) { - return new CitrusValidationProcessor(() -> citrusContext.createTestContext(), messageType, validationSpec); + return new CitrusValidationProcessor(this::createTestContext, messageType, validationSpec); } private CitrusValidationProcessor createProcessor( MessageValidationSpec validationSpec, MessageResponseSpec responseSpec) { - return new CitrusValidationProcessor( - () -> citrusContext.createTestContext(), messageType, validationSpec, 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) --- 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 bef527f62ada6..9d1bf697ccc6e 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 @@ -3390,9 +3390,8 @@ public static ChunkEndpointBuilderFactory.ChunkEndpointBuilder chunk(String comp * 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. + * Path parameter: name (required) + * Name of mock endpoint * * @param path operation/name * @return the dsl builder @@ -3416,9 +3415,8 @@ public static CitrusEndpointBuilderFactory.CitrusEndpointBuilder citrus(String p * 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. + * Path parameter: name (required) + * Name of mock endpoint * * @param componentName to use a custom component name for the endpoint * instead of the default 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 index 57bbadf83f6b2..5c1bcbe5036b5 100644 --- 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 @@ -592,9 +592,8 @@ public interface CitrusBuilders { * 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. + * Path parameter: name (required) + * Name of mock endpoint * * @param path operation/name * @return the dsl builder @@ -618,9 +617,8 @@ default CitrusEndpointBuilder citrus(String path) { * 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. + * Path parameter: name (required) + * Name of mock endpoint * * @param componentName to use a custom component name for the endpoint * instead of the default name From eae6a100d5cdd894133c1a5127d5171fac069fbe Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Thu, 15 Oct 2026 13:51:22 +0200 Subject: [PATCH 14/17] CAMEL-25378: Restore name field with accessor overrides and add missing tests Co-Authored-By: Claude Opus 4.6 --- .../camel/catalog/components/citrus.json | 2 +- .../camel/catalog/docs/citrus-component.adoc | 13 +++ .../citrus/CitrusEndpointUriFactory.java | 2 +- .../apache/camel/component/citrus/citrus.json | 2 +- .../src/main/docs/citrus-component.adoc | 13 +++ .../component/citrus/CitrusEndpoint.java | 21 ++++- .../citrus/CitrusValidationProcessor.java | 4 +- .../CitrusReceiveErrorHandlingTest.java | 85 +++++++++++++++++++ .../CitrusSequentialValidationTest.java | 14 +++ .../component/citrus/CitrusVariableTest.java | 85 +++++++++++++++++++ .../endpoint/StaticEndpointBuilders.java | 10 ++- .../dsl/CitrusEndpointBuilderFactory.java | 10 ++- 12 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusReceiveErrorHandlingTest.java create mode 100644 components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusVariableTest.java 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 index 76941d15c7f33..058471240c86c 100644 --- 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 @@ -31,7 +31,7 @@ }, "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": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Name of mock endpoint" }, + "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." }, 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 index faf89bdbd5c5d..3b223533a49f3 100644 --- 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 @@ -263,6 +263,8 @@ 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. @@ -381,6 +383,17 @@ citrus.when().receive().then().respond() 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. + === Exchange properties and variables as Citrus variables All exchange properties and exchange variables are automatically propagated to the Citrus test 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 index 64ff55b2ec76f..54ed2323f209b 100644 --- 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 @@ -60,7 +60,7 @@ public String buildUri(String scheme, Map properties, boolean en Map copy = copyParameters(properties); uri = buildPathParameter(syntax, uri, "operation", null, true, copy); - uri = buildPathParameter(syntax, uri, "name", null, true, copy); + uri = buildPathParameter(syntax, uri, "name", null, false, copy); uri = buildQueryParameters(uri, copy, encode); return uri; } 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 index 76941d15c7f33..058471240c86c 100644 --- 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 @@ -31,7 +31,7 @@ }, "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": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "description": "Name of mock endpoint" }, + "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." }, 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 index faf89bdbd5c5d..3b223533a49f3 100644 --- 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 @@ -263,6 +263,8 @@ 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. @@ -381,6 +383,17 @@ citrus.when().receive().then().respond() 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. + === Exchange properties and variables as Citrus variables All exchange properties and exchange variables are automatically propagated to the Citrus test 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 index 614059cc76260..bf58be0ad4dfa 100644 --- 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 @@ -40,6 +40,10 @@ public class CitrusEndpoint extends MockEndpoint { @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; @@ -146,6 +150,17 @@ protected void performAssertions(Exchange exchange, Exchange copy) throws Except // --- 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; } @@ -171,6 +186,8 @@ public WhenMessageValidationSpec receive() { } 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); @@ -246,8 +263,4 @@ public MessageResponseSpec send() { } } - // --- Validation spec for expected messages --- - - // --- Response spec for simulated responses --- - } 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 index c9e139140c6e9..47af6a7b55105 100644 --- 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 @@ -215,9 +215,7 @@ private void writeBackExtractedVariables(Exchange exchange, TestContext testCont if (key.startsWith("citrus.") || key.startsWith("CITRUS_")) { continue; } - if (!exchange.getProperties().containsKey(key)) { - exchange.setVariable(key, entry.getValue()); - } + exchange.setVariable(key, entry.getValue()); } } } 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..11192d5aa55b9 --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusReceiveErrorHandlingTest.java @@ -0,0 +1,85 @@ +/* + * 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); + } + + @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/CitrusSequentialValidationTest.java b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusSequentialValidationTest.java index 09f98f7e8ae9c..9b25baefa07ad 100644 --- 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 @@ -130,6 +130,20 @@ void shouldFailSequentialValidationOnMismatch() { .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() { 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..f3081e8f8ba4f --- /dev/null +++ b/components/camel-test/camel-test-citrus/src/test/java/org/apache/camel/component/citrus/CitrusVariableTest.java @@ -0,0 +1,85 @@ +/* + * 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"); + } + + @Override + protected RoutesBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:start").to("citrus:validate/test?messageType=json"); + } + }; + } +} 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 9d1bf697ccc6e..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 @@ -3390,8 +3390,9 @@ public static ChunkEndpointBuilderFactory.ChunkEndpointBuilder chunk(String comp * response. * There are 2 enums and the value can be one of: validate, receive * - * Path parameter: name (required) - * Name of mock endpoint + * 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 @@ -3415,8 +3416,9 @@ public static CitrusEndpointBuilderFactory.CitrusEndpointBuilder citrus(String p * response. * There are 2 enums and the value can be one of: validate, receive * - * Path parameter: name (required) - * Name of mock endpoint + * 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 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 index 5c1bcbe5036b5..57bbadf83f6b2 100644 --- 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 @@ -592,8 +592,9 @@ public interface CitrusBuilders { * response. * There are 2 enums and the value can be one of: validate, receive * - * Path parameter: name (required) - * Name of mock endpoint + * 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 @@ -617,8 +618,9 @@ default CitrusEndpointBuilder citrus(String path) { * response. * There are 2 enums and the value can be one of: validate, receive * - * Path parameter: name (required) - * Name of mock endpoint + * 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 From 8c52e497279761ae7f276e627db4b12926b570ee Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Thu, 15 Oct 2026 16:53:41 +0200 Subject: [PATCH 15/17] CAMEL-25378: Only write back new or changed variables after Citrus validation Co-Authored-By: Claude Opus 4.6 --- .../citrus/CitrusValidationProcessor.java | 12 +++++++++--- .../component/citrus/CitrusVariableTest.java | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) 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 index 47af6a7b55105..29d491d79cb26 100644 --- 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 @@ -17,10 +17,12 @@ 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; @@ -66,6 +68,7 @@ class CitrusValidationProcessor implements Processor { public void process(Exchange exchange) throws Exception { TestContext testContext = testContextSupplier.get(); propagateExchangeVariables(exchange, testContext); + Map propagated = new HashMap<>(testContext.getVariables()); try { if (spec != null) { @@ -83,7 +86,7 @@ public void process(Exchange exchange) throws Exception { applyResponse(exchange, responseSpec, testContext); } } finally { - writeBackExtractedVariables(exchange, testContext); + writeBackExtractedVariables(exchange, testContext, propagated); } } @@ -209,13 +212,16 @@ private void propagateExchangeVariables(Exchange exchange, TestContext testConte } } - private void writeBackExtractedVariables(Exchange exchange, TestContext testContext) { + 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; } - exchange.setVariable(key, entry.getValue()); + 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/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 index f3081e8f8ba4f..6c56ddf524e2e 100644 --- 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 @@ -73,6 +73,23 @@ void shouldExtractVariableEvenWhenPropertyExists() throws Exception { 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() { From b9a0061b512ce43f46207e71af75aaba481b1cd9 Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Thu, 15 Oct 2026 17:34:12 +0200 Subject: [PATCH 16/17] CAMEL-25378: Fix receive mode failure recording and document edge cases Co-Authored-By: Claude Opus 4.6 --- .../src/main/docs/citrus-component.adoc | 12 +++++++++++- .../camel/component/citrus/CitrusEndpoint.java | 16 +++++++++++++--- .../citrus/CitrusReceiveErrorHandlingTest.java | 3 +++ 3 files changed, 27 insertions(+), 4 deletions(-) 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 index 3b223533a49f3..f004bb3dcff88 100644 --- 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 @@ -39,8 +39,10 @@ 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). +* `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[] @@ -53,6 +55,10 @@ include::partial$component-endpoint-headers.adoc[] 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 @@ -394,6 +400,10 @@ The two operations handle validation failures differently with respect to Camel' `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. + === Exchange properties and variables as Citrus variables All exchange properties and exchange variables are automatically propagated to the Citrus test 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 index bf58be0ad4dfa..efcdfc354e20a 100644 --- 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 @@ -80,6 +80,10 @@ public MessageValidationSpec validateAll() { 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()); } @@ -139,12 +143,15 @@ protected void performAssertions(Exchange exchange, Exchange copy) throws Except // assertIsSatisfied() can report Citrus validation errors properly. Exception e = exchange.getException(); if (e != null && !getFailures().contains(e)) { - getFailures().add(e); if ("validate".equals(operation)) { - // For validate-only mode, clear the exception so it does not propagate - // through the route — validation errors are collected, not thrown. + // 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(). } } @@ -181,6 +188,9 @@ public void setMessageType(String messageType) { public class WhenSpec { + /** + * Auto-incrementing variant. Do not mix with explicit {@code receive(index)} calls. + */ public WhenMessageValidationSpec receive() { return receive(nextSpecIndex.incrementAndGet()); } 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 index 11192d5aa55b9..3b52c39aab954 100644 --- 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 @@ -63,6 +63,9 @@ void receiveOperationTriggersOnException() throws Exception { 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 From ac018291b764fefa54ca37af11a0dd29a3af82d4 Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Thu, 15 Oct 2026 19:56:58 +0200 Subject: [PATCH 17/17] CAMEL-25378: Bump parent to 4.24.0-SNAPSHOT and clarify redelivery doc Co-Authored-By: Claude Opus 4.6 --- bom/camel-bom/pom.xml | 2 +- .../apache/camel/catalog/components/citrus.json | 2 +- .../camel/catalog/docs/citrus-component.adoc | 14 +++++++++++++- components/camel-test/camel-test-citrus/pom.xml | 2 +- .../org/apache/camel/component/citrus/citrus.json | 2 +- .../services/org/apache/camel/component.properties | 2 +- .../src/main/docs/citrus-component.adoc | 4 +++- 7 files changed, 21 insertions(+), 7 deletions(-) diff --git a/bom/camel-bom/pom.xml b/bom/camel-bom/pom.xml index 369a1040e16d3..72312b9de0739 100644 --- a/bom/camel-bom/pom.xml +++ b/bom/camel-bom/pom.xml @@ -2455,7 +2455,7 @@ org.apache.camel camel-test-citrus - 4.23.0-SNAPSHOT + 4.24.0-SNAPSHOT org.apache.camel 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 index 058471240c86c..b068b5b4b76a8 100644 --- 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 @@ -11,7 +11,7 @@ "supportLevel": "Preview", "groupId": "org.apache.camel", "artifactId": "camel-test-citrus", - "version": "4.23.0-SNAPSHOT", + "version": "4.24.0-SNAPSHOT", "scheme": "citrus", "extendsScheme": "", "syntax": "citrus:operation\/name", 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 index 3b223533a49f3..91b9b9254e038 100644 --- 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 @@ -39,8 +39,10 @@ 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). +* `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[] @@ -53,6 +55,10 @@ include::partial$component-endpoint-headers.adoc[] 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 @@ -394,6 +400,12 @@ The two operations handle validation failures differently with respect to Camel' `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 diff --git a/components/camel-test/camel-test-citrus/pom.xml b/components/camel-test/camel-test-citrus/pom.xml index 278ed31394869..3a61ebdd45654 100644 --- a/components/camel-test/camel-test-citrus/pom.xml +++ b/components/camel-test/camel-test-citrus/pom.xml @@ -23,7 +23,7 @@ org.apache.camel camel-test-parent - 4.23.0-SNAPSHOT + 4.24.0-SNAPSHOT camel-test-citrus 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 index 058471240c86c..b068b5b4b76a8 100644 --- 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 @@ -11,7 +11,7 @@ "supportLevel": "Preview", "groupId": "org.apache.camel", "artifactId": "camel-test-citrus", - "version": "4.23.0-SNAPSHOT", + "version": "4.24.0-SNAPSHOT", "scheme": "citrus", "extendsScheme": "", "syntax": "citrus:operation\/name", 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 index 2ecb2a503903d..ecc06c05f86d0 100644 --- 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 @@ -2,6 +2,6 @@ components=citrus groupId=org.apache.camel artifactId=camel-test-citrus -version=4.23.0-SNAPSHOT +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/main/docs/citrus-component.adoc b/components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc index f004bb3dcff88..91b9b9254e038 100644 --- 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 @@ -402,7 +402,9 @@ The two operations handle validation failures differently with respect to Camel' 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. +`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