Skip to content

Transforming data

The ntd fence reshapes the message. It is a text template: everything inside it is literal text except the constructs listed below. That single fact explains most of its behaviour — a template that looks like JSON produces JSON because the text you wrote happens to be valid JSON, not because the fence understands JSON.

## Step: build-response
```ntd
{
"orderId": "{{ $.orderId }}",
"total": {{ $.total }},
"receivedAt": "{{ date_format(now()) }}"
}
```

Note where the quotes are. "{{ $.orderId }}" produces a JSON string; {{ $.total }} without quotes produces a JSON number. You are writing the punctuation yourself.

Interpolation

{{ expression }} evaluates and inserts the result. Any expression works — see Expressions.

{{ $.customer.name }}
{{ upper($.code) }}
{{ length($.items) }}
{{ $.qty > 10 ? "bulk" : "single" }}

A field the input does not have counts as null. Where other text of the template surrounds it, null is written as the four characters null: "name": "{{ $.missing }}" yields "name": "null", and Hello {{ $.missing }} yields Hello null. A template that is only the {{ }} yields null itself, which leaves as an empty body. To send the JSON value null for a missing field, put the {{ }} outside quotes: "name": {{ $.missing }} yields "name": null.

Values in a JSON template

When the text of the template is a JSON document — it opens with { or [ and closes with } or ] — what a {{ }} writes depends on where it stands in the text:

  • Between quotes, as a value, inside a longer string or as a key ("{{ x }}", "id-{{ x }}", {"{{ k }}": 1}), it is always one string, whatever the type of the value. A string is escaped: a quote, a backslash, a tab, a newline or any other control character becomes its JSON escape. An object or an array is written as its JSON text, escaped the same way, so {"a":1} arrives as "{\"a\":1}". An XML node is written as its text, escaped. A number, a boolean and null are written as their JSON token (1.5, true, null), inside the string.
  • Outside quotes ({"a": {{ x }}}, [{{ x }}]), a number, a boolean, null, an object or an array is written as the JSON it is. A string is written as one JSON string, with its quotes, escaped the same way: "1, 2, 3" in [{{ x }}] gives ["1, 2, 3"], one element, and "5" stays the string "5". To write the number a string holds, convert it: {{ number(x) }}.

Either way a value cannot close a string, add a key of its own or turn into an array or an object, and the document stays JSON.

Text between quotes is text, markup included: a < inside a string is a character like any other, so {"h": "<p>hi</p>"} answers {"h":"<p>hi</p>"} and {"note": "a<b"} answers {"note":"a<b"}. Outside quotes, a < followed by a letter starts an XML element, as in any template ({"a": <b>x</b>}), and the template is then no longer read as a JSON document.

{
"user": "{{ ctx.headers['x-user'] }}",
"role": "guest"
}

A header x-user: ","role":"admin answers {"user":"\",\"role\":\"admin","role":"guest"}: one string, and the role is still guest.

To splice a fragment of JSON that a string holds, mark the interpolation with raw:

{
"order": {{ $.orderJson | raw }},
"note": "{{ $.note }}"
}

raw means the string is JSON you wrote or fetched, so it is written as it is. Never put a value a caller sent behind raw. Write the parentheses when the expression is longer than a path or a call: {{ (a + b) | raw }}. Without raw, a string in a place where JSON expects a value ("order": {{ $.orderJson }}) is written as one JSON string, and the answer stays JSON.

A JSON template whose text gives a {{ }} no single place is refused when the flow is validated or deployed, with an error that names the step and the expression:

  • the branches of an {{ if }} end in different places, one inside a string and the other outside (a missing else counts as a branch that changes nothing);
  • the body of a {{ for }} does not end where it started, so each pass would put the next one’s values in the other place;
  • inside a string, the text right before a {{ }} ends on a single \, which would escape the value’s first character. Write \\ for a backslash.

Balance the quotes the same way in every branch and inside every loop body. A < followed by a letter after such an {{ if }} or {{ for }} cannot be placed inside or outside a string either: the template is refused, with an error that points at the <.

Only a template that reads as a JSON document is escaped: a plain-text template, an XML template and the text of an attribute are written as they are. A template that starts or ends with a {{ }} is not recognised as a document; there, write json_escape(value) around a value outside quotes and json_escape(value, true) around a value between quotes.

Conditionals

{{ if <expression> }} ... {{ else }} ... {{ end }}

The else branch is optional. The condition must evaluate to a boolean; anything else is a step error.

```ntd
{
"id": "{{ $.id }}"
{{ if !is_empty($.note) }},
"note": "{{ $.note }}"
{{ end }}
}
```

Mind the commas. Because the fence is text, a conditional block that omits a comma produces invalid JSON, and the result comes back as a string instead of an object. Put the comma inside the conditional, as above.

Loops

{{ for <name> in <expression> }} ... {{ end }}

The current element is reachable two ways: by the name you declared, and as $. They are the same value. A name that is not bound by an enclosing loop is a publish-time error that names what is in scope — it does not quietly become the text of the name.

```ntd
<Order id="{{ $.orderId }}">
{{ for item in $.items }}<Line sku="{{ item.sku }}">{{ item.qty }}</Line>{{ end }}
</Order>
```

A loop works the same way in a JSON template:

```ntd
{
"count": {{ length($.items) }},
"skus": [{{ for item in $.items }}"{{ item.sku }}",{{ end }}""]
}
```

There is no separator construct and no index. The trailing "" above absorbs the last comma — crude, but it keeps the output valid. When the shape matters more than the terseness, build the array in a foreach step instead; see Composing flows.

Constructing XML

An element written literally becomes a real XML node, not text. Attribute values interpolate. In a template that reads as a JSON document, a < between quotes is text, not an element; see Values in a JSON template.

```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>
```

The result is an XML value that keeps element order, attributes and text nodes. Send it with content_type: application/xml and it is serialised properly — no string assembly, no escaping mistakes. See JSON and XML.

If such a step is the flow’s last one, that value is also the flow’s answer: /run returns the document itself, with Content-Type: application/xml; charset=utf-8. Build an object in the last step instead and the caller gets that JSON, with application/json. Either way the body is the output, unwrapped, and success is the status code — see The response body and Answering with XML.

What the fence returns

A template produces a sequence of segments — literal text, interpolated values, elements — and those are reduced to one value by fixed rules. Knowing them saves a lot of confusion.

First, a template with no {{ }} and no elements at all — wholly literal text — is read as a document: parsed as JSON if it is valid JSON, and taken verbatim as a string otherwise. Its whitespace is content, so none of it is discarded.

Every other template is a sequence of segments. Whitespace-only text segments are discarded first — there they separate interpolations rather than carry meaning. Then:

Meaningful segments Result
none null
exactly one that value, unwrapped
several, any of them XML an array of the segments
several, none XML joined into one string, then parsed as JSON; if it parses, that value, otherwise the string

The first rule was added after the defect it prevents was measured: a template forming valid JSON but containing no interpolation used to return its text, so the caller got the business answer as a string — at the time, escaped inside the response envelope that 1.0 removed. No error anywhere, a plausible output instead of a refusal. Adding a {{ }} no longer changes the type of the output.

The decision is made on the template’s operation, not on the shape of the value, which is why \n{{ $.x }}\n still lands on “exactly one segment” and the string "42" stays a string there instead of becoming the number 42.

Three further consequences worth internalising.

A template that produces valid JSON gives you an object or array. That is the JSON case in the last row — the join happens first, the parse second.

A template that produces slightly invalid JSON gives you a string. A missing comma or a trailing one does not raise an error; you get the whole thing as text, and the next step sees a string where it expected an object. If a downstream expression suddenly fails with “field access on non-object value”, check the template’s punctuation first.

A single interpolation is passed through unwrapped, keeping its type:

```ntd
{{ $.items }}
```

That yields the array itself, not a stringified copy. It is the idiomatic way to narrow the message to one of its parts.

{{ if }} and {{ for }} contribute their own segments to the surrounding template, rather than one segment holding a list. So { "a": {{ if $.flag }}1{{ else }}2{{ end }} } joins to { "a": 1 } and parses as an object, and a loop’s body lands inline between whatever precedes and follows it. An array that is a value — {{ $.tags }} — is not touched: it is still one segment holding ["a","b"], which is what makes it usable inside a JSON template.

Mixing XML and text gives you an array of segments rather than a document, so keep an XML template purely XML — put nothing outside the root element.

Reformatting with XSLT

The xslt fence applies a stylesheet to an XML message.

## Step: reshape
```xslt
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<summary>
<id><xsl:value-of select="/order/@id"/></id>
<customer><xsl:value-of select="/order/customer"/></customer>
</summary>
</xsl:template>
</xsl:stylesheet>
```

A subset of XSLT 1.0 and 2.0 is compiled at build time into the same operation tree the ntd fence uses, which is why it executes with no XSLT engine at run time.

Compiled and executed: literal result elements, xsl:value-of, xsl:copy-of, xsl:if, xsl:choose with xsl:when and xsl:otherwise, xsl:for-each, and attribute value templates. The identity-with-pruning idiom — a single template using xsl:if test="normalize-space(...)!=''" around xsl:copy and xsl:apply-templates — is recognised and compiled to an empty-element-pruning operation.

Not compiled, and refused: xsl:apply-templates in any other arrangement, xsl:call-template, xsl:variable, named templates, and multi-template stylesheets in general. A sheet using any of those is rejected by nexus validate and nexus deploy, with the offending construct named:

Terminal window
$ nexus validate flows/reshape.flow.md
flows/reshape.flow.md:16:1: error: step 'reshape': this XSLT sheet is outside the subset this platform compiles, and there is
no XSLT engine at run time — unsupported XSLT construct <xsl:call-template>…
error: 1 validation error(s)

It used to deploy cleanly and fail at the first real message instead. Whether a sheet lowers is a property of the sheet, so there is no reason for you to find out in production.

For anything the subset does not cover, express the transformation with ntd instead. It reaches the same result: descendants() for //name searches, string() for an element’s text, {{ if }} and {{ for }} for xsl:choose and xsl:for-each.