gnodet-bot commented on code in PR #27792: URL: https://github.com/apache/camel/pull/27792#discussion_r4274585661
########## 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; Review Comment: ⚠️ **Potential silent no-op for `validate` operation (HIGH)** `resolveValidationSpec` returns `null` here when no specs are configured (neither `allMessages()` nor any `message(n)` was called). In `CitrusProducer.process()`, the `if (spec != null)` guard then silently skips all validation. This means a `citrus:validate` endpoint with no spec configured is a silent pass-through — all messages validate successfully with no assertion. This could easily mask test misconfiguration: a developer who forgets to configure the endpoint gets a green test instead of a meaningful error. Consider throwing `IllegalStateException` here (or at the start of `process()`) when the operation is `validate` and no spec is configured, similar to the existing throw for an index with mixed indexed/all-messages configs. ########## 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()); + } + } + } + + private DefaultMessage buildReceivedMessage(Exchange exchange) { + Object body = exchange.getIn().getBody(); Review Comment: ⚠️ **StreamCache body consumed without reset (MEDIUM)** `exchange.getIn().getBody()` is called here without converting to a stable format first. If the exchange body is a Camel `StreamCache` (e.g. from an HTTP or file-based route), this call drains the stream. Any downstream processor in the route that tries to read the body after the `citrus:validate` endpoint will receive an empty/exhausted stream. For `validate` (pass-through) usage, this is particularly problematic. Consider: ```java Object body = exchange.getIn().getBody(String.class); // force conversion ``` or reset the stream after reading: ```java Object body = exchange.getIn().getBody(); if (body instanceof StreamCache sc) { sc.reset(); } ``` ########## 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: ⚠️ **`writeBackExtractedVariables` leaks internal Citrus variables (LOW)** This method iterates all `testContext.getVariables()` and writes any key not already present in the exchange. Citrus `TestContext` comes pre-populated with internal framework variables (e.g. `citrus.test.name`, `citrus.test.package`, `CITRUS_MESSAGES` and similar). These internal variables will be written into the exchange, polluting its variable map with Citrus internals visible to downstream processors. Consider filtering by prefix: ```java if (!key.startsWith("citrus.") && !exchange.getProperties().containsKey(key) && !exchange.getVariables().containsKey(key)) { exchange.setVariable(key, entry.getValue()); } ``` Or better: only write back variables that were explicitly extracted via Citrus `@ExtractFromPayload` or similar mechanisms, not the entire context. ########## 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 Review Comment: ℹ️ **Generated Javadoc version mismatch (LOW)** The Javadoc here says `Since: 4.23` but `@UriEndpoint(firstVersion = "4.24.0")` in `CitrusEndpoint.java` and the catalog JSON both say `4.24.0`. This generated file and `CitrusComponentBuilderFactory.java` carry the wrong version. Since these are generated files, the fix is in the annotation or the generation tooling input, not here directly — but the inconsistency should be resolved before this lands. -- 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]
