cannectors

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

ConstructExample
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:

TagWhy
{% 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 (&lt; becomes &LT;). Use an inline filter instead: {{ value | upper }}.

Template context

Top-level variables available inside templates:

VariableContentsAvailable in
recordThe current record.All templated fields.
metaThe record's _metadata map.All templated fields.
statePersisted incremental state: state.lastTimestamp (RFC3339, epoch on first run), state.lastId (absent until one is persisted).httpPolling endpoint, headers, queryParams and body; soapPolling body and SOAP headers; database input parameters.
paginationCurrent page values: pagination.page, pagination.offset, pagination.cursor, pagination.limit.soapPolling body and database input parameters (re-evaluated per page).
<itemName>The current item of an enclosing loop, one variable per active loop.Filters nested in a loop.
<scopeField>A field a nested filter wrote into the loop scope without the record. prefix — an http_call response, a sql_call resultKey, a set target.Filters nested in a loop.

Inside a loop, record still means the root record — the loop's aliases sit beside it rather than wrapping it, so {{ line.number }} and the field path line.number name the same value. The same goes for anything written at scope level: {{ price }}, not {{ record.price }}. See fields written at scope level.

An itemName may not take the name of a variable in this table. A scope field that collides with one is not rejected — it is simply unreachable, since the context variable always wins.

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:

FieldEscaping
HTTP / SOAP endpointURL percent-encoding (a b&ca%20b%26c).
JSON request bodyJSON string escaping — a value containing " or a newline cannot break the payload.
SOAP body and headersXML escaping (<, >, &, " become entities).
HTTP headers, cache keysNone (raw text).
SQL queryNot applicable — dynamic values go through bound parameters, see below.

URLs: one encoding for both positions

A space is encoded as %20, not +. The distinction matters because a single endpoint holds two different positions:

endpoint: https://api.example.com/customers/{{ record.name }}?q={{ record.term }}
#                                            └─ path segment ─┘   └─ query value ─┘

In a query string, + and %20 both mean a space. In a path segment + is a literal plus — so encoding a space as + there asks the server for a customer whose name really does contain a +. %20 is read as a space in both, so it is the one encoding that is right wherever you put the placeholder.

Structure is never touched: the ?, & and / you write in the endpoint stay structural. Only the substituted values are encoded — which is exactly what stops a value containing & from inventing a new query parameter.

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            # → $2

Each 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: projectId

This is a separate, structured mechanism — {projectId} is not a Jinja template. Both can coexist in the same endpoint.

Cross-references