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
h2andhttp/1.1via 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.tcpor a top-levelfault), because a connection-level abort is incompatible with HTTP/2 multiplexing; - an imposter with a
_rift.scriptresponse, since a script may callreset()at runtime; and - setting the
RIFT_DISABLE_HTTP2environment 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:
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
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 /impostersanswers400--configfilefails at startup rather than booting with the imposter silently missing- the FFI's
rift_apply_configreturns the same message throughrift_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}andequals: {body: ""}match; - a response
inject, adecorateor ashellTransformgets a request with nobodyproperty (a predicateinjectstill sees""); - a recorded request has no
bodykey (and no_bodyOmitted); - a
copyorlookupfrombodyfinds 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¶
Delete All 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¶
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:
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:
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¶
- Use meaningful names - Makes debugging easier
- Order stubs specifically - More specific predicates first
- Enable recording in tests - Verify expected requests
- Use default responses - Clear error messages for unmatched requests
- Separate imposters by service - One imposter per external dependency
- Place catch-all stubs last - Avoid accidentally shadowing specific stubs
- Use stub IDs (Rift) - Easier to track and manage stubs by name