Skip to content

Imposters

An imposter is a mock server that listens on a specific port and responds to requests based on configured stubs.


Creating an Imposter

Basic HTTP Imposter

curl -X POST http://localhost:2525/imposters \
  -H "Content-Type: application/json" \
  -d '{
    "port": 4545,
    "protocol": "http",
    "name": "My Service Mock",
    "stubs": [{
      "responses": [{
        "is": { "statusCode": 200, "body": "Hello" }
      }]
    }]
  }'

HTTPS Imposter

curl -X POST http://localhost:2525/imposters \
  -H "Content-Type: application/json" \
  -d '{
    "port": 4546,
    "protocol": "https",
    "name": "Secure Service Mock",
    "key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
    "cert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
    "stubs": [{
      "responses": [{
        "is": { "statusCode": 200, "body": "Secure Hello" }
      }]
    }]
  }'

Omit key and cert and Rift serves a generated self-signed certificate. Certificates, mutual TLS and the related fields are covered in TLS/HTTPS.


Imposter Configuration

Field Type Required Description
port number No Port to listen on (auto-assigned if omitted, null or 0)
host string No Address to bind. Default: the server's --host, or all interfaces dual-stack (::) when none was given, falling back to 0.0.0.0 on a host without IPv6, as in Mountebank; an explicit 0.0.0.0 is IPv4 only. Use 127.0.0.1 for local-only. IPv4, IPv6 bare or bracketed (::1, [::1]), or a DNS name such as localhost, which is resolved
protocol string No http or https (default: http)
name string No Human-readable name
stubs array No Request/response mappings
defaultResponse object No Response when no stub matches. It also supplies the statusCode, headers, body and _mode that an is or inject response omits
defaultForward string No Rift extension: forward an unmatched request to this base URL (takes precedence over defaultResponse). Its path prefixes the request's (http://h/api sends /foo as /api/foo); a trailing / on it is not doubled, and a query or fragment on it is ignored
recordRequests boolean No Store requests for verification (default false)
recordMatches boolean No Accepted, no effect: per-stub matches are not recorded. true is reported as config_key_ignored in _rift.warnings and by rift-lint W017. Use recordRequests: each recorded request carries matchOutcome
allowCORS any No When JavaScript-truthy (true, "yes", 1 …), answer a CORS preflight (an OPTIONS with Origin, Access-Control-Request-Method and Access-Control-Request-Headers) by echoing those values as Access-Control-Allow-Origin/-Methods/-Headers, on the stub's or default response; other requests get no CORS headers, so a browser's actual cross-origin request still needs Access-Control-Allow-Origin in the stub's own headers, as with Mountebank. Printed back in every view as configured
strictBehaviors boolean No Rift extension: a failing behavior returns 500 instead of a fallback (see Behaviors)
service_name / serviceName string No Service identifier for documentation
service_info / serviceInfo object No Additional service metadata
_rift object No Rift extensions (flow state, scripts, faults). _rift.metrics and _rift.proxy are accepted and have no effect (config_key_ignored)
key string No PEM private key (https only; paired with cert)
cert string No PEM certificate (https only; paired with key)
mutualAuth boolean No Request and require a client certificate (https only; true on http is refused)
rejectUnauthorized boolean No Validate the client certificate against ca; requires ca
ca string or array No PEM trust anchor(s) client certificates must chain to

The five TLS fields are described in full in TLS/HTTPS. Note that mutualAuth is stricter than Mountebank: a client that presents no certificate fails the handshake.

HTTP/2 and h2c

Rift auto-negotiates the HTTP version — you don't configure it per imposter:

  • HTTPS imposters advertise h2 and http/1.1 via TLS ALPN, so an HTTP/2-capable client gets HTTP/2 and everything else falls back to HTTP/1.1.
  • Plain HTTP imposters accept h2c (cleartext HTTP/2 via prior-knowledge) alongside HTTP/1 — the listener detects the HTTP/2 preface and upgrades automatically.

This is on by default and backward-compatible with HTTP/1 clients. Three things force HTTP/1-only:

  • an imposter that uses any TCP fault (_rift.fault.tcp or a top-level fault), because a connection-level abort is incompatible with HTTP/2 multiplexing;
  • an imposter with a _rift.script response, since a script may call reset() at runtime; and
  • setting the RIFT_DISABLE_HTTP2 environment variable (truthy: 1/true/yes/on), which forces every listener — HTTP and HTTPS, imposter, admin, metrics, front door and the intercept tunnel — down to HTTP/1.

On an HTTPS imposter this decision governs the ALPN offer as well as what is served: such an imposter advertises only http/1.1 during the TLS handshake rather than offering h2 it would not speak. A client offering both protocols therefore settles on http/1.1; a client that offers only h2 (a gRPC-style client, say) is refused at the handshake with no_application_protocol rather than being allowed to commit to a protocol the imposter cannot serve. The decision is made per connection against the imposter's current stubs, so adding or removing a fault or script stub through the admin API changes what the next connection is offered — an existing connection is unaffected.

Auto-Port Assignment

If you omit the port field (or set it to null or 0), Rift asks the operating system for a free port, as Mountebank does. The port comes from the OS's ephemeral range: 49152–65535 on macOS, and net.ipv4.ip_local_port_range on Linux (32768–60999 by default). This holds at every door — POST /imposters, PUT /imposters, --configfile, reload and the C-ABI — so a document may hold several port-less imposters:

curl -X POST http://localhost:2525/imposters \
  -H "Content-Type: application/json" \
  -d '{
    "protocol": "http",
    "stubs": [{
      "responses": [{ "is": { "statusCode": 200 } }]
    }]
  }'

# Response includes the assigned port:
{
  "port": 54713,
  "protocol": "http",
  ...
}

Stubs

Each stub contains predicates (matching rules) and responses:

{
  "stubs": [
    {
      "predicates": [
        { "equals": { "method": "GET", "path": "/api/users" } }
      ],
      "responses": [
        { "is": { "statusCode": 200, "body": "[]" } }
      ]
    }
  ]
}

Stub Configuration

Field Type Required Description
name string No A label for the reader, printed back and never matched on
id string No Unique identifier (Rift extension)
predicates array No Conditions to match requests (rules is accepted as an alias)
responses array Yes, non-empty Responses to return. A stub without one is refused with 'responses' must be a non-empty array, as in Mountebank; [{"is": {}}] serves the imposter's defaultResponse
scenarioName string No Identifier for test scenarios
requiredScenarioState / newScenarioState string No Rift extension: scenario state gate and transition (see Scenarios)
space string No Rift extension: only eligible for requests whose flow id equals this (see Spaces)
routePattern string No Rift extension: route such as /users/:id that fills request.pathParams
delayRange array No Stub-level latency [{"min": 50, "max": 100}], applied as a wait on each response. An entry with min greater than max is refused
recordedFrom string No Upstream a recorded stub came from (written by proxy recording)
_verify object No Ignored by the engine; read by rift-verify

The id field is a Rift extension that allows you to identify stubs by name rather than index:

{
  "stubs": [{
    "id": "get-user-success",
    "predicates": [{ "equals": { "path": "/users/123" } }],
    "responses": [{ "is": { "statusCode": 200, "body": "{\"id\": 123}" } }]
  }]
}

The scenarioName field is useful for organizing stubs into logical groups for testing:

{
  "stubs": [{
    "scenarioName": "UserService-GetUser-Success",
    "predicates": [{ "equals": { "path": "/users/123" } }],
    "responses": [{ "is": { "statusCode": 200, "body": "{\"id\": 123}" } }]
  }]
}

Multiple Responses (Round-Robin)

When a stub has multiple responses, they cycle through:

{
  "stubs": [{
    "predicates": [{ "equals": { "path": "/flip" } }],
    "responses": [
      { "is": { "body": "heads" } },
      { "is": { "body": "tails" } }
    ]
  }]
}

First request returns "heads", second returns "tails", third returns "heads", etc.


Default Response

Configure a fallback response when no stub matches:

{
  "port": 4545,
  "protocol": "http",
  "defaultResponse": {
    "statusCode": 404,
    "headers": { "Content-Type": "application/json" },
    "body": { "error": "Not Found" }
  },
  "stubs": [...]
}

defaultResponse also completes every is and inject response that does not write all of statusCode, headers, body and _mode: a field the response omits is taken from it, as in Mountebank. headers is replaced whole, not merged key by key, so "headers": {} is a written value and keeps the response header-less. A proxy response is never completed. It is merged before behaviors and templating run. A proxy key inside defaultResponse has no effect (Mountebank does not proxy from it either): it is kept as written and reported as config_key_ignored; use defaultForward to forward unmatched requests. See Differences.

defaultResponse honours "_mode": "binary", with the same contract as a stub's is body: a base64 body is decoded and served as bytes. If it does not decode, the raw text is served with x-rift-binary-error: true alongside x-rift-default-response: true, or a 500 under strictBehaviors — see behavior failures. A non-string body in binary mode can never decode, since it is serialized to JSON text first. rift-lint reports both cases as W015.


Recording Requests

Enable request recording for verification in tests:

{
  "port": 4545,
  "protocol": "http",
  "recordRequests": true,
  "stubs": [...]
}

Retrieve recorded requests:

curl http://localhost:2525/imposters/4545

# Response includes:
{
  "requests": [
    {
      "requestFrom": "127.0.0.1:53412",
      "method": "GET",
      "path": "/api/users",
      "query": {},
      "headers": {...},
      "ip": "::ffff:127.0.0.1",
      "timestamp": "2024-01-15T10:30:00.123Z",
      "status": 200,
      "latencyMs": 0
    }
  ]
}

body is omitted when the request had none. ip is on every recorded request: the client's address without its port, the value the ip predicate sees. form is added for a non-empty application/x-www-form-urlencoded body (a key sent once is a string, a repeated key an array), as Mountebank prints them. Both are derived when the request is printed, not stored. status and latencyMs are the status that went back and how long the imposter took to produce it, in whole milliseconds. Both are absent, never 0, when no answer was sent: an X-Rift-Debug request, a TCP fault, or a request abandoned before a response existed. An error response is an answer and is recorded like any other; a present latencyMs of 0 is an ordinary sub-millisecond answer. matchOutcome (which stub matched, or why none did) may also appear.

A header the client sent once is recorded as a string. A header it sent more than once is recorded as the list of its values, in order, so a header sent twice is recorded as {"X-Test": ["first", "second"]} rather than collapsing to one value. Names keep the spelling the client used.

Header names are case-insensitive, so a document that spells one name two ways describes one header, not two. Rift merges such entries when it parses a multi-valued header object — a recorded request, a stub's headers, a flat response, an intercept rule's serve action. A document like

{ "content-type": "text/plain", "Content-Type": "application/json" }

therefore loads, serves and lists back as a single header carrying both values. The same applies to a name repeated with identical spelling, which previously kept only its last value.

Which spelling survives, and the resulting order of the values, is deliberately unspecified. It is deterministic — the same document always gives the same answer, which is the bug this fixed — but it depends on how the document reached Rift, and one --configfile document can go either way depending only on whether it uses the {"imposters": [...]} wrapper or a bare array. Write the name once and the question never arises; that is the supported shape, and it is what every document that has ever been valid already does. A document that spells each name once is unaffected in every respect, and the spelling you wrote is the spelling Rift keeps: GET /imposters and every export print it as written. On the wire, Rift's HTTP/1 server writes every response header name lower-case, as HTTP allows (names are case-insensitive).

The check is UTF-8 validity, not ASCII (#1048): a header carrying non-ASCII UTF-8 is recorded byte-exact, on both the request and the response side.

A header value that is not valid UTF-8 is dropped rather than recorded: the journal never claims the client sent an empty string it did not send. A header name whose only value was undecodable is absent from headers entirely. Unlike a binary request body (below), a header has no _mode slot on the wire to carry an encoded form, so dropping — with a warning logged server-side — is the honest representation.

The same rule applies in the other direction, to a proxy response relayed from a real upstream (#1041): an upstream header value that is not valid UTF-8 is dropped, with a server-side warning, rather than relayed as an empty string. It reaches neither the client nor the stub that proxyOnce/proxyAlways records — which matters most for the stub, since a blanked value there would be served on every later request, long after the upstream was out of the picture. Note that this check is on UTF-8 validity, not on ASCII: an upstream header such as Content-Disposition: attachment; filename="résumé.pdf" relays, records and replays byte-exact.

Single-valued header objects reject a repeated name

Two header objects hold one value per name rather than a list: proxy.injectHeaders and _rift.fault.error.headers. They do the opposite of the merge above — a name given twice, in any casing, is rejected, with a message naming both spellings (#1050):

  • POST /imposters answers 400
  • --configfile fails at startup rather than booting with the imposter silently missing
  • the FFI's rift_apply_config returns the same message through rift_last_error

There is no lossless merge for one slot and two different values, and silently picking a winner is exactly what the merge above exists to avoid. Before this, both spellings survived and both header lines went out, ordered differently from one process to the next. Write the name once.

One wrinkle worth knowing: a name repeated with identical spelling ({"x": "a", "x": "a"}) is caught by the engine only when the document is read as text. Through --configfile's {"imposters": [...]} wrapper the JSON parser has already collapsed it before Rift sees it, so it loads. A name repeated in different casing is caught on every path.

rift-lint closes that asymmetry ahead of time: E044 reads the raw document text, so it reports a byte-identical repeated name in these two fields before the document reaches any ingestion path. It is scoped to them deliberately — in is.headers a repeated name is merged into two header lines rather than rejected, which is how a stub sends two Set-Cookies.

Binary Request Bodies

A request body that is not valid UTF-8 (protobuf, gzip, an image upload) cannot be recorded as text without destroying it. Such a body is recorded base64-encoded and marked with _mode: "binary", mirroring how binary response bodies are represented:

{
  "requests": [
    {
      "method": "POST",
      "path": "/api/upload",
      "body": "iVBORw0KGgoAAAANSUhEUg==",
      "_mode": "binary",
      "timestamp": "2024-01-15T10:30:00.000Z"
    }
  ]
}

The body is standard base64 (with padding). _mode is absent for a normal text/JSON body, so recordings of text traffic are unchanged — check for it rather than assuming it is present.

Compressed request bodies

A request sent with Content-Encoding: gzip or Content-Encoding: br is decoded before it is matched and recorded, as in Mountebank. Predicates, scripts, behaviors and savedRequests all see the decoded body, and a decoded body that is UTF-8 is recorded as text, with no _mode. The header itself is kept as the client sent it. Only those two exact values are decoded: GZIP, x-gzip, deflate and a list such as gzip, br leave the body as sent, as Mountebank compares the value exactly. A proxy forwards the body still encoded, with its header.

A body that does not decode (it is not really gzip or brotli, it is truncated, or it is empty) has no body at all, as Mountebank leaves request.body undefined:

  • a predicate sees the body as missing, so exists: {body: false} and equals: {body: ""} match;
  • a response inject, a decorate or a shellTransform gets a request with no body property (a predicate inject still sees "");
  • a recorded request has no body key (and no _bodyOmitted);
  • a copy or lookup from body finds no source.

The decoded size is what --max-body-size limits, and it is charged to --body-memory-budget like a raw body of that size, so a small body that inflates past the ceiling gets 413 (see Body size limits). Mountebank decodes without a limit.

Scripts see the same distinction: ctx.request.body carries the base64 string and ctx.request.isBinary is true. Where rift cannot determine the encoding (the decorate and predicate-inject paths do not carry it), isBinary is absent rather than false — so a script can tell "text" apart from "unknown" instead of being told something untrue.


Managing Imposters

List All Imposters

curl http://localhost:2525/imposters

# Response:
{
  "imposters": [
    { "port": 4545, "protocol": "http", "name": "User Service" },
    { "port": 4546, "protocol": "https", "name": "Payment Service" }
  ]
}

Get Imposter Details

curl http://localhost:2525/imposters/4545

# Response includes full configuration and recorded requests

Delete Single Imposter

curl -X DELETE http://localhost:2525/imposters/4545

Delete All Imposters

curl -X DELETE http://localhost:2525/imposters

Loading from Configuration File

JSON Format

Create imposters.json:

{
  "imposters": [
    {
      "port": 4545,
      "protocol": "http",
      "stubs": [...]
    },
    {
      "port": 4546,
      "protocol": "http",
      "stubs": [...]
    }
  ]
}

Load on startup:

docker run -v $(pwd)/imposters.json:/imposters.json \
  zainalpour/rift-proxy:latest --configfile /imposters.json

EJS Templates

A --configfile or file: source is preprocessed once, when it loads, and can read the process environment. --datadir files are not preprocessed.

{
  "imposters": [
    {
      "port": <%= process.env.PORT || '4545' %>,
      "protocol": "http",
      "stubs": [...]
    }
  ]
}

The environment is read with <%= process.env.VAR %> or <%= process.env.VAR || 'default' %>, with the default in quotes; <%= HTML-escapes the value as EJS does, and <%- process.env.VAR %> inserts it raw. The value is pasted in as text, so leave the tag outside the JSON quotes for a number such as port; inside quotes it becomes a string, which the engine refuses for a port. The only template variable is data, in a file stringified with a data argument (below).

A variable that is unset renders empty when the tag has no default, and one whose value is not valid Unicode renders the default or empty. Either way the engine logs a warning naming the variable and the tag's line when the file loads, and if the rendered file then fails to parse, the error names the variable too. Give the tag a default to avoid it for an unset variable; a value that is not valid Unicode is reported either way.

Any other tag fails the load with an error naming the tag and its line: another <%= … %> expression, a <% … %> statement other than include, a <%- … %> output tag other than include, stringify, inject or data.key, a <%# … %> comment, or a <% with no closing %>. Included and stringified files are checked the same way, and may include and stringify further files. A Mountebank template that relies on other JavaScript has to be rewritten, not loaded with parts of it missing. If a <% is meant literally, for example in a response body that serves an EJS page, load the file with --no-parse. A document fetched from an https: source is always preprocessed, so it cannot carry a literal <%.

<% include 'path' %> or <%- include('path') -%> inlines another file, and <%- stringify(filename, 'path') %> (or stringify('path'), or inject(...)) inlines a file's contents, trimmed and escaped for use inside a JSON string — so the tag goes inside quotes, as in Mountebank: "body": "<%- stringify(filename, 'datafile.json') %>". A third argument, stringify(filename, 'body.ejs', {testValue: '1111'}), sets data.testValue in that file. See Configuration for every form, the path rules, and what an unquoted tag reports. A stringified file is rendered first, so process.env tags in it are substituted. --no-parse turns preprocessing off; see the CLI reference. rift-lint renders a templated file the same way before it checks it; see Linting.


Stub Matching Behavior

First-Match-Wins

Predicate matching in a Rift imposter: a request is checked against the stubs in order. Each stub's predicates (equals, contains, jsonpath, matches) are evaluated until one fails, and that stub is skipped. The first stub whose predicates all match answers, here POST /orders gets 201 from stub 2. A request no stub matches, GET /reports, falls through to the imposter's defaultResponse, 404 here; without one Rift answers 200 with an empty body, as Mountebank does.

Stubs are evaluated in order. The first stub whose predicates match is used:

{
  "stubs": [
    {
      "predicates": [{ "startsWith": { "path": "/api" } }],
      "responses": [{ "is": { "body": "general" } }]
    },
    {
      "predicates": [{ "equals": { "path": "/api/users" } }],
      "responses": [{ "is": { "body": "specific" } }]
    }
  ]
}

In this example, /api/users returns "general" because the first stub matches. To get "specific", swap the stub order.

Empty Predicates (Catch-All)

A stub with empty predicates matches all requests:

{
  "stubs": [
    { "predicates": [], "responses": [{ "is": { "body": "catch all" } }] }
  ]
}

Warning: A catch-all stub shadows all subsequent stubs. Place catch-all stubs last.

Index-Based Operations

Stub indexes shift when stubs are added or removed:

Before: [Stub0, Stub1, Stub2]  (indexes 0, 1, 2)
Delete index 0:
After:  [Stub1, Stub2]         (indexes 0, 1)

Rift Stub Analysis (Rift Extension)

Rift provides optional warnings for common stub configuration issues. These warnings appear in the API response under _rift.warnings:

curl http://localhost:2525/imposters/4545

# Response includes:
{
  "port": 4545,
  "stubs": [...],
  "_rift": {
    "warnings": [
      {
        "warningType": "catch_all_not_last",
        "message": "Catch-all stub at index 0 will shadow 2 stub(s) after it",
        "stubIndex": 0
      }
    ]
  }
}

Warning Types

Type Description
duplicate_id Multiple stubs have the same ID
exact_duplicate Stub predicates and gates (space, requiredScenarioState) are identical to another stub's
potentially_shadowed Stub may be unreachable due to earlier stub
catch_all Stub with empty predicates matches all requests
catch_all_not_last Catch-all stub is not at the end of the list
state_ops_never_runs _rift.stateOps is on a response shape that never runs it
conditional_never_runs _rift.conditional is on a script response (_rift.script), or behind a method predicate that excludes GET and HEAD (lint W019)
truncated More warnings were produced than are retained

See Stub Analysis for details.

Note: Mountebank does NOT provide overlap detection. These warnings are a Rift extension.


Imposter Error Responses

These are errors served on the imposter port — distinct from the admin API's error responses (see API Reference). Every one of them serves the same JSON envelope as the admin plane, with Content-Type: application/json:

{ "errors": [ { "code": "...", "type": "...", "message": "..." } ] }

Branch on type — it is always a stable symbolic slug. code is legacy: the HTTP status as a string on most doors, a slug on a few, frozen for backward compatibility. See Error Responses for the full type list.

Status When
400 The request body could not be read to completion — a client transmission failure mid-stream. The underlying cause is logged server-side.
413 The request body exceeded the configured size cap.
500 An internal failure while building the response — for example a stub, inject, or upstream header value that is not a legal HTTP header.
504 A script exceeded its deadline. See Scripting and Debug Mode.

The 400/413 split matters when you are debugging a flaky client: 413 means the body was too big, 400 means the connection failed to deliver the body it promised. Earlier releases answered 413 for both, which reported every mid-stream network failure as "body too large" (#694).

One door deliberately does not use the envelope: the last-resort internal error emitted when the envelope itself cannot be constructed.


Best Practices

  1. Use meaningful names - Makes debugging easier
  2. Order stubs specifically - More specific predicates first
  3. Enable recording in tests - Verify expected requests
  4. Use default responses - Clear error messages for unmatched requests
  5. Separate imposters by service - One imposter per external dependency
  6. Place catch-all stubs last - Avoid accidentally shadowing specific stubs
  7. Use stub IDs (Rift) - Easier to track and manage stubs by name