This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 0f9f085e8b97 CAMEL-26184: camel-quickfix - an InOut consumer replies
only to application messages the route answers, and an InOut producer without
CorrelationCriteria fails before sending (#29013)
0f9f085e8b97 is described below
commit 0f9f085e8b97aab2f45bba04fa2573fb8b6a7788
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 3 07:52:34 2026 +0100
CAMEL-26184: camel-quickfix - an InOut consumer replies only to application
messages the route answers, and an InOut producer without CorrelationCriteria
fails before sending (#29013)
* CAMEL-26184: camel-quickfix - an InOut consumer replies only to appli…
…cation messages the route answers, and an InOut producer without
CorrelationCriteria fails before sending
An InOut consumer answered every event with the body the route ended with,
so
unanswered messages were echoed back and the AdminMessageSent event of the
Logon
sent the Logon again until a StackOverflowError. It now replies only to an
AppMessageReceived event, when the route set a body other than the received
message (and not null), and the exchange did not fail. A reply that is not
a FIX
message goes to the exception handler instead of failing inside fromApp,
where
the engine asked for the request again and again.
An InOut producer without the CorrelationCriteria property registered a rule
with a null predicate, which failed every engine event and timed out. It now
fails with an IllegalArgumentException before the message is sent. A
producer
body that is not a FIX message fails with an InvalidPayloadException
instead of
a NullPointerException.
* CAMEL-26184: camel-quickfix - an InOut consumer replies with the rece…
…ived message changed in place
The reply to an AppMessageReceived event is the body the route ends with,
also when it is the received
FIX message changed in place. A null body sends no reply.
* CAMEL-26184: camel-quickfix - the InOut consumer example answers only…
… OrderStatusRequest, and sets a null body for the other messages
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../apache/camel/catalog/components/quickfix.json | 4 +-
.../camel/catalog/docs/quickfix-component.adoc | 41 +++-
.../apache/camel/component/quickfixj/quickfix.json | 4 +-
.../src/main/docs/quickfix-component.adoc | 41 +++-
.../component/quickfixj/MessageCorrelator.java | 7 +
.../component/quickfixj/QuickfixjConsumer.java | 26 ++-
.../component/quickfixj/QuickfixjEndpoint.java | 11 +-
.../component/quickfixj/QuickfixjProducer.java | 2 +-
.../component/quickfixj/QuickfixjConsumerTest.java | 75 ++++++-
.../component/quickfixj/QuickfixjInOutTest.java | 236 +++++++++++++++++++++
.../QuickfixjProducerCorrelationCleanupTest.java | 18 ++
.../component/quickfixj/QuickfixjProducerTest.java | 2 +-
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 18 ++
13 files changed, 450 insertions(+), 35 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/quickfix.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/quickfix.json
index e4a007573f98..9e3e8f292c7e 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/quickfix.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/quickfix.json
@@ -40,9 +40,9 @@
},
"bodyTypes": {
"consumerOut": { "carriers": [ "pojo", "null" ], "description": "The FIX
message: a quickfix.Message, such as a quickfix.fix44.Email, with the kind of
event in the EventCategory header and the MsgType in the MessageType header.
SessionCreated, SessionLogon and SessionLogoff events have no body" },
- "consumerReply": { "carriers": [ "pojo", "text", "bytes" ], "description":
"With exchangePattern=InOut, the reply sent back on the session of the request:
a quickfix.Message, or the FIX message as text or bytes. Every event is
answered with the body the route ends with: a route that sets no reply sends
back the message it received" },
+ "consumerReply": { "carriers": [ "pojo", "text", "bytes" ], "description":
"With exchangePattern=InOut, the reply sent back on the session of the request:
a quickfix.Message, or the FIX message as text or bytes. Only an application
message received (AppMessageReceived) is answered, with the body the route ends
with: a new message or the received message changed in place. A route that does
not change the body sends back the message it received, and a null body sends
no reply. A reply [...]
"producerIn": { "carriers": [ "pojo", "text", "bytes" ], "description":
"The FIX message to send: a quickfix.Message, or the FIX message as text or
bytes, parsed with the DataDictionary exchange property or the data dictionary
of the session. A stream, a Map or no body fails" },
- "producerOut": { "carriers": [ "unchanged", "pojo" ], "description": "The
body is left as it was. An InOut exchange waits for the reply that matches the
CorrelationCriteria exchange property, and the reply quickfix.Message is the
body" }
+ "producerOut": { "carriers": [ "unchanged", "pojo" ], "description": "The
body is left as it was. An InOut exchange waits for the reply that matches the
CorrelationCriteria exchange property, and the reply quickfix.Message is the
body. An InOut exchange without the property fails before the message is sent" }
},
"properties": {
"configurationName": { "index": 0, "kind": "path", "displayName":
"Configuration Name", "group": "common", "label": "", "required": true, "type":
"string", "javaType": "java.lang.String", "deprecated": false,
"deprecationNote": "", "autowired": false, "secret": false,
"supportFileReference": true, "description": "Path to the quickfix
configuration file. You can prefix with: classpath, file, http, ref, or bean.
classpath, file and http loads the configuration file using these protocol [...]
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickfix-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickfix-component.adoc
index 61d92dbca9fa..e8956f2a410a 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickfix-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/quickfix-component.adoc
@@ -241,7 +241,8 @@ Add `exchangePattern=InOut` to the QuickFIX/J endpoint URI.
The
the example below is a bean with a synchronous service method. The
method returns the response
to the request (an ExecutionReport in this case) which is then sent back
-to the requestor session.
+to the requestor session. The `otherwise` sets a null body, so the other
+messages are not answered.
[tabs]
====
@@ -250,8 +251,11 @@ Java::
[source,java]
----
from("quickfix:examples/inprocess.qf.cfg?sessionID=FIX.4.2:MARKET->TRADER&exchangePattern=InOut")
-
.filter(header(QuickfixjEndpoint.MESSAGE_TYPE_KEY).isEqualTo(MsgType.ORDER_STATUS_REQUEST))
- .bean(new MarketOrderStatusService());
+ .choice()
+
.when(header(QuickfixjEndpoint.MESSAGE_TYPE_KEY).isEqualTo(MsgType.ORDER_STATUS_REQUEST))
+ .bean(new MarketOrderStatusService())
+ .otherwise()
+ .setBody(simple("${null}"));
----
YAML::
@@ -265,19 +269,33 @@ YAML::
sessionID: "FIX.4.2:MARKET->TRADER"
exchangePattern: InOut
steps:
- - filter:
- expression:
- simple:
- expression: "${header.MessageType} == 'H'"
- steps:
- - bean:
- beanType: com.example.MarketOrderStatusService
+ - choice:
+ when:
+ - expression:
+ simple:
+ expression: "${header.MessageType} == 'H'"
+ steps:
+ - bean:
+ beanType: com.example.MarketOrderStatusService
+ otherwise:
+ steps:
+ - setBody:
+ expression:
+ simple:
+ expression: "${null}"
----
====
In YAML, the headers are written by name: `MessageType` is
`QuickfixjEndpoint.MESSAGE_TYPE_KEY`, and the type of an
OrderStatusRequest is `H`.
+A reply is sent only for an application message received (the
`AppMessageReceived` event): the reply is the body
+the route ends with. This can be a new FIX message, or the received message
changed in place. A route that does not
+change the body sends the received message back, so set a null body when a
message must not be answered (for example
+in the `otherwise` of a `choice`). The other events (such as the Logon being
sent) are never answered. A reply that is
+not a FIX message, such as a `Map`, is not sent: the error goes to the
exception handler of the consumer (logged at
+WARN by default). No reply is sent when the exchange failed.
+
The reply is always sent on the session the request was received on.
Changing the `SessionID` header while routing does not change where the
reply goes. To send a message to another session, use a QuickFIX/J
@@ -311,7 +329,8 @@ matches our request. The SenderCompID and TargetCompID of
the session ID are
matched as given against the header of the reply, so use the session ID as
the __replier__ sees it: for a request sent on `FIX.4.2:TRADER->MARKET`, use
`FIX.4.2:MARKET->TRADER`. The correlation criteria is required for an InOut
-exchange.
+exchange: without it, the exchange fails with an `IllegalArgumentException`
before the message is sent. Use the
+`InOnly` exchange pattern to send a message without waiting for a reply.
._Java-only: programmatic correlation criteria setup using QuickFIX/J API_
[source,java]
diff --git
a/components/camel-quickfix/src/generated/resources/META-INF/org/apache/camel/component/quickfixj/quickfix.json
b/components/camel-quickfix/src/generated/resources/META-INF/org/apache/camel/component/quickfixj/quickfix.json
index e4a007573f98..9e3e8f292c7e 100644
---
a/components/camel-quickfix/src/generated/resources/META-INF/org/apache/camel/component/quickfixj/quickfix.json
+++
b/components/camel-quickfix/src/generated/resources/META-INF/org/apache/camel/component/quickfixj/quickfix.json
@@ -40,9 +40,9 @@
},
"bodyTypes": {
"consumerOut": { "carriers": [ "pojo", "null" ], "description": "The FIX
message: a quickfix.Message, such as a quickfix.fix44.Email, with the kind of
event in the EventCategory header and the MsgType in the MessageType header.
SessionCreated, SessionLogon and SessionLogoff events have no body" },
- "consumerReply": { "carriers": [ "pojo", "text", "bytes" ], "description":
"With exchangePattern=InOut, the reply sent back on the session of the request:
a quickfix.Message, or the FIX message as text or bytes. Every event is
answered with the body the route ends with: a route that sets no reply sends
back the message it received" },
+ "consumerReply": { "carriers": [ "pojo", "text", "bytes" ], "description":
"With exchangePattern=InOut, the reply sent back on the session of the request:
a quickfix.Message, or the FIX message as text or bytes. Only an application
message received (AppMessageReceived) is answered, with the body the route ends
with: a new message or the received message changed in place. A route that does
not change the body sends back the message it received, and a null body sends
no reply. A reply [...]
"producerIn": { "carriers": [ "pojo", "text", "bytes" ], "description":
"The FIX message to send: a quickfix.Message, or the FIX message as text or
bytes, parsed with the DataDictionary exchange property or the data dictionary
of the session. A stream, a Map or no body fails" },
- "producerOut": { "carriers": [ "unchanged", "pojo" ], "description": "The
body is left as it was. An InOut exchange waits for the reply that matches the
CorrelationCriteria exchange property, and the reply quickfix.Message is the
body" }
+ "producerOut": { "carriers": [ "unchanged", "pojo" ], "description": "The
body is left as it was. An InOut exchange waits for the reply that matches the
CorrelationCriteria exchange property, and the reply quickfix.Message is the
body. An InOut exchange without the property fails before the message is sent" }
},
"properties": {
"configurationName": { "index": 0, "kind": "path", "displayName":
"Configuration Name", "group": "common", "label": "", "required": true, "type":
"string", "javaType": "java.lang.String", "deprecated": false,
"deprecationNote": "", "autowired": false, "secret": false,
"supportFileReference": true, "description": "Path to the quickfix
configuration file. You can prefix with: classpath, file, http, ref, or bean.
classpath, file and http loads the configuration file using these protocol [...]
diff --git a/components/camel-quickfix/src/main/docs/quickfix-component.adoc
b/components/camel-quickfix/src/main/docs/quickfix-component.adoc
index 61d92dbca9fa..e8956f2a410a 100644
--- a/components/camel-quickfix/src/main/docs/quickfix-component.adoc
+++ b/components/camel-quickfix/src/main/docs/quickfix-component.adoc
@@ -241,7 +241,8 @@ Add `exchangePattern=InOut` to the QuickFIX/J endpoint URI.
The
the example below is a bean with a synchronous service method. The
method returns the response
to the request (an ExecutionReport in this case) which is then sent back
-to the requestor session.
+to the requestor session. The `otherwise` sets a null body, so the other
+messages are not answered.
[tabs]
====
@@ -250,8 +251,11 @@ Java::
[source,java]
----
from("quickfix:examples/inprocess.qf.cfg?sessionID=FIX.4.2:MARKET->TRADER&exchangePattern=InOut")
-
.filter(header(QuickfixjEndpoint.MESSAGE_TYPE_KEY).isEqualTo(MsgType.ORDER_STATUS_REQUEST))
- .bean(new MarketOrderStatusService());
+ .choice()
+
.when(header(QuickfixjEndpoint.MESSAGE_TYPE_KEY).isEqualTo(MsgType.ORDER_STATUS_REQUEST))
+ .bean(new MarketOrderStatusService())
+ .otherwise()
+ .setBody(simple("${null}"));
----
YAML::
@@ -265,19 +269,33 @@ YAML::
sessionID: "FIX.4.2:MARKET->TRADER"
exchangePattern: InOut
steps:
- - filter:
- expression:
- simple:
- expression: "${header.MessageType} == 'H'"
- steps:
- - bean:
- beanType: com.example.MarketOrderStatusService
+ - choice:
+ when:
+ - expression:
+ simple:
+ expression: "${header.MessageType} == 'H'"
+ steps:
+ - bean:
+ beanType: com.example.MarketOrderStatusService
+ otherwise:
+ steps:
+ - setBody:
+ expression:
+ simple:
+ expression: "${null}"
----
====
In YAML, the headers are written by name: `MessageType` is
`QuickfixjEndpoint.MESSAGE_TYPE_KEY`, and the type of an
OrderStatusRequest is `H`.
+A reply is sent only for an application message received (the
`AppMessageReceived` event): the reply is the body
+the route ends with. This can be a new FIX message, or the received message
changed in place. A route that does not
+change the body sends the received message back, so set a null body when a
message must not be answered (for example
+in the `otherwise` of a `choice`). The other events (such as the Logon being
sent) are never answered. A reply that is
+not a FIX message, such as a `Map`, is not sent: the error goes to the
exception handler of the consumer (logged at
+WARN by default). No reply is sent when the exchange failed.
+
The reply is always sent on the session the request was received on.
Changing the `SessionID` header while routing does not change where the
reply goes. To send a message to another session, use a QuickFIX/J
@@ -311,7 +329,8 @@ matches our request. The SenderCompID and TargetCompID of
the session ID are
matched as given against the header of the reply, so use the session ID as
the __replier__ sees it: for a request sent on `FIX.4.2:TRADER->MARKET`, use
`FIX.4.2:MARKET->TRADER`. The correlation criteria is required for an InOut
-exchange.
+exchange: without it, the exchange fails with an `IllegalArgumentException`
before the message is sent. Use the
+`InOnly` exchange pattern to send a message without waiting for a reply.
._Java-only: programmatic correlation criteria setup using QuickFIX/J API_
[source,java]
diff --git
a/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/MessageCorrelator.java
b/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/MessageCorrelator.java
index 8b6a9bcc276d..cca3b749d309 100644
---
a/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/MessageCorrelator.java
+++
b/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/MessageCorrelator.java
@@ -34,6 +34,13 @@ public class MessageCorrelator implements
QuickfixjEventListener {
public Callable<Message> getReply(Exchange exchange) {
MessagePredicate messageCriteria = (MessagePredicate)
exchange.getProperty(QuickfixjProducer.CORRELATION_CRITERIA_KEY);
+ if (messageCriteria == null) {
+ // without criteria no message can be matched as the reply
+ throw new IllegalArgumentException(
+ "An InOut exchange needs the " +
QuickfixjProducer.CORRELATION_CRITERIA_KEY
+ + " exchange property (a
MessagePredicate) to match the reply."
+ + " Set the property, or send
the message with the InOnly exchange pattern.");
+ }
final MessageCorrelationRule correlationRule = new
MessageCorrelationRule(exchange, messageCriteria);
rules.add(correlationRule);
diff --git
a/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/QuickfixjConsumer.java
b/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/QuickfixjConsumer.java
index 7f60ad11da4c..8056a2bb2414 100644
---
a/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/QuickfixjConsumer.java
+++
b/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/QuickfixjConsumer.java
@@ -18,6 +18,7 @@ package org.apache.camel.component.quickfixj;
import org.apache.camel.Endpoint;
import org.apache.camel.Exchange;
+import org.apache.camel.InvalidPayloadException;
import org.apache.camel.Message;
import org.apache.camel.Processor;
import org.apache.camel.support.DefaultConsumer;
@@ -59,10 +60,16 @@ public class QuickfixjConsumer extends DefaultConsumer {
// the reply goes back on the session the request arrived on,
so capture it before routing
// can change the header
SessionID messageSessionID =
exchange.getIn().getHeader(QuickfixjEndpoint.SESSION_ID_KEY, SessionID.class);
+ // only an application message received is a request; the
other events (such as the Logon being sent)
+ // must never be answered
+ QuickfixjEventCategory category
+ =
exchange.getIn().getHeader(QuickfixjEndpoint.EVENT_CATEGORY_KEY,
QuickfixjEventCategory.class);
+ boolean replyExpected
+ = exchange.getPattern().isOutCapable() && category ==
QuickfixjEventCategory.AppMessageReceived;
getProcessor().process(exchange);
- if (exchange.getPattern().isOutCapable() && exchange.hasOut())
{
+ if (replyExpected && !exchange.isFailed()) {
sendOutMessage(exchange, messageSessionID);
}
} catch (Exception e) {
@@ -73,7 +80,22 @@ public class QuickfixjConsumer extends DefaultConsumer {
private void sendOutMessage(Exchange exchange, SessionID messageSessionID)
throws QFJException {
Message camelMessage = exchange.getMessage();
- quickfix.Message quickfixjMessage =
camelMessage.getBody(quickfix.Message.class);
+ // the reply is the body the route ends with, which can be the
received message changed in place
+ if (camelMessage.getBody() == null) {
+ // a null body is how a route says there is no reply
+ LOG.debug("No FIX message reply to send for exchange: {}",
exchange.getExchangeId());
+ return;
+ }
+
+ quickfix.Message quickfixjMessage;
+ try {
+ quickfixjMessage =
camelMessage.getMandatoryBody(quickfix.Message.class);
+ } catch (InvalidPayloadException e) {
+ // the request has been routed, so it must not fail inside the
QuickFIX/J callback, where the engine would
+ // ask the counterparty to resend it and route it again, over and
over
+ getExceptionHandler().handleException("Cannot send the reply, as
it is not a FIX message", exchange, e);
+ return;
+ }
LOG.debug("Sending FIX message reply: {}", quickfixjMessage);
diff --git
a/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/QuickfixjEndpoint.java
b/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/QuickfixjEndpoint.java
index a15f79350ba6..f2def89a47d7 100644
---
a/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/QuickfixjEndpoint.java
+++
b/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/QuickfixjEndpoint.java
@@ -57,8 +57,12 @@ import quickfix.SessionID;
consumerReply = @BodyType(value = { Carrier.POJO, Carrier.TEXT,
Carrier.BYTES },
description = "With
exchangePattern=InOut, the reply sent back on the session of the"
+ " request: a
quickfix.Message, or the FIX message as text or bytes."
- + " Every event is answered
with the body the route ends with: a route"
- + " that sets no reply
sends back the message it received"),
+ + " Only an application
message received (AppMessageReceived) is"
+ + " answered, with the body
the route ends with: a new message or the"
+ + " received message
changed in place. A route that does not change"
+ + " the body sends back the
message it received, and a null body"
+ + " sends no reply. A reply
that is not a FIX message, such as a Map,"
+ + " goes to the exception
handler"),
producerIn = @BodyType(value = { Carrier.POJO, Carrier.TEXT,
Carrier.BYTES },
description = "The FIX message to send: a
quickfix.Message, or the FIX message as text or"
+ " bytes, parsed with the
DataDictionary exchange property or the data"
@@ -66,7 +70,8 @@ import quickfix.SessionID;
producerOut = @BodyType(value = { Carrier.UNCHANGED, Carrier.POJO },
description = "The body is left as it was.
An InOut exchange waits for the reply that"
+ " matches the
CorrelationCriteria exchange property, and the reply"
- + " quickfix.Message is the
body"))
+ + " quickfix.Message is the
body. An InOut exchange without the property"
+ + " fails before the message
is sent"))
public class QuickfixjEndpoint extends DefaultEndpoint implements
QuickfixjEventListener, MultipleConsumersSupport {
@Metadata(description = "The event category.", javaType =
"org.apache.camel.component.quickfixj.QuickfixjEventCategory")
public static final String EVENT_CATEGORY_KEY = "EventCategory";
diff --git
a/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/QuickfixjProducer.java
b/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/QuickfixjProducer.java
index 7392e440c5f2..4caace8fee96 100644
---
a/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/QuickfixjProducer.java
+++
b/components/camel-quickfix/src/main/java/org/apache/camel/component/quickfixj/QuickfixjProducer.java
@@ -66,7 +66,7 @@ public class QuickfixjProducer extends DefaultProducer {
}
void sendMessage(Exchange exchange, org.apache.camel.Message camelMessage)
throws Exception {
- Message message = camelMessage.getBody(Message.class);
+ Message message = camelMessage.getMandatoryBody(Message.class);
LOG.debug("Sending FIX message: {}", message);
SessionID messageSessionID = getEndpoint().getSID();
diff --git
a/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjConsumerTest.java
b/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjConsumerTest.java
index e072018e11f4..6c45426c2322 100644
---
a/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjConsumerTest.java
+++
b/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjConsumerTest.java
@@ -16,17 +16,23 @@
*/
package org.apache.camel.component.quickfixj;
+import java.util.Map;
+
import org.apache.camel.Exchange;
import org.apache.camel.ExchangePattern;
+import org.apache.camel.InvalidPayloadException;
import org.apache.camel.Processor;
import org.apache.camel.component.quickfixj.converter.QuickfixjConverters;
+import org.apache.camel.spi.ExceptionHandler;
import org.apache.camel.test.junit6.CamelTestSupport;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import quickfix.Message;
import quickfix.Session;
import quickfix.SessionID;
+import quickfix.field.Subject;
+import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.hamcrest.MatcherAssert.assertThat;
@@ -62,6 +68,67 @@ public class QuickfixjConsumerTest extends CamelTestSupport {
Mockito.verify(consumer, Mockito.never()).getSession(otherSessionID);
}
+ @Test
+ public void otherEventsAreNotAnswered() throws Exception {
+ Message reply = new Message();
+ Session requestSession = Mockito.mock(Session.class);
+
+ QuickfixjConsumer consumer = startConsumer(replyWith(reply, null),
requestSession);
+ for (QuickfixjEventCategory category :
QuickfixjEventCategory.values()) {
+ if (category != QuickfixjEventCategory.AppMessageReceived) {
+ Exchange exchange = receive(consumer, category, new Message());
+ assertThat(exchange.getException(), nullValue());
+ }
+ }
+
+ Mockito.verify(requestSession, Mockito.never()).send(Mockito.any());
+ }
+
+ @Test
+ public void receivedMessageChangedInPlaceIsSentBack() throws Exception {
+ Message request = new Message();
+ Session requestSession = Mockito.mock(Session.class);
+ Mockito.when(requestSession.send(request)).thenReturn(true);
+
+ // the route changes the received message and keeps it as the body
+ QuickfixjConsumer consumer = startConsumer(
+ exchange ->
exchange.getMessage().getBody(Message.class).setString(Subject.FIELD,
"changed"),
+ requestSession);
+ Exchange exchange = receive(consumer,
QuickfixjEventCategory.AppMessageReceived, request);
+
+ assertThat(exchange.getException(), nullValue());
+ Mockito.verify(requestSession).send(request);
+ assertThat(request.getString(Subject.FIELD), is("changed"));
+ }
+
+ @Test
+ public void nullReplyIsNotSent() throws Exception {
+ Session requestSession = Mockito.mock(Session.class);
+
+ QuickfixjConsumer consumer = startConsumer(exchange ->
exchange.getMessage().setBody(null), requestSession);
+ Exchange exchange = receive(consumer);
+
+ assertThat(exchange.getException(), nullValue());
+ Mockito.verify(requestSession, Mockito.never()).send(Mockito.any());
+ }
+
+ @Test
+ public void replyThatIsNotAFixMessageGoesToTheExceptionHandler() throws
Exception {
+ Session requestSession = Mockito.mock(Session.class);
+ ExceptionHandler exceptionHandler =
Mockito.mock(ExceptionHandler.class);
+
+ QuickfixjConsumer consumer = startConsumer(exchange ->
exchange.getMessage().setBody(Map.of("Subject", "reply")),
+ requestSession);
+ consumer.setExceptionHandler(exceptionHandler);
+ Exchange exchange = receive(consumer);
+
+ // the exchange must not fail, or the engine would not take the
request as received and route it again
+ assertThat(exchange.getException(), nullValue());
+ Mockito.verify(requestSession, Mockito.never()).send(Mockito.any());
+ Mockito.verify(exceptionHandler).handleException(Mockito.anyString(),
Mockito.eq(exchange),
+ Mockito.isA(InvalidPayloadException.class));
+ }
+
private QuickfixjConsumer startConsumer(Processor processor, Session
requestSession) throws Exception {
QuickfixjEndpoint endpoint = Mockito.mock(QuickfixjEndpoint.class);
Mockito.when(endpoint.getCamelContext()).thenReturn(context);
@@ -73,8 +140,12 @@ public class QuickfixjConsumerTest extends CamelTestSupport
{
}
private Exchange receive(QuickfixjConsumer consumer) {
- Exchange exchange = QuickfixjConverters.toExchange(consumer,
requestSessionID, new Message(),
- QuickfixjEventCategory.AppMessageReceived,
ExchangePattern.InOut);
+ return receive(consumer, QuickfixjEventCategory.AppMessageReceived,
new Message());
+ }
+
+ private Exchange receive(QuickfixjConsumer consumer,
QuickfixjEventCategory category, Message message) {
+ Exchange exchange = QuickfixjConverters.toExchange(consumer,
requestSessionID, message, category,
+ ExchangePattern.InOut);
consumer.onExchange(exchange);
return exchange;
}
diff --git
a/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjInOutTest.java
b/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjInOutTest.java
new file mode 100644
index 000000000000..2b5fcf6f533a
--- /dev/null
+++
b/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjInOutTest.java
@@ -0,0 +1,236 @@
+/*
+ * 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.quickfixj;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ThreadLocalRandom;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.ExchangePattern;
+import org.apache.camel.InvalidPayloadException;
+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 quickfix.Acceptor;
+import quickfix.FieldNotFound;
+import quickfix.FixVersions;
+import quickfix.Initiator;
+import quickfix.Session;
+import quickfix.SessionFactory;
+import quickfix.SessionID;
+import quickfix.SessionSettings;
+import quickfix.field.EmailThreadID;
+import quickfix.field.EmailType;
+import quickfix.field.MsgType;
+import quickfix.field.Subject;
+import quickfix.fix44.Email;
+import quickfix.mina.ProtocolFactory;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * InOut exchanges between an in-process acceptor and initiator: the consumer
replies only to the application messages
+ * it is asked to answer, and the producer gets the reply that matches its
correlation criteria.
+ */
+class QuickfixjInOutTest extends CamelTestSupport {
+
+ private static final SessionID ACCEPTOR = new
SessionID(FixVersions.BEGINSTRING_FIX44, "ACCEPTOR", "INITIATOR");
+ private static final SessionID INITIATOR = new
SessionID(FixVersions.BEGINSTRING_FIX44, "INITIATOR", "ACCEPTOR");
+ private static final String CONSUMER = "quickfix:inout?sessionID=" +
ACCEPTOR + "&exchangePattern=InOut";
+ private static final String PRODUCER = "quickfix:inout?sessionID=" +
INITIATOR;
+
+ @Override
+ protected CamelContext createCamelContext() throws Exception {
+ CamelContext context = super.createCamelContext();
+ // the values are text, as in a settings file; the vm pipe "port" is a
name inside the JVM, not a socket
+ long port = ThreadLocalRandom.current().nextInt(20000, 30000);
+
+ Map<Object, Object> defaults = new HashMap<>();
+ defaults.put(Acceptor.SETTING_SOCKET_ACCEPT_PROTOCOL,
ProtocolFactory.getTypeString(ProtocolFactory.VM_PIPE));
+ defaults.put(Initiator.SETTING_SOCKET_CONNECT_PROTOCOL,
ProtocolFactory.getTypeString(ProtocolFactory.VM_PIPE));
+ defaults.put(Session.SETTING_USE_DATA_DICTIONARY, "N");
+ defaults.put(Session.SETTING_NON_STOP_SESSION, "Y");
+
+ QuickfixjConfiguration configuration = new QuickfixjConfiguration();
+ configuration.setDefaultSettings(defaults);
+ configuration.addSessionSetting(ACCEPTOR, session(ACCEPTOR,
SessionFactory.ACCEPTOR_CONNECTION_TYPE,
+ Acceptor.SETTING_SOCKET_ACCEPT_PORT, port));
+ configuration.addSessionSetting(INITIATOR, session(INITIATOR,
SessionFactory.INITIATOR_CONNECTION_TYPE,
+ Initiator.SETTING_SOCKET_CONNECT_PORT, port));
+
+ QuickfixjComponent component = new QuickfixjComponent();
+ component.setConfigurations(Map.of("inout", configuration));
+ context.addComponent("quickfix", component);
+ return context;
+ }
+
+ private static Map<Object, Object> session(SessionID id, String
connectionType, String portKey, long port) {
+ Map<Object, Object> settings = new HashMap<>();
+ settings.put(SessionSettings.BEGINSTRING, id.getBeginString());
+ settings.put(SessionSettings.SENDERCOMPID, id.getSenderCompID());
+ settings.put(SessionSettings.TARGETCOMPID, id.getTargetCompID());
+ settings.put(SessionFactory.SETTING_CONNECTION_TYPE, connectionType);
+ settings.put(portKey, String.valueOf(port));
+ settings.put(Initiator.SETTING_RECONNECT_INTERVAL, "1");
+ settings.put(Session.SETTING_HEARTBTINT, "30");
+ return settings;
+ }
+
+ @Test
+ void consumerRepliesOnlyToTheMessagesTheRouteAnswers() throws Exception {
+ awaitLogon();
+ MockEndpoint replies = getMockEndpoint("mock:replies");
+ replies.expectedBodiesReceived("re: ping");
+
+ // the route sets a null body for this one, so it must not be
answered; the session keeps the order, so a
+ // reply would arrive before the reply to the ping
+ template.sendBody(PRODUCER, email("no reply"));
+ template.sendBody(PRODUCER, email("ping"));
+
+ MockEndpoint.assertIsSatisfied(context);
+ }
+
+ @Test
+ void consumerRepliesWithTheReceivedMessageChangedInPlace() throws
Exception {
+ awaitLogon();
+ MockEndpoint replies = getMockEndpoint("mock:replies");
+ replies.expectedBodiesReceived("re: edit");
+
+ template.sendBody(PRODUCER, email("edit"));
+
+ MockEndpoint.assertIsSatisfied(context);
+ }
+
+ @Test
+ void consumerReplyThatIsNotAFixMessageIsNotSent() throws Exception {
+ awaitLogon();
+ MockEndpoint requests = getMockEndpoint("mock:requests");
+ requests.expectedBodiesReceived("map", "null", "ping");
+ MockEndpoint replies = getMockEndpoint("mock:replies");
+ replies.expectedBodiesReceived("re: ping");
+
+ template.sendBody(PRODUCER, email("map"));
+ template.sendBody(PRODUCER, email("null"));
+ template.sendBody(PRODUCER, email("ping"));
+
+ // each request is routed once: a reply that cannot be sent does not
make the engine ask for it again
+ MockEndpoint.assertIsSatisfied(context);
+ }
+
+ @Test
+ void producerGetsTheReplyThatMatchesTheCriteria() throws Exception {
+ awaitLogon();
+ Exchange exchange = template.send(PRODUCER, ExchangePattern.InOut, e
-> {
+ e.getIn().setBody(email("ping"));
+ // the reply is matched with the session ID as the replier sees it
+ e.setProperty(QuickfixjProducer.CORRELATION_CRITERIA_KEY,
+ new MessagePredicate(ACCEPTOR,
MsgType.EMAIL).withField(Subject.FIELD, "re: ping"));
+ e.setProperty(QuickfixjProducer.CORRELATION_TIMEOUT_KEY, 10000L);
+ });
+
+ assertNull(exchange.getException());
+ Email reply = assertInstanceOf(Email.class,
exchange.getMessage().getBody());
+ assertEquals("re: ping", reply.getSubject().getValue());
+ }
+
+ @Test
+ void producerWithoutCriteriaFailsBeforeSending() throws Exception {
+ awaitLogon();
+ Exchange exchange = template.send(PRODUCER, ExchangePattern.InOut, e
-> e.getIn().setBody(email("lost")));
+
+ IllegalArgumentException e =
assertInstanceOf(IllegalArgumentException.class, exchange.getException());
+
assertTrue(e.getMessage().contains(QuickfixjProducer.CORRELATION_CRITERIA_KEY),
e.getMessage());
+
+ // the request was not sent, and the session still works
+ MockEndpoint requests = getMockEndpoint("mock:requests");
+ requests.expectedBodiesReceived("ping");
+ template.sendBody(PRODUCER, email("ping"));
+ MockEndpoint.assertIsSatisfied(context);
+ }
+
+ @Test
+ void producerBodyThatIsNotAFixMessageNamesItsType() throws Exception {
+ awaitLogon();
+ Exchange exchange = template.send(PRODUCER, e ->
e.getIn().setBody(Map.of("Subject", "map")));
+
+ InvalidPayloadException e =
assertInstanceOf(InvalidPayloadException.class, exchange.getException());
+ assertTrue(e.getMessage().contains("quickfix.Message"),
e.getMessage());
+ }
+
+ private void awaitLogon() throws InterruptedException {
+ MockEndpoint logons = getMockEndpoint("mock:logon");
+ logons.expectedMinimumMessageCount(2);
+ logons.setResultWaitTime(10000);
+ logons.assertIsSatisfied();
+ logons.reset();
+ }
+
+ private static Email email(String subject) {
+ return new Email(new EmailThreadID("ID"), new
EmailType(EmailType.NEW), new Subject(subject));
+ }
+
+ private static String subject(Exchange exchange) throws FieldNotFound {
+ return exchange.getIn().getBody(Email.class).getSubject().getValue();
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ // the acceptor answers the pings; every event of the session
reaches the InOut route
+ from(CONSUMER).routeId("acceptor")
+ .choice()
+
.when(header(QuickfixjEndpoint.EVENT_CATEGORY_KEY).isEqualTo(QuickfixjEventCategory.SessionLogon))
+ .to("mock:logon")
+
.when(header(QuickfixjEndpoint.EVENT_CATEGORY_KEY).isEqualTo(QuickfixjEventCategory.AppMessageReceived))
+ .setVariable("request", body())
+ .process(e -> e.getMessage().setBody(subject(e)))
+ .to("mock:requests")
+ .process(e -> {
+ String subject =
e.getMessage().getBody(String.class);
+ switch (subject) {
+ case "ping" ->
e.getMessage().setBody(email("re: ping"));
+ case "map" ->
e.getMessage().setBody(Map.of("Subject", "re: map"));
+ // the received message changed in place is
the reply
+ case "edit" -> {
+ Email request = e.getVariable("request",
Email.class);
+ request.set(new Subject("re: edit"));
+ e.getMessage().setBody(request);
+ }
+ // a null body: no reply
+ default -> e.getMessage().setBody(null);
+ }
+ });
+ // the initiator, which sends the requests and receives the
replies
+ from(PRODUCER).routeId("initiator")
+ .choice()
+
.when(header(QuickfixjEndpoint.EVENT_CATEGORY_KEY).isEqualTo(QuickfixjEventCategory.SessionLogon))
+ .to("mock:logon")
+
.when(header(QuickfixjEndpoint.EVENT_CATEGORY_KEY).isEqualTo(QuickfixjEventCategory.AppMessageReceived))
+ .process(e -> e.getMessage().setBody(subject(e)))
+ .to("mock:replies");
+ }
+ };
+ }
+}
diff --git
a/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjProducerCorrelationCleanupTest.java
b/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjProducerCorrelationCleanupTest.java
index 16a4b1915b59..21ded8a0ac84 100644
---
a/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjProducerCorrelationCleanupTest.java
+++
b/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjProducerCorrelationCleanupTest.java
@@ -38,6 +38,7 @@ import quickfix.field.TargetCompID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.isA;
/**
@@ -115,6 +116,23 @@ class QuickfixjProducerCorrelationCleanupTest {
assertEquals(0, pendingRules(), "correlation rules left after a send
that threw");
}
+ @Test
+ void requestWithoutCorrelationCriteriaFailsBeforeItIsSent() throws
Exception {
+ Mockito.doReturn(true).when(session).send(isA(Message.class));
+
+ Exchange exchange = createInOutExchange(5000L);
+ exchange.removeProperty(QuickfixjProducer.CORRELATION_CRITERIA_KEY);
+ producer.process(exchange);
+
+ IllegalArgumentException e =
assertInstanceOf(IllegalArgumentException.class, exchange.getException());
+
assertTrue(e.getMessage().contains(QuickfixjProducer.CORRELATION_CRITERIA_KEY),
e.getMessage());
+ Mockito.verify(session, Mockito.never()).send(isA(Message.class));
+ assertEquals(0, pendingRules(), "correlation rules left after a
request without criteria");
+
+ // the engine events are still dispatched to the correlator without
failing
+
engine.getMessageCorrelator().onEvent(QuickfixjEventCategory.AppMessageReceived,
responseSessionID, fixMessage);
+ }
+
private Exchange createInOutExchange(long timeout) {
Exchange exchange = new DefaultExchange(context,
ExchangePattern.InOut);
exchange.getIn().setBody(fixMessage);
diff --git
a/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjProducerTest.java
b/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjProducerTest.java
index 9cd2f09bda2c..7028ea4b1aa2 100644
---
a/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjProducerTest.java
+++
b/components/camel-quickfix/src/test/java/org/apache/camel/component/quickfixj/QuickfixjProducerTest.java
@@ -68,7 +68,7 @@ public class QuickfixjProducerTest {
inboundFixMessage.getHeader().setString(TargetCompID.FIELD, "TARGET");
sessionID = MessageUtils.getSessionID(inboundFixMessage);
-
Mockito.when(mockCamelMessage.getBody(Message.class)).thenReturn(inboundFixMessage);
+
Mockito.when(mockCamelMessage.getMandatoryBody(Message.class)).thenReturn(inboundFixMessage);
Mockito.when(mockEndpoint.getSID()).thenReturn(sessionID);
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 91b130caf3d0..cebd7ff9196c 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -2543,6 +2543,24 @@ an `Integer`, `Long`, `Short`, `Float` or `Double` was
written in its binary for
now also written in the charset of the response `Content-Type`, as a `String`
is, rather than in the charset of the
exchange. To send the binary form of a number, convert the body to `byte[]`
first.
+=== camel-quickfix - an InOut consumer replies only to application messages,
and an InOut producer needs CorrelationCriteria
+
+A consumer with `exchangePattern=InOut` now sends a reply only for an
`AppMessageReceived` event, with the body the
+route ends with (a new FIX message, or the received message changed in place),
and a null body sends no reply. Before,
+every event was answered with the body the route ended with, so the
`AdminMessageSent` event of the Logon sent the Logon
+again until a `StackOverflowError`. A route that does not change the body
still sends the received message back, so
+set a null body for a message that must not be answered. A reply that is not a
FIX message (such as a `Map`) is no
+longer sent: the error goes to the exception handler of the consumer (logged
at WARN by default), and the request
+counts as received. Before, it failed inside QuickFIX/J, which then asked the
counterparty to resend the request over
+and over. No reply is sent when the exchange failed.
+
+A producer with an InOut exchange now fails before it sends the message when
the `CorrelationCriteria` exchange property
+is not set, with an `IllegalArgumentException` that names the property.
Before, the message was sent and the exchange
+failed with `ExchangeTimedOutException`, as no reply could be matched. Send
with `InOnly` when no reply is expected.
+
+A producer body that does not convert to a FIX message (such as a stream, a
`Map` or no body) now fails with an
+`InvalidPayloadException` that names the body type, instead of a
`NullPointerException`.
+
=== camel-report-maven-plugin - Java routes are read with the Java DSL parser
The `validate` and `route-coverage` goals read Java routes with the Java DSL
parser of camel-java-io instead of