Building imposters¶
An imposter is a mock server on a port. It holds stubs; each stub has predicates that select requests and responses served when they match.
imp := rift.NewImposter("orders").Record().
Stub(rift.OnPost("/orders").
WithHeader("Content-Type", rift.Contains("json")).
Return(rift.Created("/orders/1"))).
Stub(rift.OnGet("/orders/1").
Return(rift.OKJSON(order))).
Stub(rift.OnAny().Return(rift.Status(404)))
Stub order decides the winner¶
The engine serves the first stub whose predicates match. A catch-all belongs last:
// Wrong — the catch-all wins every time, and /health is dead code.
rift.NewImposter("x").
Stub(rift.OnAny().Return(rift.Status(404))).
Stub(rift.OnGet("/health").Return(rift.OKText("ok")))
The same applies when adding a stub to a live imposter: AddStub appends, so a stub added
behind an existing catch-all is unreachable. Use AddStubAt(0, …) to put one in front.
Predicates¶
Matchers are values, so they compose freely:
rift.OnGet("/search").
WithQuery("q", rift.Equals("widgets")).
WithHeader("Accept", rift.Contains("json")).
WithBody(rift.Matches(`"total":\s*\d+`))
| Matcher | Matches when |
|---|---|
Equals(v) |
the field equals v |
DeepEquals(v) |
the whole object corresponds — no extra keys |
Contains(v) |
the field contains v |
StartsWith(v) / EndsWith(v) |
prefix / suffix |
Matches(re) |
the regular expression matches |
Exists(bool) |
the field is present / absent |
Modifiers chain onto any matcher:
rift.Contains("json").CaseSensitive(true)
rift.Equals("x").Except(`\s+`) // strip before comparing
rift.Equals(hdrs).KeyCaseSensitive(true) // object *keys*
Fields sharing an operator collapse¶
rift.OnGet("/x") // → {"equals": {"method": "GET", "path": "/x"}}
Not two separate equals predicates. They would AND identically, but the merged form is what the
engine and the conformance fixtures use — and it is what a _verify comparison expects.
Matchers with different parameters are not merged, because case sensitivity is a property of the predicate rather than the field; merging would silently change the other field's semantics.
Composites and selectors¶
rift.OnAny().WithPredicate(
rift.Or(
rift.PredicateOn("path", rift.Equals("/a")),
rift.PredicateOn("path", rift.Equals("/b")),
),
rift.Not(rift.PredicateOn("method", rift.Equals("DELETE"))),
rift.PredicateOn("body", rift.Equals("42")).WithJSONPath("$.count"),
)
rift.Inject(js) builds a predicate evaluated by JavaScript. It requires the engine started with
--allowInjection over the admin API; the embedded C ABI accepts it unconditionally, because the
in-process embedder is already trusted.
Responses¶
rift.OK() // 200, no body
rift.OKText("pong") // 200 text/plain
rift.OKJSON(map[string]any{…}) // 200 application/json
rift.Status(503)
rift.Created("/orders/1") // 201 + Location
rift.NoContent() // 204
rift.NotFound() // 404
Response cycling¶
Call Return repeatedly. The engine walks the cycle and wraps around:
rift.OnGet("/flaky").
Return(rift.Status(503).Repeat(2)). // twice
Return(rift.OKText("ok")) // then this
Behaviours¶
rift.OKJSON(body).
After(250 * time.Millisecond). // fixed delay; AfterBetween(min, max) for a random one
Repeat(3).
Decorate(`(cfg) => { cfg.response.headers['X-Seen'] = '1' }`).
Templated()
Copy, Lookup and ShellTransform pass their engine config through verbatim.
Evaluation order is the engine's, not the call order¶
The builders write the object form, _behaviors, and the engine runs it in a fixed order:
wait, lookup, copy, shellTransform, decorate. That is Mountebank's order, and Rift follows it
from engine 0.18.0. Earlier engines ran wait, copy, lookup, decorate, shellTransform.
So a chain such as .Decorate(js).ShellTransform(cmd) runs shellTransform first, whatever order
you called the methods in. And lookup runs before copy, so text that copy puts into the
response is never scanned for lookup tokens.
When you need a different order, use the array form, behaviors: a list the engine runs element by
element, in order. The builders do not produce it, but the model carries it through Extra, so
either of these works:
resp := rift.OKText("…").Build()
resp.Extra = map[string]rift.JSON{"behaviors": []rift.JSON{
map[string]rift.JSON{"decorate": js},
map[string]rift.JSON{"shellTransform": cmd},
}}
// or write the response as JSON
resp, err := rift.ResponseFromJSON([]byte(`{"is":{…},"behaviors":[{"decorate":"…"},{"shellTransform":"…"}]}`))
GET /imposters writes behaviors back in Mountebank's grammar: repeat on the response, and a
one-item list as a bare value. A config read back from the engine can therefore differ textually
from the one you sent while meaning the same thing.
Faults and proxies¶
rift.Fault("CONNECTION_RESET_BY_PEER") // connection-level failure, no response
rift.Proxy("http://upstream:8080").
Once(). // record the first response, replay it after
InjectHeader("X-Via", "rift").
RewritePath("^/api", "/v2")
Fault names are passed through as strings, so a newer engine's fault works without an SDK release.
A proxy response takes the same behaviors as a canned one. They run on the upstream's response before it reaches the client and before it is recorded, as Mountebank does:
rift.Proxy("http://upstream:8080").Once().
After(500 * time.Millisecond) // delays the live first call; the recorded replay has no delay
That needs engine 0.18.0 or later; older engines ignore behaviors on a proxy response. The stub a recording generates holds the transformed response, not the behaviors, so nothing runs twice.
A fault takes only Repeat. Any other behavior on a fault is kept but never runs, and engines
from 0.18.0 on report it as a config_key_ignored warning.
AfterBetween(min, max) needs min no greater than max. Engines from 0.18.0 refuse the imposter
otherwise; earlier ones accepted it and then failed every request.
HTTPS and client certificates¶
HTTPS(certPEM, keyPEM) serves TLS with your certificate; empty strings select the engine's
self-signed default. RequireClientCert makes the imposter demand a client certificate at the
handshake:
rift.NewImposter("bank").
HTTPS(serverCert, serverKey).
RequireClientCert(clientCA). // the certificate must chain to clientCA
Stub(rift.OnAny().Return(rift.OK()))
rift.NewImposter("any-client").RequireClientCert() // any certificate will do
With one or more CA PEMs the client certificate must chain to one of them. With none, any
certificate the client holds is accepted, but a client that presents nothing still fails the
handshake. RequireClientCert sets the protocol to https unless you set one.
Engine 0.18.0 enforces this, earlier engines ignored it
Before 0.18.0 the engine dropped mutualAuth, rejectUnauthorized and ca, and accepted every
client. From 0.18.0 they are enforced. A test that used MutualAuth() and connected without a
client certificate passed before and fails now, and MutualAuth() on a non-HTTPS imposter is
refused. MutualAuth() is deprecated in favour of RequireClientCert.
Scenarios¶
A stub can gate on and advance a named state machine:
rift.NewImposter("retry").
Stub(rift.OnGet("/x").InScenario("s").RequireState("Started").
SetState("failed-once").Return(rift.Status(503))).
Stub(rift.OnGet("/x").InScenario("s").RequireState("failed-once").
Return(rift.OKText("ok")))
Spaces¶
A space is a per-flow overlay on a shared imposter, so parallel shards can hit one port and stay isolated, partitioned by flow id:
eng.SpaceAddStub(ctx, port, "flow-a", rift.OnGet("/who").Return(rift.OKText("a")))
eng.SpaceAddStub(ctx, port, "flow-b", rift.OnGet("/who").Return(rift.OKText("b")))
Keys the engine accepts but ignores¶
Three keys the model carries have no effect on a Rift engine. They are kept so a Mountebank config round-trips unchanged:
| Key | Why it does nothing | Use instead |
|---|---|---|
recordMatches (RecordMatches()) |
Rift does not record per-stub matches | Record() and the request journal |
_rift.metrics |
metrics are process-wide | the metrics port (ServeOptions.MetricsPort, or --metrics-port) |
_rift.proxy |
a proxy response's upstream is its own proxy.to |
the Proxy(...) response |
Engines from 0.18.0 on report each one as a config_key_ignored warning. On the embedded engine it
appears in StubWarnings, and on a remote one in _rift.warnings on GET /imposters/:port.
RecordMatches() is deprecated.
The escape hatch¶
For a config that predates the DSL, is generated elsewhere, or exercises a corner of the grammar the builders do not model yet:
raw := []byte(`{"port":4545,"stubs":[…],"someFutureKey":true}`)
imp, err := rift.ImposterFromJSON(raw)
// or the bulk envelope
cfg, err := rift.ImpostersFromJSON(raw)
Every open struct carries an Extra map, so unknown keys survive an unmarshal/marshal
round-trip untouched. A config built for a newer engine passes through this SDK unchanged — which
is exactly what the conformance gate asserts.
You can also mix: parse a document, adjust it with typed fields, and send it.
imp.Port = 0 // let the engine assign
imp.RecordRequests = true
Declared fields always win over a colliding Extra entry, so an escape-hatch value can never
silently overwrite typed state.