6.3 JSON templates (structural, injection-safe substitution)

Most query languages you'll target — the reference adapter, Mongo aggregates, Elastic DSL — are JSON. RS2 substitutes parameters into a JSON query structurally, which makes it valid JSON at rest and injection-safe by construction.

Structural substitution

A string node that is exactly "${name}" is replaced by the parameter's JSON value — not by string interpolation. So a number stays a number, an array stays an array:

"where": { "total": { "op": ">=", "value": "${min}" } }

If min is 20, the result is { "op": ">=", "value": 20 } — the integer 20, not the string "20". Because the param's typed value is dropped in as a JSON node, there's no string concatenation anywhere, and therefore no way to inject query syntax through a parameter.

And because "${min}" is itself a valid JSON string, the template is valid JSON even before substitution — you can store, lint, and diff it like any JSON document.

Optional placeholders: "${name?}"

A trailing ? marks an optional placeholder. If the param is absent, the enclosing clause is elided — the object member or array element it sits in is removed entirely:

"where": { "status": "${status}", "name": "${name?}" }
  • With name supplied → { "status": "open", "name": "Ada" }.
  • With name absent → { "status": "open" } (the whole name member drops).

This generalizes the common "ignore empty filters" need into a language-agnostic rule: optional filters simply disappear when their input isn't given, so one stored query covers "filter by name" and "don't" without branching.

Placeholders inside longer strings

A placeholder embedded in a longer string ("name:${who}", or a positional "$0" spliced into text) is substituted as text, adapter-quoted — the adapter is responsible for escaping it for its own syntax. The injection-safe guarantee is strongest for the exact-match "${name}" form (structural); prefer it where you can.

Dynamic object keys

An object key can itself be an exact placeholder. The parameter must resolve to a string; an optional missing key removes the member:

{ "$sort": { "${sortField?}": "${sortDir?}" } }

This is useful for query languages such as Mongo's aggregation syntax, where a field name is part of the instruction rather than a value. A non-string key is rejected with 400 rather than being guessed.

Gating a whole clause with $if

Put "$if": "${flag?}" on an object to include or remove that whole object. It is removed when the flag is absent or empty (null, false, "", or []); otherwise $if is stripped and the remaining object is substituted normally:

[
  { "$if": "${filterByStatus?}",
    "$match": { "status": "${status}" } },
  { "$sort": { "created": -1 } }
]

That keeps optional pipeline stages declarative instead of forcing several near-identical stored queries.

Why this is the safe default

You author a JSON query, parameters arrive typed and validated (6.2), and they're dropped into the query as JSON nodes. There's no SQL-string-building step where an attacker could smuggle in syntax. For the cases that genuinely need string queries (real SQL with bind parameters), the next section explains how those are handled differently — and just as safely.


Previous: 6.2 Parameters · Manual home · Next: 6.4 String/SQL templates →