davsclaus commented on code in PR #27792: URL: https://github.com/apache/camel/pull/27792#discussion_r4275132736
########## components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusProducer.java: ########## @@ -0,0 +1,247 @@ +/* + * 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.Locale; +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 + 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 { + 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); + + List<ValidationContext> 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); + if ("receive".equals(endpoint.getOperation())) { + throw e; + } Review Comment: Recording the failure for `validate` is good. The side effect is that a failed validation is now only visible through `citrus.assertIsSatisfied()`. `MockEndpoint.assertIsSatisfied(context)` only checks `MockEndpoint` instances, so a test that relies on the context-wide assert passes with a failed validation. Before this commit, `sendBody` failed. This makes the context-wide assert (`extends MockEndpoint`, or a static `assertIsSatisfied(CamelContext)` helper) more important than a follow-up. Please also add the test with `onException(...).handled(true)` or a redelivery policy for `receive`. A retry calls `nextExchangeIndex()` again, so `message(1)` is applied to the retry of the first exchange. ########## components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusEndpoint.java: ########## @@ -0,0 +1,413 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.component.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.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<Exchange> receivedExchanges = new CopyOnWriteArrayList<>(); + private final List<Throwable> 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.", + enums = "validate,receive") + @Metadata(required = true) + private String operation; + + @UriPath(description = "A unique name to identify this endpoint. Allows multiple Citrus endpoints" + + " with different validation expectations in the same route.") + private String name; + + @UriParam(description = "The message type for validation. Determines which Citrus validation module is used." + + " Supported values: json, xml, yaml, plaintext.") + private String messageType; + + private volatile MessageValidationSpec allMessagesSpec; + private final Map<Integer, MessageValidationSpec> indexedSpecs = new LinkedHashMap<>(); + private volatile int nextSpecIndex; + + private volatile MessageResponseSpec allMessagesResponseSpec; + private final Map<Integer, MessageResponseSpec> indexedResponseSpecs = new LinkedHashMap<>(); + private volatile int nextResponseSpecIndex; + + private volatile 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.receivedCount.set(0); + this.receivedExchanges.clear(); + 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 { + int effectiveExpected = expectedCount; + if (effectiveExpected < 0 && !indexedSpecs.isEmpty()) { + effectiveExpected = indexedSpecs.size(); + } Review Comment: `indexedSpecs.size()` is used as an exact count. With an `allMessages()` fallback, more exchanges than indexed specs is the normal case: ```java citrus.message(0).body("{\"event\": \"start\"}"); citrus.allMessages().body("{\"event\": \"progress\"}"); // 3 exchanges sent, no expectedMessageCount() citrus.assertIsSatisfied(); // fails: Expected 1 messages but received 3 ``` This is the example in the docs. Suggest deriving an exact count only when `allMessagesSpec == null`, and using the highest index + 1 rather than the map size. With a fallback, "at least highest index + 1" fits better. Also, `latch` stays `null` on this path, so `assertIsSatisfied()` does not wait. For a route that reaches the endpoint asynchronously (seda, wireTap, threads), it fails at once with "received 0". Could the wait also apply to the derived count? A test for the derived path, sync and async, would cover both cases. ########## components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc: ########## @@ -0,0 +1,409 @@ += Citrus Component +:doctitle: Citrus +:shortname: citrus +:artifactid: camel-test-citrus +:description: Citrus message validation component for testing +:since: 4.24 +:supportlevel: Preview +:tabs-sync-option: +:component-header: Only producer is supported + +*Since Camel {since}* + +*{component-header}* + +The Citrus component integrates with the https://citrusframework.org/[Citrus integration test framework] +to perform message validation on exchanges arriving at a Camel endpoint. It allows test authors to +verify exchange body content, headers, and path expressions (JsonPath, XPath) using Citrus validation +modules. + +This component is intended for use in Camel route unit and integration tests. + +== Maven dependency + +[source,xml] +---- +<dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-test-citrus</artifactId> + <scope>test</scope> +</dependency> +---- + +== 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). Review Comment: The failure behaviour is only described in the missing-spec paragraph further down. Users need it up front, because it decides whether the test must call `assertIsSatisfied()`: ```suggestion * `validate` — validates exchanges without modifying them (pass-through validation). A validation failure does not fail the route. Failures are collected and reported when `assertIsSatisfied()` is called on the endpoint, so a test must always call it. * `receive` — validates exchanges and returns a simulated response (request/response simulation). A validation failure is thrown into the route as an exception, so the route's error handling applies. ``` ########## components/camel-test/camel-test-citrus/src/main/java/org/apache/camel/component/citrus/CitrusProducer.java: ########## @@ -0,0 +1,247 @@ +/* + * 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.Locale; +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 + 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 { + 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); + + List<ValidationContext> 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); + if ("receive".equals(endpoint.getOperation())) { + throw e; + } + } finally { + writeBackExtractedVariables(exchange, testContext); + endpoint.onExchangeReceived(exchange); + } + } + + private void propagateExchangeVariables(Exchange exchange, TestContext testContext) { + for (Map.Entry<String, Object> entry : exchange.getProperties().entrySet()) { + if (entry.getValue() != null) { + testContext.setVariable(entry.getKey(), entry.getValue()); + } + } + for (Map.Entry<String, Object> entry : exchange.getVariables().entrySet()) { + if (entry.getValue() != null) { + testContext.setVariable(entry.getKey(), entry.getValue()); + } + } + } + + private void writeBackExtractedVariables(Exchange exchange, TestContext testContext) { + for (Map.Entry<String, Object> entry : testContext.getVariables().entrySet()) { + String key = entry.getKey(); + if (!exchange.getProperties().containsKey(key) && !exchange.getVariables().containsKey(key)) { + exchange.setVariable(key, entry.getValue()); + } + } + } Review Comment: Keys that already exist on the exchange are skipped. So if `@variable('orderId')@` extracts a value and the exchange already has an `orderId` property or variable (for example from an earlier step), the extracted value is dropped and the old value stays. Writing back only the keys whose value changed, or always overwriting exchange variables, would avoid that. No test covers this method or the `exchange.getVariables()` propagation above. Please add one that uses `setVariable` and one that reads an extracted variable after the endpoint. ########## components/camel-test/camel-test-citrus/src/main/docs/citrus-component.adoc: ########## @@ -0,0 +1,409 @@ += Citrus Component +:doctitle: Citrus +:shortname: citrus +:artifactid: camel-test-citrus +:description: Citrus message validation component for testing +:since: 4.24 +:supportlevel: Preview +:tabs-sync-option: +:component-header: Only producer is supported + +*Since Camel {since}* + +*{component-header}* + +The Citrus component integrates with the https://citrusframework.org/[Citrus integration test framework] +to perform message validation on exchanges arriving at a Camel endpoint. It allows test authors to +verify exchange body content, headers, and path expressions (JsonPath, XPath) using Citrus validation +modules. + +This component is intended for use in Camel route unit and integration tests. + +== Maven dependency + +[source,xml] +---- +<dependency> + <groupId>org.apache.camel</groupId> + <artifactId>camel-test-citrus</artifactId> + <scope>test</scope> +</dependency> +---- + +== 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 + `<ns1:element xmlns:ns1="...">` and `<ns2:element xmlns:ns2="...">` 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 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"); + +// 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 Review Comment: Exchange variables are propagated too now: ```suggestion Exchange properties and exchange variables are available as Citrus test variables in response templates. This enables ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
