Skip to content

JSON and XML

The message travelling through a flow is not JSON and not a string — it is a value model that covers JSON’s types and XML as a first-class citizen. This page is about what that means when you write expressions and templates.

The value model

Kind Notes
null
boolean
number Integer or floating point
string
array Ordered
object Keys keep insertion order, not alphabetical order
XML element Tag, namespace, attributes, children — all preserved in document order

Object key order is preserved on the way out, so a response comes back in the order your template wrote it. That matters when the receiving system is order-sensitive, or when a human is reading it.

An XML element is a distinct kind, not an object with conventions. It carries:

  • a tag name,
  • an optional namespace URI,
  • attributes, in document order,
  • children — elements and text — in document order.

Because it is a real node, serialising it back to XML is exact: no escaping mistakes, no lost attributes, no reordered elements.

Inbound JSON

A body with Content-Type: application/json becomes the message directly.

Terminal window
$ curl -X POST http://localhost:9090/flows/acme/orders/run \
-H "Authorization: Bearer $NEXUS_KEY" \
-H 'Content-Type: application/json' \
-d '{"orderId":"A-1","items":[{"sku":"X","qty":2}]}'
$.orderId → "A-1"
$.items[0].sku → "X"
length($.items) → 1

Inbound XML

A body with Content-Type: application/xml is parsed into an XML value. The two entry points differ, and the difference is easy to trip over.

Synchronous execution

The message is the XML element itself, root included.

Terminal window
$ curl -X POST http://localhost:9090/flows/acme/orders/run \
-H "Authorization: Bearer $NEXUS_KEY" \
-H 'Content-Type: application/xml' \
-d '<order id="A-1"><customer>Acme Ltd</customer></order>'

Field access on an XML value reads attributes and child elements, plus two special names:

Expression Reads
$.id The id attribute if there is one, otherwise the first child element named id
$.customer The child <customer> — its text, since it has no element children
$["$tag"] The tag name — "order"
$["$ns"] The namespace URI, or null

Dotted access chains, so $.Body.Order.Id walks three levels of a document. It matches local names, so a prefix in the message is not part of the expression: $.Body reads <soapenv:Body>. An element whose only content is text reads as that text; one with element children stays an element so the chain can continue.

For anything deeper than “the child right here”, use descendants(), which searches the whole subtree by local name and returns every match:

{{ string(descendants($, "OrderId")[0]) }} the text of the first match
{{ length(descendants($, "Item")) }} how many there are
{{ for x in descendants($, "Item") }}{{ $ }}{{ end }} copy each one whole

One trap, which produces plausible output rather than an error: descendants() returns elements, so interpolating one directly emits the element and not its text — wrap it in string() when you meant the value. See the function reference.

Inside {{ for x in … }} you can use either $ or x for the current element; they are the same value, and a name that is not bound is refused at publish time.

For a document whose fields you want as ordinary object fields instead, the queued path below does that flattening for you.

Queued submission

For /enqueue, the XML is flattened before it is stored: child elements become object keys and the root tag is dropped.

Terminal window
$ curl -X POST http://localhost:9090/flows/acme/orders/enqueue \
-H "Authorization: Bearer $NEXUS_KEY" \
-H 'Content-Type: application/xml' \
-d '<order><orderId>A-1</orderId><customer>Acme Ltd</customer></order>'
$.orderId → "A-1"
$.customer → "Acme Ltd"

An element with no child elements becomes its text content. Attributes are not carried over by this flattening, so a document that puts data in attributes needs the synchronous path or a transformation step.

The practical consequence: a flow written against /enqueue with XML will not read the same way on /run. Decide which entry point a flow serves and write its expressions for that.

application/soap+xml and text/xml are handled by the SOAP path — see SOAP.

Outbound encoding

content_type on an http_egress step selects the encoding:

content_type Encoding
application/json (default) Serialised as JSON
contains xml An XML value is serialised as XML; a string is sent verbatim; anything else is refused
contains text Stringified — a scalar renders as text, an XML element as its text
anything else Serialised as JSON

XML and text/* are deliberately not the same rule. XML has a shape and the value either has it or does not, so an object under application/xml is an error naming the step, not a body silently serialised as JSON under a header that promises XML — that only moves the failure to the far end, where it arrives as someone else’s parse error. text/* has no shape to violate, so it keeps its tolerance.

A string is taken at its word in the XML case: either it is already markup, or it is text the flow means to send verbatim. Neither invents structure.

So sending XML is a matter of producing an XML value and saying so:

## Step: build
```ntd
<order id="{{ $.orderId }}">
<customer>{{ $.customer.name }}</customer>
</order>
```
## Step: send
effects: [http_egress]
endpoint: https://orders.example.com/ingest
method: POST
content_type: application/xml

If the step produces a string rather than an XML value with an XML content type, the string is sent as-is. That is a useful escape hatch and a common accident — see the normalisation rules in Transforming data, because a template with a punctuation error yields a string.

Answering with XML

The same rule applies to what the caller gets back. A flow whose last step builds an element answers /run with that document and Content-Type: application/xml; charset=utf-8:

## Step: normalise
```ntd
<Result>
<Status>FOUND</Status>
<Count>{{ length(descendants($, "Item")) }}</Count>
</Result>
```
Terminal window
$ curl -s -X POST http://localhost:9090/flows/acme/lookup/run \
-H "Authorization: Bearer $NEXUS_KEY" -d '{"id":"A-1"}'
<Result>
<Status>FOUND</Status>
<Count>3</Count>
</Result>

The whitespace between elements is the template’s own — the serialiser does not reformat, so indentation you write is indentation the caller receives.

The body is the output, unwrapped, whatever its form — a flow that builds an object answers with that JSON and application/json. So the shape is decided by the last step, success is the status code, and a client calling a mix of flows branches on the response Content-Type. Full table in The response body.

Constructing XML

Write elements literally in an ntd fence. Attributes interpolate, children may be loops:

```ntd
<order id="{{ $.orderId }}" xmlns="urn:acme:orders">
<customer>{{ $.customer.name }}</customer>
<total currency="{{ $.currency }}">{{ $.total }}</total>
{{ for item in $.items }}
<line sku="{{ item.sku }}" qty="{{ item.qty }}"/>
{{ end }}
</order>
```

Keep the template purely XML. A template that mixes an element with surrounding text produces an array of segments rather than a document — put nothing outside the root element.

Converting between the two

XML in, JSON out — read what you need and build an object:

## Step: to-json
```ntd
{
"orderId": "{{ $.id }}",
"tag": "{{ $["$tag"] }}"
}
```

JSON in, XML out — build the document, then send it with an XML content type, as above.

For anything more elaborate on the XML side, reach for ntd with descendants() first. An xslt step is an option, but only a subset of the language is compiled, and a sheet outside that subset is refused by nexus validate and nexus deploy, naming the construct. See Transforming data for exactly which constructs compile.