Skip to content

Mocks and templating

A mock answers a matching request instead of forwarding it. Everything that does not match still reaches the upstream.

Matching

A mock matches when the request's method equals its method exactly, and the request's path matches its url as a regular expression.

Methods are case-sensitive, and the configuration must spell them in upper case -- method: get is rejected at load rather than silently never matching.

Patterns are unanchored, and order decides

url: /api/v1/resource/ matches any path containing that string, so it also matches /api/v1/resource/42/. Mocks are tried in the order they are written and the first match wins, so a general pattern placed above a specific one makes the specific one unreachable.

Doppel does not detect this. A shadowed mock is a valid configuration -- just a useless one. Put specific patterns first.

This is not hypothetical: the reference configuration shipped with two unreachable mocks until an end-to-end test caught it.

The pattern stays unanchored deliberately. Anchoring it would silently change what already-written configurations mean, and a change that alters behaviour without the configuration changing is the worst kind -- there is no error and nothing to review.

Repeated leading slashes

A run of slashes at the start of the path is collapsed to one before matching, so GET //api/v1/index/ is matched as /api/v1/index/.

Clients produce the doubled form constantly -- a base URL ending in / joined to a path beginning with / is the usual way -- and it is legal HTTP, so nothing rejects it. An unanchored pattern happened to match it anyway, because the extra slash falls outside the substring being looked for. An anchored one (^/api/v1/index/$) did not, and the only symptom was the mock silently not firing.

Only the leading run. /a//b/ keeps its empty middle segment and does not match a pattern written /a/b/: whether those name the same resource is the upstream's business, and answering it here would also disagree with the path that gets forwarded.

Variables

Five sources. Four are yours and optional; the fifth is Doppel's and always there.

Path captures. Named groups in the pattern become variables:

          url: /api/v1/resource/(?P<resource_id>\d+)/

binds resource_id.

Headers. A map of variable name to header name:

          headers:
            trace_id: X-Trace-Id

Query and body. A map of variable name to a selector -- a leading dot and dot-separated keys:

          query:
            filter: .filter
          body:
            item_count: .content.items

Selectors address object keys. A selector that lands on an array yields the array, which templates can iterate or measure with length. Array indexing is not supported.

A variable name may not collide with a capture group name; that is rejected at load.

Names are snake_case throughout this documentation and in main.example.yaml. Jinja accepts any identifier, so itemCount works -- but Doppel's own variables are snake_case, and one convention per context reads as one source.

System variables

Doppel binds nine variables into every template it renders, whether or not the mock asked for anything:

Variable Is
proxy_name The proxy that resolved. Empty for a request that resolved to none
mock_name The mock answering. Empty when the request is being forwarded
doppel_version The version of the binary serving the request
request_id The id echoed in X-Request-ID, minted when the client sent none
method The request method
path The request path, without the query string
host The Host the client asked for. Empty when it sent none
peer_ip The address the connection came from
real_ip Who the request is said to be from: X-Real-IP, else the leftmost X-Forwarded-For entry, else peer_ip
        response:
          status: 200
          json: '{"served_by": "{{ proxy_name }} {{ doppel_version }}", "caller": "{{ real_ip }}"}'
          headers:
            X-Request-ID: "{{ request_id }}"

peer_ip and real_ip are not the same claim. peer_ip is the socket's own address and nobody can fake it. real_ip is what a proxy in front says, out of headers a client can also send -- useful for a mock that reports who called it, and not something to make a decision on unless you know what sits in front.

They are reserved. They are bound after your extractions, so a mock that extracts into proxy_name finds Doppel's value in its template rather than its own. The extraction still happens and its result is thrown away, which is why startup says so:

mock `m1` of proxy `alpha` extracts `proxy_name` into a name Doppel binds
itself; the system value wins, so the extraction is read and thrown away

Being always present, they also never need | default('') -- an absent one is an empty string rather than an undefined variable, which is the one place Doppel's own variables are gentler than yours.

Rendering

Exactly one of three fields produces the body, or none at all for a status that forbids one:

  • body -- a template rendered and returned as-is.
  • json -- a template whose output is additionally parsed as JSON, so a template that produces malformed JSON fails loudly instead of emitting a broken body. The parse is a check: what goes on the wire is exactly what the template rendered, with your key order and spacing intact.
  • template -- the name of a file under <templates.dir>/<proxy>/.

Every value in response.headers is a template too.

Rendering is strict

An undefined variable is an error, not an empty string.

          headers:
            request_id: X-Request-ID
        response:
          json: '{"seen": "{{ request_id }}"}'

A request without X-Request-ID fails this mock with TEMPLATE_RENDER_ERROR, because request_id is undefined. That is deliberate: a mock that silently renders "seen": "" because a variable was mistyped is worse than one that refuses. The error message names the expression that failed.

If a variable should be optional, say so:

          json: '{"seen": "{{ request_id | default('''') }}"}'

Serving some of the time

replace is the probability that a matching mock actually answers. It defaults to 1.0.

    replace: 0.5          # on the proxy: half of all matched requests
    mocks:
      - name: sometimes
        proxy:
          replace: 0.1    # on the mock: overrides the proxy for this one

A mock that matches but loses the roll falls through to the upstream. That is the point of the setting: serve a mock part of the time and the real backend the rest.

Bodies and the size limit

Doppel streams request bodies rather than buffering them, so an upload larger than memory passes straight through. A mock that extracts variables from the body cannot do that -- it needs the whole body in hand.

So buffering happens only when a matched mock declares body selectors, and only up to the proxy's body_limit, which defaults to 1 MiB. A body over the limit is rejected with 413 and UPLOAD_TOO_LARGE.

It is rejected rather than quietly forwarded upstream on purpose. A proxy that stopped mocking under load would show you real backend traffic you believed was intercepted, and you would have no way to tell.

Template files

template: put.json.j2 names a file at <templates.dir>/<proxy-name>/put.json.j2.

The file is read when the request arrives, not when the configuration loads, so a mock may name a file that does not exist yet and have it uploaded through the admin API later, with no reload. A missing file at request time is TEMPLATE_NOT_FOUND.

Errors

Every failure returns the standard envelope with the status below:

{"status": "error", "message": "...", "code": "TEMPLATE_RENDER_ERROR"}
Situation Code Status
Undefined variable, bad filter, template syntax error TEMPLATE_RENDER_ERROR 500
A json response rendered to something that is not JSON TEMPLATE_RENDER_ERROR 500
template names a file that is not on disk TEMPLATE_NOT_FOUND 500
A body selector was declared and the body is not valid JSON BODY_EXTRACTION_ERROR 500
The body exceeded body_limit UPLOAD_TOO_LARGE 413