Templating
The Jinja template engine — syntax, contexts, contextual escaping, and the SQL parameters model.
Cannectors templates request fields with a Jinja engine (Jinja2 syntax). Every dynamic field — HTTP endpoints, headers, request bodies, SOAP envelopes, SQL queries, cache keys — goes through the same engine, with escaping matched to the field's format applied automatically.
output:
type: httpRequest
endpoint: https://api.example.com/customers/{{ record.customerId }}
method: POST
body: |
{
"email": "{{ record.email | lower }}",
"tier": "{{ record.tier | default('standard') }}"
{% if record.discount %},"discount": "{{ record.discount }}"{% endif %}
}Syntax
| Construct | Example |
|---|---|
| Output tag | {{ record.customer.id }} |
| Array indexing | {{ record.items[0].sku }} |
| Filter | {{ record.email | lower }} |
| Default value | {{ record.tier | default('standard') }} |
| Condition | {% if record.priority == "high" %}URGENT{% endif %} |
| Loop | {% for tag in record.tags %}<t>{{ tag }}</t>{% endfor %} |
| Comment | {# ignored #} |
Common filters: default(value), lower, upper, capitalize, trim,
join(sep), length, replace(old, new). The full set is the standard
Jinja builtin catalog.
Unsupported tags
Two families of Jinja tags are rejected at compile time — cannectors validate reports them with the field path:
| Tag | Why |
|---|---|
{% include %}, {% extends %}, {% import %}, {% from %} | A template pulled from disk would skip contextual escaping, and its path can be a record expression. Keep each template self-contained; external files go through bodyTemplateFile / queryFile, which are escaped normally. |
{% filter %} (block form) | It transforms the value after escaping, which corrupts the output (< becomes <). Use an inline filter instead: {{ value | upper }}. |
Template context
Top-level variables available inside templates:
| Variable | Contents | Available in |
|---|---|---|
record | The current record. | All templated fields. |
meta | The record's _metadata map. | All templated fields. |
state | Persisted incremental state: state.lastRunTimestamp (RFC3339, epoch on first run), state.lastRunId. | database input parameters. |
pagination | Current page values: pagination.offset, pagination.cursor, pagination.limit. | database input parameters (re-evaluated per page). |
How values are rendered
Substituted values keep the record's own representation, not Jinja's Python
flavour: 42 renders as 42 (not 42.0), and true renders as true (not
True). This holds on every target, so {{ record.id }} in a URL and the
{id} key placeholder always produce the same text, and a
number or boolean substituted into a JSON body stays valid JSON.
Contextual escaping
Substituted values are escaped for the field they land in — the literal text you write around them is never touched:
| Field | Escaping |
|---|---|
| HTTP / SOAP endpoint | URL query escaping (a b&c → a+b%26c). |
| JSON request body | JSON string escaping — a value containing " or a newline cannot break the payload. |
| SOAP body and headers | XML escaping (<, >, &, " become entities). |
| HTTP headers, cache keys | None (raw text). |
| SQL query | Not applicable — dynamic values go through bound parameters, see below. |
SQL: query + parameters
SQL queries never interpolate values into the text. The query is a Jinja
template that renders to SQL shape — with $1, $2, … placeholders —
and the parameters list supplies the values, bound by the driver:
filters:
- type: sql_call
connectionStringRef: ${REFERENCE_DATABASE_URL}
query: |
SELECT tier, owner FROM customers
WHERE id = $1
{% if record.region %}AND region = $2{% endif %}
parameters:
- record.customer.id # → $1
- record.region # → $2Each parameters entry is an expr expression —
the same language as the condition filter. Values bind natively
typed: an integer stays an integer, a missing field binds as NULL.
Use ?? for explicit fallbacks (pagination.cursor ?? 0) and ?. for
optional navigation.
Write $1, $2, … regardless of the database: Cannectors translates them
per driver (kept for PostgreSQL, ? for MySQL/SQLite). A placeholder
inside a {% if %} branch that isn't taken is neither evaluated nor bound.
cannectors validate checks the pairing statically: a $N with no
matching entry, or a parameter never referenced by the query, is a
validation error. Queries loaded from queryFile are checked too — the
path is resolved from the working directory, as at run time.
An output tag ({{ … }}) inside a SQL query is rejected by
cannectors validate — it would splice a raw, unescaped value into
the SQL. Dynamic values always go through parameters; only control
blocks ({% if %}, {% for %}) may shape the query text.
Missing variables
- HTTP fields (endpoint, headers, body) render a missing variable as
an empty string. Use
default('…')to make fallbacks explicit. - SOAP bodies and SQL query text are strict: a variable that is
missing — or present but
null— fails the record instead of silently sending an incomplete request.default('…')covers both cases. - SQL parameters are permissive: a missing field binds as
NULL.
Not templating: {paramName} keys
Some modules (httpRequest, http_call, SOAP modules) also support a
keys list that injects record fields into the URL path, query string,
or headers via single-brace placeholders:
endpoint: https://api.example.com/projects/{projectId}/tasks
keys:
- field: project.id
paramType: path
paramName: projectIdThis is a separate, structured mechanism — {projectId} is not a Jinja
template. Both can coexist in the same endpoint.