gnodet-bot commented on code in PR #28457:
URL: https://github.com/apache/camel/pull/28457#discussion_r4377790903
##########
design/payload-mode.adoc:
##########
@@ -0,0 +1,490 @@
+---
+title: Payload mode
+authors:
+ - "@davsclaus"
+reviewers: []
+approvers: []
+creation-date: 2026-10-25
+last-updated: 2026-10-25
+status: proposed
+see-also:
+ - "CAMEL-25334"
+ - "CAMEL-24844"
+ - "CAMEL-25330"
+ - "CAMEL-25329"
+ - "CAMEL-25746"
+ - "CAMEL-25749"
+replaces: []
+superseded-by: []
+---
+
+== Summary
+
+A route carries the body as the Java type that each component and each step
happens to produce: a `GenericFile`, a
+`byte[]`, an `InputStream`, a `Map`, a `String`. Users who think of the
payload as JSON (or CSV, or XML) write their
+mappings for that shape, and the gap between the two shows up as runtime
errors.
+
+This proposal adds an opt-in *payload mode*. With `payloadMode=json` the body
inside the route is JSON data (a
+`JsonObject` or a `JsonArray`), whatever the component or the step before
gave, and at the edges of the route it is
+the JSON text. The payload mode is an SPI: JSON is the first plugin, and other
text and binary formats (CSV, XML, CBOR,
+Avro, ...) can follow as plugins.
+
+The same route with both modes, as a visual route editor could show it with
the body types of the catalog:
+
+image::payload-mode-route-diagram.svg[The same five-step route twice. With
payloadMode default the body is a GenericFile, then a LinkedHashMap, then an
InputStream, and the groovy mapping, the http call and the header sent to JMS
are marked with warnings. With payloadMode json every arrow carries JSON data
(JsonObject), and the callouts say the file is parsed, the http call gets the
JSON text, and the header is sent as JSON text.]
+
+== Motivation
+
+Low-code users, and AI models writing routes, think of the payload as JSON and
write data mappings in Groovy and simple
+accordingly. Camel carries the native Java type of each component instead, and
that type changes during routing and
+from component to component. Examples:
+
+- Groovy `body.find { it.sku == headers.sku }` on JSON that was not
unmarshalled fails with
+ `No such property: sku for class: java.lang.Byte`.
+- simple `${body[amount]}` on the body of the file consumer fails with
+ `Key: amount not found in bean: GenericFile[...]`.
+- A mapping that returns a `Map`, sent to an http producer, fails: there is no
type converter from `LinkedHashMap` to
+ `InputStream`.
+- A jsonpath result stored in a header as a `LinkedList` disappears when the
message crosses a JMS broker.
+
+Raymond Meester described the same from the Fluxygen low-code platform built
on Camel, where users write Groovy and
+simple without knowing Java (Zulip thread "Text-orientated vs.
Object-orientated in integration"). In the local-model
+benchmark, 19 of 156 attempts in one series failed on such a body type
collision.
+
+The hints of CAMEL-25330, CAMEL-25746 and CAMEL-25749 now say what to do in
each of these cases ("unmarshal it
+first"), but nothing does it: a user still has to know which step gives which
Java type, and where to unmarshal and
+marshal.
+
+== Goals
+
+- A route can opt in to work on the payload as data of one format: JSON first.
+- Inside such a route, the body has one predictable shape: Groovy `body.sku`,
simple `${body.sku}` and
+ `${body[sku]}`, split and aggregate work on it without unmarshal and marshal
steps. Reading a few fields of the
+ payload needs no jsonpath, jq or JSON parsing code in Groovy: `body.sku`
reads better than their syntax.
+- At the edges (producers, header values, what users see in logs and tooling)
the payload is its text, or its bytes
+ for a binary format.
+- Formats are plugins of one SPI, so CSV, XML and binary formats can be added
without changing the engine.
+- Routes that do not opt in behave exactly as today.
+
+== Non-goals
+
+- Changing the default behaviour of Camel. The mode is opt-in, per
`CamelContext` and per route.
+- The lowest overhead. The mode parses and writes payloads that a hand-written
route might pass through untouched; it
+ is for routes where predictability matters more than raw speed.
+- Mapping arbitrary Java objects (SDK objects, entities) to JSON. They stay as
they are; a JSON writer SPI can come
+ later.
+- Schemas and validation of the payload.
+
+== Current design
+
+- The body is whatever the last step set. Consumers and producers document
their body types in the catalog
+ (CAMEL-24961), and the validator follows them through a route (CAMEL-24844,
CAMEL-25928), but at runtime nothing
+ normalizes the body.
+- Reading fields needs an `unmarshal` step first. When it is missing, the
Groovy language, the bean OGNL of simple,
+ jsonpath, jq and several producers say so in their error (`BodyFacts`,
CAMEL-25749).
+- Some JSON support already exists in core without Jackson: camel-util-json
(`JsonObject` extends `LinkedHashMap`,
+ `JsonArray` extends `ArrayList`, `Jsoner` parses and writes), type
converters from a `String`, `byte[]`,
+ `InputStream`, `File` and `WrappedFile` to `JsonObject` / `JsonArray` in
camel-support (`JsonConverter`; there are
+ none in the other direction), and the `${simpleJsonpath(...)}` function of
simple.
+- Stream caching is the precedent for an opt-in mode that changes the body: it
is enabled per `CamelContext` and per
+ route (`RuntimeConfiguration#setStreamCaching`, the `streamCache` option of
a route), and works through an advice
+ where the consumer hands the message to the route (`CamelInternalProcessor`)
and around every step
+ (`DefaultChannel`).
+
+== Proposal
+
+=== The option
+
+`payloadMode` is a new option of the `CamelContext` and of a route, like
stream caching:
+
+- `camel.main.payloadMode = json` for all the routes, and a route can set its
own `payloadMode` to opt in or out.
+- The value is the name of a payload mode plugin. `default` (the default)
means no payload mode: today's behaviour.
+- A route has one payload mode. The value is a single name, so a list
(`json,csv`) can be added later without
+ breaking it.
+- `payloadModeStrict` (on by default) makes a route in a payload mode accept
only payloads of its mode from the
+ consumer: the safety belt of the mode, so a body that is not what the route
says fails where it comes in, not three
+ steps later. `camel.main.payloadModeStrict = false` turns it off for all the
routes, and a route can set its own
+ `payloadModeStrict` (see the strict mode in the engine).
+- A route that does not set its own `payloadMode` follows the payload mode of
the exchange, so a utility route that
+ other routes call with `direct` acts as its caller (see the exchange's
payload mode in the engine).
+
+=== The SPI
+
+A payload mode is a plugin found by its name, like data formats and languages
+(`META-INF/services/org/apache/camel/payload-mode/<name>`), or bound in the
registry. It is marked preview in its first
+release, so the interface can still change when the second plugin arrives.
+
+[source,java]
+----
+public interface PayloadMode extends Service, CamelContextAware {
+
+ /** The name, such as json, csv, cbor. */
+ String getName();
+
+ /**
+ * The content types of the payloads of this mode, such as
application/cbor and application/x-cbor. The first is
+ * the one this mode writes.
+ */
+ List<String> getContentTypes();
+
+ /** Whether the value is data of this mode (for json: a JsonObject or a
JsonArray). */
+ boolean isData(Object value);
+
+ /** Whether the body is a payload of this mode, which toData turns into
data. */
+ boolean isPayload(Exchange exchange, Object body);
+
+ /**
+ * The payload as data of this mode. Only called when isPayload is true;
throws when the payload does not parse.
+ */
+ Object toData(Exchange exchange, Object body) throws Exception;
+
+ /** A copy of the data, so the branches of an EIP that copies the exchange
do not share it. */
+ Object copy(Object data);
+
+ /** The wire form of the data: a String for a text format, a byte[] for a
binary one. */
+ Object toPayload(Exchange exchange, Object data);
+
+ /** The data as readable text, for logs, tracing, tooling and header
values. */
+ String toDisplay(Exchange exchange, Object data);
+}
+----
+
+A payload mode is a service with the lifecycle of a data format: it is started
with the routes that use it and
+stopped with them, and it can have options (an Avro or Protobuf schema),
configured like a data format. A plugin with
+nothing to start, such as json, gets the lifecycle from the base class.
+
+A base class in camel-support holds the rule that is the same for every
format, so a plugin declares its content
+types and file extensions, and brings its parser, its writer and its copy:
+
+- `isPayload` reads the `Content-Type` header (and its `charset`), the file
name through `MimeTypeHelper` when there is
+ no `Content-Type`, and a sniff of the content that a plugin can override
(the first character for a text format,
+ magic bytes for a binary one)
+- the engine calls `toData` only when `isPayload` is true, so there is no "not
mine" result to check. When it throws,
+ the engine leaves the body as it is and logs at DEBUG. The only place the
mode fails a message is the strict check
+ where the message comes in (see the strict mode in the engine)
+- a stream that stream caching has spooled to disk is not a payload: the spool
threshold is the size limit, so the
+ mode has no size option of its own
+
+=== The engine
+
+The engine part does not depend on the format.
+
+*The exchange's payload mode.* Where a route consumer creates an exchange, the
payload mode of that route (its own, or
+the global one) is marked on the exchange, in the exchange extension (not a
property, so a route cannot remove it by
+accident). The copies of an exchange (`seda`, multicast, recipient list,
split, wire tap) carry the mark. A route then
+works under:
+
+- its own `payloadMode`, when it sets one. `payloadMode=default` opts a route
out, such as a route of Java helpers that
+ wants the Java types of the components;
+- otherwise the mode of the exchange, so a utility route that several routes
call with `direct` acts as its caller:
+ in `json` mode for a json route, and as today for a route without a mode;
+- otherwise the global mode, for an exchange that no route consumer created,
such as one sent with a
+ `ProducerTemplate`.
+
+When the exchange comes back from a route with a mode of its own, the caller's
mode applies again after the step that
+called it. The type converters at the edges and the JMS binding read the mode
from the exchange too.
+
+*Which bodies become data.* Where the consumer hands the message to the route,
and after every step, the body is
+given to the route's mode (where stream caching works today): when it `isData`
already it is left alone, and when it
+`isPayload` it is turned into data with `toData`. So one rule covers the body
of a consumer, the reply of a producer and the result of
+any step, including a language, `process`, `bean`, split and aggregate, and a
route that stays in its data costs a
+type check per step.
+
+The steps of the error paths are steps too: `onException`, `doTry` / `doCatch`
/ `doFinally` and the error handler
+have a channel like any other step, so the hook runs there as well. A body
that is not a payload, such as an exception
+or an error text, is left as it is.
+
+*A stale `Content-Type`.* The `Content-Type` header that came with a payload
stays on the message when a step turns
+the payload into another format, such as JSON that a `marshal` writes as XML,
or an xslt. The data formats set the
+header of what they write (`contentTypeHeader`, on by default), but a language
or an xslt step does not. So after a
+step that changed the body, a body that does not parse although the header
names one of the mode's content types is
+not a broken payload but a stale header: the body is left as it is and the
header is removed (logged at DEBUG), so a
+producer does not send XML as `application/json`.
+
+*The strict mode.* A user who sets `payloadMode=json` says the payload is
JSON. Passing a CSV file or broken JSON on
+to the next step only moves the error to a confusing place later (a Groovy `No
such property` three steps on), which
+is the surprise the mode is there to remove. So `payloadModeStrict` is on by
default, and a route in `json` mode
+accepts only valid JSON from its consumer:
+
+- The check is where the exchange is created and marked with its payload mode:
the consumer that brings the message
+ into Camel. A route whose consumer is `direct` or `seda` is not checked
again, so a utility route that gets XML
+ from a json caller after a step that writes XML does not fail.
+- A body that is not a payload of the mode, or does not parse, fails the
message with an `InvalidPayloadException`,
+ whose hint says why: the parse error of invalid JSON, or the Java type and
`Content-Type` of a body that is not JSON
+ at all (a CSV file in a json route, an object of a component's SDK).
+- The check runs in the channel of the first step, inside the error handler,
so `onException` and the error handler
+ of the route handle it like any failed message, and an http consumer answers
with an error.
+- A null or empty body passes (a timer, a scheduler, an http `GET`, a Kafka
tombstone): there is no payload to check.
+ The rows of a database consumer are `Map` values that become JSON data, so
they pass too.
+- A JSON value that is not an object or an array (`123`, `"text"`, `true`) is
valid JSON, so it passes, and stays as
+ it is.
+- After a step nothing is checked: a body that is not data is the route's own
doing (a step that writes XML), and is
+ left as it is.
+
+A route whose input is not JSON, or not only JSON (a directory with JSON and
other files, a REST endpoint that also
+takes form posts, a SOAP route, PDF files, the objects of an SDK), is not a
json route: it sets `payloadMode=default`
+(later `text`), or `payloadModeStrict=false` to keep the mode and let other
bodies through. A file route that rejects
+a file needs `moveFailed` (or an `include` filter on `.json`), as for any
failure of the file consumer, or the file
+fails again on every poll; the user manual says so.
+
+*Copies of the exchange.* Multicast, recipient list, wire tap and split give
each branch a copy of the exchange, and
+the copies would share the same mutable data, so a branch that changes a field
would change it for the others. Where
+these EIPs copy a `StreamCache` body for each branch today
(`MulticastProcessor`, `WireTapProcessor`), they call
+`copy` of the payload mode for data, so each branch gets its own.
+
+*The edges.* Producers get the payload through the type converter, not by
replacing the body before them:
+
+- type converters give the payload when the target is a `String`, `byte[]` or
`InputStream`. For the json mode these
+ are the regular type converters of camel-support from `JsonObject` and
`JsonArray` to their JSON text (CAMEL-26324),
+ which act in every route, with or without a payload mode. A plugin whose
data type has no such converters adds a
+ fallback type converter that gives `toPayload` (and `toDisplay` for a
`String` from a binary format) when the value
+ is data of the exchange's payload mode. So http, file, kafka and the other
producers that read text, bytes or a
+ stream get the payload.
+- producers that take a `Map` as their API use the data: sql named parameters
(`:#sku`, `SqlHelper` converts the body
+ to a `Map`), mongodb documents.
+- producers that would turn a `Map` into something that is not the payload are
fixed to ask the payload mode first:
+ JMS and SJMS send a `Map` as a `MapMessage` (`JmsBinding`), and send data of
a payload mode as a `TextMessage`
+ (`BytesMessage` for a binary mode). Other producers of this kind are found
by a survey of the components.
+- before a remote producer (`Endpoint#isRemote()`), header values that are
data of the mode, a `Map` or a `List`
+ become their `toDisplay` text (not the `Camel*` headers, some of which are
maps on purpose), so they survive a JMS
+ or Kafka hop; and the `Content-Type` header is set to the first of the
mode's content types unless it is already
+ one of them.
+- local endpoints (`direct`, `seda`) pass the data as it is to the next route,
which works under its own mode or the
+ mode of the exchange (above).
+
+*What users see.* `MessageHelper` (the body in logs, tracing, the backlog
tracer and the dev consoles, so also Camel
+TUI and the MCP tools) shows `toDisplay` of data of a payload mode, so users
see the JSON text, not
+`{sku=CAMEL-MUG, qty=3}`.
+
+=== Type converters, data formats and body types
+
+A payload mode is a stage of its own, not a type converter, and it does not
replace the type converters or the data
+formats:
+
+- Inside the route the engine hook turns payloads into data, at the spots of
the stream caching advice. A type
+ converter only runs when a step asks for a type, so it cannot keep the body
as data from one step to the next; the
+ hook can.
+- At the edges the json mode needs no type converter of its own: the regular
type converters from `JsonObject` and
+ `JsonArray` to a `String`, `byte[]` or `InputStream` give the JSON text
(CAMEL-26324). A plugin whose data type has
+ no such converters adds a fallback type converter, which only acts on
exchanges of a route with a payload mode (see
+ the edges above). The other type converters are left as they are.
+- A conversion inside the route uses the type converters as today:
`convertBodyTo`, `${bodyAs(...)}` and the
+ parameter binding of a bean. Data of the json mode is a `Map` or a `List`,
so a conversion to `Map` gives it as it
+ is, and a conversion to `String` gives the JSON text through the converters
of CAMEL-26324 above (not the
+ `toString` of a `Map`); a conversion to another type goes through the type
converters that exist today.
+- The data formats do not change: `marshal` and `unmarshal` do what they do
today, and their result is handled like
+ the result of any other step (see what changes for route authors below).
+- The body types the catalog declares stay the Java types the components give.
The validator applies the payload mode
+ on top of them (see tooling below).
+
+=== The json plugin
+
+In camel-support, with camel-util-json, so it needs no new dependency.
+
+- Data: `JsonObject` (a `LinkedHashMap`, so the field order is kept) and
`JsonArray`; numbers are `BigDecimal`.
+- Payloads: a `String`, `byte[]`, stream or file whose `Content-Type` is
`application/json` or `*+json`, or with no
+ `Content-Type` whose file name ends with `.json` or whose text starts with
`{` or `[`; and a `Map` or a `List` that
+ converts as below. Any other body is left as it is.
+- `toData` parses the text, and converts a `Map` to a `JsonObject` and a
`List` (or an array) to a `JsonArray`, with
+ their nested values:
+** a `String`, `Number`, `Boolean` and null stay as they are
+** a nested `Map`, `List` or array is converted the same way
+** a `java.util.Date`, a `java.sql.Timestamp` and the `java.time` values
become their ISO-8601 text, so the rows of
+ sql (with their `Timestamp` columns) become JSON data
+** an enum becomes its name, a `UUID` its text, and a `byte[]` its base64 text
+** any other object (a POJO), or a reference back to a map or list that
contains it, is not JSON data: the whole
+ `Map` or `List` is then not a payload, and is left as it is
Review Comment:
❓ **Design ambiguity:** The SPI contract (line 137) says `toData` _"throws
when the payload does not parse"_, with no `"not mine"` result. But this
description reads as a silent skip — `toData` encounters a POJO and the `Map`
is _"left as it is"_, with no mention of an exception being thrown.
If `toData` does throw here (and the engine catches it), these are
consistent — but the document should say so explicitly. If `toData` can
silently return the original body instead of throwing, then the SPI contract
needs a `"not mine"` result defined.
--
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]