Skip to content

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(50*time.Millisecond, 1*time.Second).
    Repeat(3).
    Decorate(`(cfg) => { cfg.response.headers['X-Seen'] = '1' }`).
    Templated()

Copy, Lookup and ShellTransform pass their engine config through verbatim.

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.

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")))

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.