rift-java API Design (v1)¶
Status: accepted design, amended as the API evolved — it pinned down the public API so
implementation issues carried no open design decisions, and is kept in step with the shipped API
(current as of rift-java 0.3.0). Canonical location: docs/design/sdk-api.md; mirrored as
issue #19. Sources: the rift engine (achird-labs/rift) — this document tracks the pinned
<rift.engine.version>, currently 0.18.0, and states a per-feature version floor wherever one
applies rather than pinning the whole document to a single release — C-ABI v2
(librift_ffi, include/rift_ffi.h), the rift-conformance corpus + Plane-B SPI, rift-node
0.12.x (reference SDK), the rift-scala design issues (consumer of this SDK), and a DX benchmark
of WireMock 3 / MockServer / Hoverfly / Testcontainers / wiremock-spring-boot.
1. Goals and hard constraints¶
- Full engine surface on every transport. Stubs/predicates/responses, response cycling,
behaviors, proxy record/playback, faults (connection + probabilistic
_riftfaults), stateful scenarios, spaces/flow-state, request verification, TLS-MITM intercept. No transport is a reduced subset (README promise; conformance Plane-B requirement). - Cross-SDK contracts are fixed (shared with rift-node and rift-scala):
- Transports:
Rift.embedded()/Rift.connect(uri)/Rift.spawn(). - Error taxonomy, exactly five leaves:
InvalidDefinition,EngineUnavailable,CommunicationError,ImposterNotFound,EngineError(code, message). - DSL vocabulary:
imposter,onGet/onPost/…,willReturn,ok/okJson/created/status/ notFound/noContent,proxyTo,fault,inject,scenario().startingAt().when() .respond().goTo(),record(),verify(match, times(n)), andmerge(...)for one predicate over several request fields (§7.6a; distinct fromand). - Scala-wrappable by construction (rift-scala delegates everything to this SDK):
synchronous, throwing,
AutoCloseable,Optional(nevernull),java.utilcollections, immutable value types, plus a raw-JSON escape hatch on every ingestion point so the Scala bridge can bypass Java builders entirely. - Framework-neutral core.
rift-java-corestays zero-runtime-dep, JDK 17, plain types — usable from bare JUnit, TestNG, or any framework. Spring/JUnit5/Jackson support are separate modules that adapt the core, never the other way around. - Conformance is the acceptance test. Every fixture in the shared corpus must be
expressible in the typed DSL and round-trip losslessly (byte-fidelity via the existing
JsonNumber-raw +semanticEqualsmachinery).
2. Module map (final)¶
| Artifact | JDK | Contents |
|---|---|---|
rift-java-core |
17 | wire model, zero-dep JSON codec, DSL, Rift client + remote/spawn transports, verification, body-codec SPI |
rift-java-embedded |
22 | FFM bridge for C-ABI v2, EmbeddedEngineProvider |
rift-java-embedded-jdk21 |
21 (preview) | same sources, preview FFM |
rift-java-natives |
— | per-platform classifier jars (native/<os>-<arch>/librift_ffi.<ext>) |
rift-java-junit5 |
17 | @RiftTest, RiftTestExtension, parameter injection, @RiftGolden |
rift-java-jackson |
17 | RiftBodyCodec implementation over jackson-databind |
rift-java-spring |
17 | new — Spring Boot test integration (@EnableRift, @ConfigureImposter, @InjectImposter) |
rift-java-testcontainers |
17 | RiftContainer — a Dockerized engine with a connected client and intercept (attach or runtime start) |
rift-java-bom |
— | new — BOM aligning all modules + natives classifiers |
Record/playback (Imposter.startRecording, @RiftGolden) and RiftContainer, once backlog,
have shipped.
3. Package map (final)¶
io.github.achirdlabs.rift Rift, RiftAsync, Imposter, Space, FlowState, Scenarios, StubRef,
RecordedRequest, RecordedPage, MatchClause, ApplyResult,
EngineInfo, VersionCheck, UpstreamTrust,
ConnectOptions, SpawnOptions, EmbeddedOptions,
Recording, RecordSpec, RecordMode,
EventStream, EventStreamOptions, RiftEvent,
Intercept, InterceptOptions, InterceptTrust,
InterceptRule, InterceptRuleBuilder, TruststoreFormat
io.github.achirdlabs.rift.error RiftException (sealed) + 5 leaves
io.github.achirdlabs.rift.verify VerificationException, VerificationTimes, RequestMatch,
PredicateEvaluator (deprecated, #337)
io.github.achirdlabs.rift.model wire records (ImposterDefinition, Stub, Predicate, …),
WireFormatException
io.github.achirdlabs.rift.json JsonValue + codec (unchanged)
io.github.achirdlabs.rift.dsl RiftDsl + spec builders (unchanged home)
io.github.achirdlabs.rift.codec RiftBodyCodec SPI
io.github.achirdlabs.rift.transport internal SPI (RiftTransport, EmbeddedEngineProvider,
EmbeddedEngine, StubAddress), HostAuthority
3.1 Naming decision: handle vs. definition¶
The live, transport-bound handle is Imposter (root package) — it is what users hold 99%
of the time (Imposter users = rift.create(...), per README and rift-scala examples). The
wire-model record currently named model.Imposter is renamed model.ImposterDefinition
(and model.Imposters → model.ImposterDefinitions). Rationale: two types named Imposter
in adjacent packages is a permanent ambiguity tax on the Scala bridge and on anyone using the
escape hatches; the rename is mechanical and we are pre-release. imposter.definition()
returns the record, closing the loop.
4. Error model (cross-SDK contract, sealed)¶
package io.github.achirdlabs.rift.error;
public abstract sealed class RiftException extends RuntimeException
permits InvalidDefinition, EngineUnavailable, CommunicationError,
ImposterNotFound, EngineError { … }
public final class InvalidDefinition extends RiftException { } // HTTP 400 / FFI validation
public final class EngineUnavailable extends RiftException { } // connect refused / lib missing / spawn failed
public final class CommunicationError extends RiftException { } // unparseable response / broken pipe
public final class ImposterNotFound extends RiftException { public int port(); } // HTTP 404
public final class EngineError extends RiftException { public int code(); } // any other engine error
- Unchecked. Class names deliberately match rift-node / the cross-SDK contract verbatim (no
-Exceptionsuffix); the base carries the suffix. - Uniform across transports: remote maps HTTP status +
{errors:[{code,message}]}envelope; embedded maps FFI sentinel +rift_last_error(); spawn maps process-launch failures toEngineUnavailable. VerificationException extends AssertionError(NOTRiftException) so test runners report verification failures as failures, not errors (see §8).
5. The client: Rift¶
public interface Rift extends AutoCloseable {
// -- construction (one client, three transports) --
static Rift connect(URI adminUri);
static Rift connect(ConnectOptions options);
static Rift spawn(); // managed rift binary, ephemeral admin port
static Rift spawn(SpawnOptions options);
static Rift embedded(); // in-process via ServiceLoader (see §5.2)
static Rift embedded(EmbeddedOptions options);
static boolean isEmbeddedAvailable(); // provider on classpath AND native resolvable
// -- imposters --
Imposter create(ImposterSpec spec); // DSL spec directly — no .build() at call sites
Imposter create(ImposterDefinition definition);
Imposter create(JsonValue imposterJson); // escape hatch (Scala bridge, raw fixtures)
Imposter create(String imposterJson); // escape hatch
Optional<Imposter> imposter(int port);
List<Imposter> imposters();
void deleteAll();
ApplyResult applyConfig(JsonValue config); // reconcile ({imposters:[…]}) with a report of changed ports (#264;
// toggled = enabled-only changes, rift >= 0.20.0, #273):
// embedded rift_apply_config; remote PUT /imposters, whose 200 has
// the report from engine 0.20.0 (rift#1304); older → EngineUnavailable.
// Not POST /admin/reload, which reads no body.
void replaceAll(List<ImposterDefinition> imposters); // atomic PUT /imposters
// -- engine --
EventStream events(EventStreamOptions options); // push tail over the engine's SSE stream (§8.3)
EngineInfo info(); // {version, commit, features} — GET /config | rift_build_info
URI adminUri(); // remote/spawn: the admin endpoint; embedded: lazily
// starts rift_serve_admin on first call
// -- intercept (TLS-MITM) --
Intercept intercept(); // default options
Intercept intercept(InterceptOptions options); // InterceptOptions.attach() discovers a running listener
Optional<InterceptStatus> interceptStatus(); // GET /intercept (404 → empty); embedded: the admin plane's once served, else own listener
// -- async facade (secondary surface) --
RiftAsync async(); // createAsync(ImposterSpec), deleteAllAsync(), impostersAsync()
@Override void close(); // idempotent; never throws checked
}
Conventions: all methods synchronous + throwing (RiftException leaves); thread-safe; safe on
virtual threads (documented: embedded downcalls block a carrier only for the call duration).
5.1 Options types (immutable builders)¶
public final class ConnectOptions {
static Builder builder(URI adminUri);
// adminUri (required), apiKey (→ Authorization), requestTimeout (default 30s),
// versionCheck: FAIL | WARN | OFF — default from -Drift.versionCheck, then RIFT_VERSION_CHECK,
// then FAIL; checks the engine's version via GET /config against the SDK's floor
// (RiftImpl.MIN_ENGINE_VERSION, 0.13.1) and against per-feature floors at create/replaceAll.
// hostResolver: HostResolver (protocol, port) -> URI — the base URI the SUT should use for an
// imposter. Default: imposter protocol as the scheme + adminUri.host + imposter port (#250).
// Needed for Docker/remapped-port setups (the conformance "hostFor seam"). An
// IntFunction<URI> overload stays: its URI is used verbatim, scheme included. No
// upstreamTrust here: a connected engine's outbound trust is set where that engine was started.
// interceptAddress: IntFunction<InetSocketAddress> port -> where a runtime-started intercept
// listener is dialled on a connected engine (#258). Unset: the engine's reported bind address,
// a wildcard bind replaced by the admin host. Separate from hostResolver: the listener is a
// CONNECT proxy, not an imposter base URI. Also asked with the requested port before the start,
// so it can refuse an unreachable port with nothing started.
}
public final class SpawnOptions {
// binaryPath (Path), version (String, default = SDK's pinned engine version),
// host (default 127.0.0.1; an IP literal, IPv6 bare ::1 or bracketed [::1] — a bare one needs
// rift >= 0.18.0; localOnly(true) makes the engine bind loopback whatever this says; from
// rift 0.23.0 a host-less imposter binds it too, rift#1517),
// adminPort (0 = ephemeral), allowInjection (default true),
// localOnly (default true; from rift 0.23.0 it also refuses non-loopback clients on imposters,
// rift#1523, so a containerised SUT needs it off plus imposter host("0.0.0.0")), logLevel, env (Map), workingDir, mirrorUrl,
// startupTimeout (default 15s), shutdownTimeout (default 5s), inheritLog (bool),
// upstreamTrust (UpstreamTrust, rift >= 0.18.0 — CaFile or SkipVerify; CaPem and a declared
// version below 0.18.0 are refused at build()),
// apiKey (String; blank refused — passed as MB_APIKEY in the engine's environment, never argv,
// and sent by the returned client), requireAdminAuth (bool, --require-admin-auth, rift >= 0.17.0
// checked at build(); a no-op on the default loopback bind), metricsPort (1..65535,
// --metrics-port; unset = the engine's 9090, which two spawned engines contend for; a taken
// port is not fatal — the engine runs without metrics). An MB_APIKEY in env() is refused at
// build(), and one inherited from the JVM is not passed on.
// maxBodySize (long bytes >= 1, --max-body-size; over it 413), bodyMemoryBudget (>= 0, 0 = none,
// --body-memory-budget; over it 503 + Retry-After), journalMaxBytes (>= 1, --journal-max-bytes;
// a body larger than it is recorded without it, RecordedRequest.bodyOmitted()): rift >= 0.22.0,
// checked at build(), which also refuses a non-zero budget below the ceiling (the engine's
// 512 MiB default when unset), or a ceiling above its 1 GiB default budget with none set.
// Binary resolution order: binaryPath → $RIFT_BINARY_PATH → PATH (rift) →
// version cache (~/.cache/rift-java/binaries/rift-<ver>/) → download via release
// ffi-manifest.json (SHA-256 verified; RIFT_OFFLINE/RIFT_SKIP_BINARY_DOWNLOAD → fail).
}
public final class EmbeddedOptions {
// libraryPath (Path — overrides resolution), versionCheck FAIL|WARN|OFF,
// serveAdminEagerly (bool, default false), adminHost/adminPort/apiKey (for rift_serve_admin;
// adminHost is an IP literal, IPv6 bare or bracketed, and is also the host imposters
// report in uri()), upstreamTrust (UpstreamTrust, rift >= 0.18.0 — needs the engine to
// advertise the option in EngineInfo.serveOptions, else EngineUnavailable; serves the
// admin API eagerly),
// allowInjection / requireAdminAuth (Optional<Boolean>; unset = engine default, i.e. scripts refused
// on the admin plane — which replaceAll, events and recordedSince go through, so replaceAll of a
// script-bearing imposter needs allowInjection(true); create/applyConfig are C-ABI, ungated),
// metricsPort (1..65535; unset = no metrics listener; serves the admin API eagerly, so a taken
// port fails Rift.embedded). Each needs the engine to advertise it in EngineInfo.serveOptions
// (rift >= 0.17.0), like upstreamTrust.
// maxBodySize / bodyMemoryBudget / journalMaxBytes (OptionalLong; as for SpawnOptions, checked at
// build(); advertised from rift 0.22.0; each serves the admin API eagerly, since the engine
// applies them only to imposters created afterwards).
// scriptsDir (Optional<Path>; rift_serve_admin's scriptsDir, advertised from rift 0.24.0): the only
// root an imposter posted through the plane may read lookup CSVs, require() modules and file:
// scripts from (rift#1617). Unset, 0.24.0 refuses such a lookup on the plane, replaceAll
// included. Not eager: the plane takes it whenever it is served. SpawnOptions.scriptsDir is the
// same root as --scripts-dir.
}
public sealed interface UpstreamTrust { // outbound TLS trust for proxying/recording (#209)
record CaFile(Path path) …; record CaPem(String pem) …; record SkipVerify() …;
String MIN_ENGINE_VERSION = "0.18.0";
static boolean supportedBy(String engineVersion); // the up-front gate spawn and RiftContainer share
}
// RiftContainer.withUpstreamTrust(UpstreamTrust) (#248): all three variants; the PEM (file or
// inline) is copied into the container and named via RIFT_UPSTREAM_CA_FILE, SkipVerify sets
// RIFT_UPSTREAM_TLS_SKIP_VERIFY; an image tag that is a version below 0.18.0 is refused.
Every http://host:port URI the SDK builds goes through transport.HostAuthority
(bracketed(host), httpUri(host, port)), which brackets a bare IPv6 literal as the engine's own
URLs do (#232).
5.2 Rift.embedded() lives in core, implementation in rift-java-embedded¶
Core defines:
package io.github.achirdlabs.rift.transport;
public interface EmbeddedEngineProvider {
boolean isAvailable(); // native lib resolvable for this OS/arch
EmbeddedEngine start(EmbeddedOptions options); // record EmbeddedEngine(RiftTransport transport,
// Runnable onClose)
}
Rift.embedded() ServiceLoader-locates the provider; if absent it throws EngineUnavailable
with the exact dependency coordinates to add (rift-java-embedded + the natives classifier
for the current platform). This keeps the "one client, three transports" story in a single
entry-point class while preserving the JDK-17 core / JDK-22 embedded split.
5.3 Transport SPI (internal)¶
package io.github.achirdlabs.rift.transport;
public interface RiftTransport extends AutoCloseable {
JsonValue createImposter(JsonValue definition); // returns created definition (with port)
JsonValue getImposter(int port); // ImposterNotFound on 404
JsonValue getImposter(int port, boolean replayable, boolean removeProxies);
void deleteImposter(int port); // idempotent: an absent port is a no-op (rift#1371)
void deleteAll();
JsonValue listImposters(boolean replayable, boolean removeProxies);
void replaceAllImposters(JsonValue impostersDoc);
JsonValue applyConfig(JsonValue config);
void addStub(int port, JsonValue stub);
default void addStub(int port, JsonValue stub, int index);
void replaceStubs(int port, JsonValue stubs);
void replaceStub(int port, StubAddress address, JsonValue stub); // index- or id-addressed
void deleteStub(int port, StubAddress address);
JsonValue getStub(int port, StubAddress address); // default: UnsupportedOperationException
JsonValue recorded(int port); // savedRequests
default RecordedSlice recordedSince(int port, OptionalLong since, List<MatchClause> match);
void clearRecorded(int port);
default void clearRecorded(int port, List<MatchClause> match);
void clearProxyResponses(int port);
void enable(int port); void disable(int port);
// scenarios
JsonValue scenarios(int port, Optional<String> flowId);
void setScenarioState(int port, String name, String state, Optional<String> flowId);
void resetScenarios(int port, Optional<String> flowId);
// flow state
Optional<JsonValue> flowStateGet(int port, String flowId, String key);
void flowStatePut(int port, String flowId, String key, JsonValue value);
void flowStateDelete(int port, String flowId, String key);
void flowStateClear(int port, String flowId); // DELETE /admin/imposters/{port}/flow-state/{flowId} | rift_flow_state_clear (optional, >= 0.22.0)
// spaces
void spaceAddStub(int port, String flowId, JsonValue stub);
JsonValue spaceListStubs(int port, String flowId);
JsonValue spaceRecorded(int port, String flowId);
void spaceDelete(int port, String flowId);
// intercept
JsonValue startIntercept(JsonValue options);
default Optional<JsonValue> interceptStatus(); // GET /intercept | embedded: the admin plane's GET /intercept once one is served (it shares the FFI slot, rift#521), else the FFI-started listener
void interceptAddRules(JsonValue rules);
default void interceptReplaceRules(JsonValue rules); // PUT /intercept/rules | rift_intercept_replace_rules (>= 0.20.0)
JsonValue interceptListRules(); void interceptClearRules();
String interceptCaPem();
// events
default EventStream events(EventStreamOptions options);
// engine
JsonValue buildInfo(); URI adminUri();
JsonValue verify(int port, JsonValue body); // default: UnsupportedOperationException
JsonValue stubWarnings(int port); // default: UnsupportedOperationException
}
getStub, verify, stubWarnings, the indexed addStub, recordedSince, the filtered
clearRecorded and events are default methods (throwing UnsupportedOperationException
unless overridden) so existing RiftTransport implementations and test fakes keep compiling.
Implementations: RemoteTransport (java.net.http, admin HTTP), EmbeddedTransport
(FFM ↔ the C-ABI table below). spawn = RemoteTransport + process lifecycle. Everything
above RiftTransport (handles, verification, DSL) is transport-agnostic — this is what makes
"full surface on each transport" cheap to guarantee and lets the conformance harness run the
same corpus over both.
Embedded mapping (C-ABI v2): createImposter→rift_create_imposter, replaceStubs→
rift_replace_stubs, recorded→rift_recorded, deleteImposter→rift_delete_imposter,
deleteAll→rift_delete_all, applyConfig→rift_apply_config, flowState*→rift_flow_state_*,
space*→rift_space_*, startIntercept→rift_start_intercept, intercept*→rift_intercept_*,
buildInfo→rift_build_info, adminUri→rift_serve_admin (lazy, once). The v2-admin long tail is
now direct FFI too (rift ≥ 0.13.1): getImposter/listImposters→rift_get_imposter/
rift_list_imposters, addStub→rift_add_stub, getStub→rift_get_stub, replaceStub→
rift_update_stub, deleteStub→rift_delete_stub, clearRecorded→rift_clear_recorded,
clearProxyResponses→rift_clear_proxy_recordings, enable/disable→
rift_set_imposter_enabled, scenarios→rift_scenarios, setScenarioState→
rift_set_scenario_state, resetScenarios→rift_reset_scenarios, verify→rift_verify,
stubWarnings→rift_stub_warnings. The operations with no C-ABI counterpart — replaceAllImposters
(bulk PUT /imposters), events (GET /events), and the cursor/clause reads recordedSince plus
the scoped clearRecorded (?since=/match=, which the C-ABI cannot express) — still route
through the lazily-started in-process admin server. Ownership discipline (per-call confined arenas,
rift_free, sentinel + same-thread rift_last_error, static build_info never freed, symbol-probe
for graceful degradation) is encapsulated once in the bridge (issue #8).
6. The handle: Imposter¶
public interface Imposter {
int port();
URI uri(); // spawn/embedded: the imposter's own bind host when concrete
// (#243); otherwise, and always for connect/testcontainers,
// ConnectOptions.hostResolver. IPv6 bracketed (HostAuthority).
// Scheme = the imposter's protocol (http/https, #250).
Optional<String> name();
ImposterDefinition definition(); // live GET (includes recorded state if requested)
// -- stubs --
StubRef addStub(StubSpec spec); // returns index- (and id-, if set) addressed ref
StubRef addStub(StubSpec spec, int index); // insert at position (0 = first, highest priority)
StubRef addStubFirst(StubSpec spec); // sugar for (spec, 0) — the overlay idiom (addStubFirst → ref.delete() reverts)
StubRef addStub(JsonValue stub); // escape hatch
StubRef addStub(JsonValue stub, int index); // escape hatch
StubRef addStubFirst(JsonValue stub); // escape hatch
void replaceStubs(List<StubSpec> specs);
void replaceStubs(JsonValue stubs); // escape hatch; JsonArray (List<JsonValue> would erase to the line above)
StubRef stub(String id); // id-addressed handle; ImposterNotFound-style miss → EngineError
List<Stub> stubs();
// -- recorded requests + verification (§8) --
List<RecordedRequest> recorded();
List<RecordedRequest> recorded(RequestMatch match); // engine-evaluated (verify + includeRequests, #337)
RecordedPage recordedPage(); // baseline: retained + cursor (§8)
RecordedPage recordedPage(MatchClause... filters); // server-side filtered (§8.2)
RecordedPage recordedSince(long cursor); // tail poll: strictly newer than cursor
RecordedPage recordedSince(long cursor, MatchClause... filters);
void clearRecorded();
void clearRecorded(MatchClause... filters); // scoped clear
void clearProxyResponses(); // DELETE savedProxyResponses
void verify(RequestMatch match); // == atLeastOnce(); engine-evaluated (§7.7)
void verify(RequestMatch match, VerificationTimes times);
VerificationResult verifyResult(RequestMatch match, VerifyDetail... details); // non-throwing, typed
VerificationResult verifyResult(RequestMatch match, VerificationTimes times, VerifyDetail... details);
void verifyNoInteractions();
// -- record / playback --
Recording startRecording(String originUrl); // proxy the origin, capture stubs
Recording startRecording(String originUrl, RecordSpec spec); // RecordMode, predicate generators, …
// -- scenarios (FSM) --
Scenarios scenarios();
// -- spaces + flow state --
Space space(String flowId);
FlowState flowState(String flowId);
// -- lifecycle --
void enable(); void disable();
boolean isEnabled(); // definition().enabled(): the engine's current view, one GET
void delete(); // DELETE /imposters/:port; handle unusable after; idempotent (an absent imposter is a no-op, rift#1371)
}
public interface Recording extends AutoCloseable {
List<Stub> stop(); // stop proxying, return the captured stubs
List<Stub> snapshot(); // captured so far, still recording
void persist(Path path); // replayable JSON (Rift.create(String) reads it back)
}
public interface StubRef {
int index(); Optional<String> id();
Stub definition();
void replace(StubSpec spec); void delete(); // from rift 0.23.0 a by-index replace restarts the response cycle; by id keeps it (rift#1515)
void replace(JsonValue stub); // escape hatch
}
public interface Scenarios {
record State(String name, String state) {}
List<State> list();
List<State> list(String flowId); // space-scoped FSM state
String state(String name);
void setState(String name, String state);
void setState(String name, String state, String flowId); // space-scoped FSM write
void reset();
void reset(String flowId); // space-scoped FSM reset
}
public interface Space {
String flowId();
StubRef addStub(StubSpec spec);
StubRef addStub(JsonValue stub); // escape hatch
List<Stub> stubs();
List<RecordedRequest> recorded();
List<RecordedRequest> recorded(RequestMatch match);
RecordedPage recordedPage(MatchClause... filters); // flow-scoped cursor reads (§8.1)
RecordedPage recordedSince(long cursor, MatchClause... filters);
void verify(RequestMatch match); // same semantics as Imposter.verify, space-scoped
VerificationResult verifyResult(RequestMatch match, VerifyDetail... details); // space-scoped (flowId)
VerificationResult verifyResult(RequestMatch match, VerificationTimes times, VerifyDetail... details);
void verify(RequestMatch match, VerificationTimes times);
void delete(); // tears down stubs + recorded + state for the space
}
public interface FlowState {
Optional<JsonValue> get(String key); // the stored value itself, on every transport (#236)
void put(String key, JsonValue value);
void put(String key, String value);
void delete(String key);
void clear(); // every key in the flow; idempotent (embedded: rift >= 0.22.0)
}
Imposter, Space, FlowState, Scenarios are thin transport-backed views — no client-side
caching, every call hits the engine (test code wants truth, not staleness).
Flow-state / spaces configuration (uniform across transports). The engine backs the flow-state
and per-space APIs with a real store only when the def declares one — an explicit _rift.flowState,
a scenario stub (scenarioName/requiredScenarioState/newScenarioState), a _rift.script
stub, or an is response with _rift.stateOps (§7.3; the engine checks this when the imposter is
created); otherwise, before rift 0.23.0, it uses a silent no-op store (reads return empty). From
rift 0.23.0 every imposter gets an in-memory store (300 s TTL) by default (rift#1504), and the SDK
warns about a trigger-less def only on an older engine. Separately, a per-space stub only
matches when a header-form flowIdSource is configured (flowIdFromHeader(...)), because the
engine's flow-id source defaults to the imposter port. The SDK does not auto-inject a flow store
(a transport is a faithful wire mapping — it never rewrites user input); instead it fails fast and
warns: ImposterSpec.build() throws if a space stub is declared without a header flowIdSource, and
Imposter.space()/flowState() log one advisory warning on a source-less / trigger-less def. Declare
flowState(inMemoryFlowState().flowIdFromHeader("X-Your-Header")) for spaces.
Blank flow ids are rejected at the facade. Every caller-supplied flowId (Imposter.space/
flowState, Scenarios.list/setState/reset, MatchClause.flowId, StubSpec.inSpace) must be non-null
and non-blank: a blank id is never the default flow but a distinct, silently-wrong engine partition
(and on the flow-state DELETE path a destructive misroute), so ""/whitespace throws
IllegalArgumentException and null throws NullPointerException. Any non-blank id passes through
verbatim — no trim, no normalization. The RiftTransport SPI receives ids already validated and
never re-checks them.
7. DSL v2 — closing the surface gaps¶
RiftDsl remains the single static-import hub (WireMock model). Grammar and existing entry
points are unchanged; the following is added/fixed.
7.1 Collision policy (documented in the class javadoc)¶
equalTo(String|JsonValue)becomes the primary equality matcher name (WireMock muscle memory;equalscannot be usefully static-imported because ofObject.equals).eqstays as the terse alias;equalsoverloads stay for discoverability. Note:eqcollides withMockito.eq,equalTowith Hamcrest — no name is globally safe; the javadoc shows the qualified-import fix.exactly(n)is added as an alias fortimes(n)becauseMockito.timesis ubiquitous in the same test files (wildcard-importing both otherwise forces qualification).
7.2 Compile-time response typing (kills the IllegalStateException guard)¶
public sealed interface ResponseSpec permits IsSpec, ProxySpec, FaultSpec, InjectSpec, ScriptSpec { }
ok()/okJson()/created()/noContent()/notFound()/status(n)returnIsSpec— the only type carrying status/body/header chain methods.- Behavior chainers live on
BehaviorChain<S>(§7.3), implemented byIsSpec,ProxySpecandInjectSpec— the three shapes the engine runs behaviors on (proxy/inject from rift 0.18.0, #215). fault(Fault)→FaultSpec,script(Script)→ScriptSpec: their one chain method isrepeat(int), the only behavior the engine runs on them (rift >= 0.18.0, engine-version gated at create/replaceAll; #229).Response.Fault/Response.RiftScriptcarry the whole behaviors block typed, so any other key round-trips unchanged.inject(js)→InjectSpec(behavior chainers only).willReturn(ResponseSpec)accepts them all; call it again per response for response cycling.ImposterSpec.defaultResponse(IsSpec)is typed to the engine's actual constraint (defaultResponse is anisresponse, no behaviors).response()is theIsSpecthat names no status. From rift 0.22.0 the engine fills anisresponse's unwrittenstatusCode,headersand_modefrom the imposter'sdefaultResponse(rift#1369); without a default it answers 200.IsResponse.omittedrecords which of the three a parsed response left unwritten (isWritten(Field)), so a read → re-post keeps that absence, and a written"headers": {}or"_mode": "text"stays written and keeps the default out (#302).Faultbecomes a plain 4-value enum mirroring the engine (and WireMock) exactly:CONNECTION_RESET_BY_PEER, EMPTY_RESPONSE, RANDOM_DATA_THEN_CLOSE, MALFORMED_RESPONSE_CHUNK. The currentFault.latencySpike(Duration)hybrid moves to the probabilistic_riftfault surface below.
7.3 IsSpec methods and the shared BehaviorChain (full behavior + _rift coverage)¶
// RiftDsl factory: okJsonRaw(String) serves the JSON verbatim (byte-for-byte, no reparse),
// unlike okJson(String) which parses + canonicalizes — for payloads whose exact form matters.
IsSpec withBinaryBody(byte[] bytes) // base64 + _mode=binary
IsSpec withBodyFromCodec(Object pojo) // via RiftBodyCodec SPI (§10)
// Behavior chainers: one home, shared by IsSpec, ProxySpec and InjectSpec (#215). Each returns
// the spec kind it was called on, so behaviors can sit anywhere in a chain.
public sealed interface BehaviorChain<S extends ResponseSpec & BehaviorChain<S>>
permits IsSpec, ProxySpec, InjectSpec {
S withBehavior(Behavior behavior); // the one abstract method
S after(Duration d); S waitMs(long ms); S waitBetween(long minMs, long maxMs);
S decorate(String script); S repeat(int count);
S copy(CopySpec... copies) // _behaviors.copy (array wire form)
S copyObject(CopySpec copy) // _behaviors.copy (single-object wire form)
S lookup(LookupSpec... lookups) // _behaviors.lookup (array wire form)
S lookupObject(LookupSpec lookup) // _behaviors.lookup (single-object wire form)
S shellTransform(String... commands) // one typed step per command: one is
// "shellTransform": "cmd", several go out as
// one behaviors-array element each (#234);
// from rift 0.24.0 a command gets request and
// response JSON as two args (+ MB_REQUEST/
// MB_RESPONSE) and must print a JSON response
// object, e.g. printf '{"body":"x"}' # (#339)
S waitInject(String script) // _behaviors.wait as {"inject": ...}; a rift superset, not portable to Mountebank (rift#608)
S waitScript(String source) // _behaviors.wait as a bare function string (the Mountebank-compatible spelling)
}
// On proxy/inject the block is written beside `proxy`/`inject` in the same shape as beside `is`,
// and Response.Proxy/Response.Inject carry typed Behaviors. rift < 0.18.0 accepts the block there
// and drops it silently, so Rift.create(ImposterDefinition) and replaceAll refuse such a definition
// with InvalidDefinition when the engine reports 0.13.1 <= version < 0.18.0 (VersionCheck.OFF skips
// the check; a below-floor placeholder version is not treated as old, nor, in WARN mode, an
// unreadable one; the raw-JSON create overloads, addStub, replaceStubs, StubRef.replace and
// applyConfig are not inspected). Every per-feature floor below works the same way.
// both wait spellings are one injection capability: the engine must run with --allowInjection or it
// rejects the imposter with 400 (rift#610). Fixed/{min,max} waits are unaffected.
// Calling one chainer twice appends two entries. The `_behaviors` object cannot hold a repeated
// key, so the write switches to the `behaviors` array form, one element per entry (#217). The array
// form is also written when lookup/copy/shellTransform/decorate are chained out of the engine's
// fixed object order (wait, lookup, copy, shellTransform, decorate): rift >= 0.18.0 and Mountebank
// run an array in the order written but an object in that fixed order, so chain order is run order
// (#230). wait, repeat and unknown keys do not count towards order. An object read from the wire is normalised to the order it runs in, so it writes back as
// the equivalent canonical object. Otherwise the object form is kept, so existing output is
// byte-unchanged. Carrying a
// repeated key is not running it: rift 0.17.0 merges that array on parse and applies only the last
// entry, in its own fixed order; Mountebank runs both.
// `repeat` has two wire spellings: inside the block, and as a response-level field beside `is`
// (Mountebank's canonical one, and what its save writes). Both are read as `Behavior.Repeat` and
// written back in the spelling they arrived in, never moved (#216) — the two are not
// interchangeable, since rift 0.17.0 honours only the block one. When both are present both are
// kept; `Behaviors.effectiveRepeat()` reports which applies. The DSL's `repeat(int)` emits the
// block spelling.
// From rift 0.24.0 the write follows what that engine admits: a `_behaviors` object beside a
// response-level `repeat` is ignored (rift#1709), so the block is then written as the array; an
// array element may not hold `repeat` (rift#1582, rift#1672), so in the array form the effective
// `repeat` is written beside the response and any other is dropped; and an element may not hold a
// list of `copy`/`lookup` (rift#1639), so a list is written one item per element and read back the
// same way. This trades rift 0.17.0's block-only `repeat` for an imposter the pinned engine accepts.
// The read follows the same rule (rift#1709, Mountebank's upcast): a `behaviors` key (even `null`)
// or a response-level `repeat` voids a `_behaviors` object beside it, so the voided block is not
// read and a read + write never starts running it. The legacy `extra`-map constructors keep both.
IsSpec templated() // rename of template(); _rift.templated
// declarative flow-state writes after the response (_rift.stateOps, rift >= 0.18.0; #227). Run in
// chain order after templating and behaviors; engine-version gated at create/replaceAll.
IsSpec setState(String key, String valueTemplate) // {"op":"set"}; value rendered as a template
IsSpec incrementState(String key) // {"op":"increment","by":1}
IsSpec incrementState(String key, long by)
IsSpec deleteState(String key) // {"op":"delete"}
IsSpec clearFlowState() // {"op":"clearFlow"}
// declarative conditional GET (_rift.conditional, rift >= 0.20.0; #263). A 2xx GET/HEAD answer gets a
// strong ETag over the served bytes and a Last-Modified; If-None-Match / If-Modified-Since that match
// get a bodyless 304. The engine computes the ETag (no explicit-ETag form exists) and replaces any
// ETag/Last-Modified header set here. Engine-version gated at create/replaceAll (conditional:false,
// the engine's off state, is not) — like every per-feature gate, not on Imposter.addStub/replaceStubs;
// refused on an intercept serve rule. The last call wins.
IsSpec conditional() // true: ETag + Last-Modified = stub load time
IsSpec conditional(Instant lastModified) // {"lastModified":"Mon, 05 Jan 2026 08:09:10 GMT"}
IsSpec conditionalWithoutEtag() // {"etag":false}
IsSpec conditionalWithoutEtag(Instant lastModified) // {"etag":false,"lastModified":"…"}
// A fixed lastModified is an IMF-fixdate (two-digit day, GMT, whole seconds) served verbatim — not
// RFC_1123_DATE_TIME, which writes a one-digit day. Read back as RiftResponseExtension.conditional():
// Optional<RiftConditional>, sealed Enabled(boolean) | Validators(Optional<Boolean> etag,
// Optional<String> lastModified), absent fields kept absent so the engine's echo round-trips.
// probabilistic _rift faults (chainable, composable):
IsSpec withLatencyFault(double probability, Duration min, Duration max)
IsSpec withLatencyFault(double probability, Duration fixed)
IsSpec withErrorFault(double probability, int status)
IsSpec withErrorFault(double probability, int status, String body)
IsSpec withErrorFault(double probability, int status, String body, Map<String, String> headers)
IsSpec withTcpFault(Fault kind) // always fires (bare wire form)
IsSpec withTcpFault(double probability, Fault kind) // probabilistic object form; requires rift >= 0.13.2 (rift#531)
// helper factories on RiftDsl:
static CopySpec copyFrom(String from) // .into("$TOKEN").using(regex(...)|jsonPath(...)|xPath(...))
// xPath(sel, ns) writes Mountebank's `ns` (rift >= 0.22.0, rift#1326)
static CopySpec copyFromQuery(String name) // copy from {"query": name}
static CopySpec copyFromHeader(String name) // copy from {"headers": name}
static LookupSpec lookupKey(String from) // .using(...).fromCsv(path, keyColumn).into("$ROW")
static LookupSpec lookupKeyFromQuery(String name) // key.from {"query": name}
static LookupSpec lookupKeyFromHeader(String name) // key.from {"headers": name}
// .index(n) key.index, Mountebank's meaning: regex 0 = whole match, n = nth capture group;
// jsonPath/xPath = nth selected value. Unset = first group / first value.
// .fromCsv(path, keyColumn, ';') csv.delimiter (engine default ','); both emitted only when set
// .fromCsv(path, keyColumn, "||") a multi-character csv.delimiter (rift >= 0.24.0, rift#1566); empty is refused
7.4 New StubSpec methods¶
StubSpec inSpace(String flowId) // stub.space
StubSpec withId(String id) // stub.id (enables by-id ops)
StubSpec withRoute(String pattern) // stub.routePattern ("/users/:id")
StubSpec named(String name) // stub.name: a label, never matched; rift >= 0.22.0 prints it back
StubSpec withPredicateInject(String script) // PredicateOperation.Inject
// WireMock-style stub-level scenario sugar (alternative to ScenarioSpec):
StubSpec inScenario(String name)
StubSpec whenScenarioState(String state)
StubSpec willSetScenarioState(String state)
ScenarioSpec.stubs() gains fail-fast validation: startingAt(...) must have been called
(missing start state currently emits an unsatisfiable guard silently).
7.5 New ImposterSpec methods (imposter-level config + _rift)¶
ImposterSpec host(String bindHost)
ImposterSpec https(String certPem, String keyPem) // both-or-neither, validated at build
ImposterSpec requireClientCertificate() // mutualAuth: any client certificate (#210)
ImposterSpec requireClientCertificate(String... caPems) // mutualAuth + rejectUnauthorized + ca: chain to one of these
// The two client-auth states rift accepts; every other combination is a 400 there, so the DSL cannot
// build one (https-only, checked at build; >= 1 PEM holding a certificate, checked at the call).
// ImposterDefinition models mutualAuth / rejectUnauthorized / ca (CaCertificates keeps the string
// vs array spelling) without validating, so any engine output parses. Rift.create/replaceAll refuse
// any of mutualAuth / rejectUnauthorized / ca on an engine older than 0.18.0, which drops them and
// accepts every client (same OFF / placeholder / WARN-unreadable exceptions as above).
ImposterSpec defaultForward(String upstreamUrl)
ImposterSpec strictBehaviors()
ImposterSpec enabled(boolean) // false: created paused ("enabled": false, written only then)
ImposterSpec disabled() // enabled(false)
ImposterSpec serviceName(String name)
ImposterSpec serviceInfo(JsonValue info)
ImposterSpec allowCors() // exists
ImposterSpec flowState(FlowStateSpec spec) // _rift.flowState
ImposterSpec metrics(int port) // _rift.metrics — @Deprecated(since 0.2.4): engine ignores it; use --metrics-port
ImposterSpec scriptEngine(ScriptEngine engine, Duration timeout) // _rift.scriptEngine (defaultEngine honoured by engine >= 0.18.0)
ImposterSpec script(String name, Script script) // _rift.scripts named registry
ImposterSpec proxyPool(int maxIdlePerHost, Duration idleTimeout) // _rift.proxy.connectionPool — @Deprecated(since 0.2.4): engine ignores it
ImposterSpec recordMatches() // recordMatches — @Deprecated(since 0.2.4): engine ignores it; use record()
// factories:
static FlowStateSpec inMemoryFlowState() // .ttl(Duration).flowIdFromHeader(name)
7.6 ProxySpec typed predicate generators¶
ProxySpec generateBy(RequestField... fields) // enum METHOD, PATH, QUERY, HEADERS, BODY,
// REQUEST_FROM, IP, FORM (rift >= 0.22.0)
ProxySpec generateBy(PredicateGeneratorSpec generator) // .matching(fields).caseSensitive(b), and from rift 0.22.0:
// .jsonPath(sel) | .xPath(sel[, ns]) (one body selector)
// .ignore(QUERY|HEADERS|FORM, names...) (headers Title-Cased)
// .predicateOperator(GeneratorOperator) .except(regex)
ProxySpec addWaitBehavior() // proxy.addWaitBehavior: records upstream latency on recorded stubs
ProxySpec decorateWith(String script) // proxy.addDecorateBehavior: decorates the stubs this proxy records
ProxySpec clientCertificate(String certPem, String keyPem[, String passphrase])
// proxy.cert/key/passphrase: presented to an mTLS upstream
// (rift >= 0.24.0, rift#1609; gated: an older engine drops
// them silently). A pair by construction; PEM blocks checked
// up front, key/cert matching left to the engine (400). (#342)
// not to be confused with BehaviorChain.waitMs/decorate (§7.3), which act on this proxy's own
// response — before it is served and before it is recorded.
7.6a Multi-field predicates: merge vs and (cross-SDK contract, #312)¶
static PredicateSpec merge(PredicateSpec first, PredicateSpec... more)
// merge(path(eq("/kcs")), header("X-Tenant", eq("acme"))).keyCaseSensitive(true)
// -> {"equals":{"path":"/kcs","headers":{"X-Tenant":"acme"}},"keyCaseSensitive":true}
mergebuilds one operator object carrying every field;andbuilds a list of predicates. Both require every field to match.- Matcher parameters (
caseSensitive,keyCaseSensitive,except) and the selector apply to the whole predicate they are set on. The engine evaluatesand/or/notchildren with their own parameters and ignores the combinator's, soand(path(..), header(..)).keyCaseSensitive(true)does not make the header key case-sensitive. Usemergeand set parameters on its result. - The parts must be field predicates sharing one operator (any of the seven,
existsincluded) with no parameters or selector of their own. Header and query parts join one object by name; names differing only in case are both kept. Any conflict fails fast with anIllegalArgumentExceptionnaming it. - Other SDKs should use the name
merge(rift-scala'sallOfalready meansand).
7.7 Verification grammar (single vocabulary, WireMock-quality diffs)¶
StubSpec implements RequestMatch — the same openers stub and verify:
users.verify(onGet("/api/users/1"), times(1));
users.verify(onPost("/api/users").withHeader("Content-Type", contains("json")), atLeast(2));
List<RecordedRequest> hits = users.recorded(onGet("/api/users/1"));
public interface RequestMatch {
List<Predicate> predicates();
// verification-path escape hatch (mirrors the raw-JSON creation hatches — §1.3):
static RequestMatch of(List<Predicate> predicates); static RequestMatch of(Predicate... predicates);
static RequestMatch ofJson(JsonValue predicateArray); static RequestMatch ofJson(String predicateArrayJson);
}
Verification defers to the engine (#127). verify POSTs /imposters/{port}/verify and the
engine's evaluator — the same one the request hot path uses — decides; this SDK never re-implements
matching, so a verdict can never drift from a stub's. Consequences: inject/xpath predicates are
honoured in verify (not rejected client-side); a custom RiftTransport must implement verify;
remote requires an engine with POST /imposters/{port}/verify (rift#494). recorded(match) is the same
engine call with includeRequests (#337), returning the matching requests rather than a count;
verifyNoInteractions is a client-side emptiness check, not a predicate verdict.
verifyResult is the same verification as a value — the typed seam a bridge renders its own
assertion failures from (no diff-string scraping):
VerificationResult verifyResult(RequestMatch match, VerifyDetail... details); // satisfied = atLeastOnce
VerificationResult verifyResult(RequestMatch match, VerificationTimes times, VerifyDetail... details);
enum VerifyDetail { REQUESTS, CLOSEST } // each opt-in: none → counts only, no journal shipped
record VerificationResult(int matched, int total, boolean satisfied,
List<RecordedRequest> requests, // empty unless REQUESTS requested
Optional<ClosestMiss> closest) { // present iff CLOSEST requested and a non-match existed
static VerificationResult read(JsonValue envelope, VerificationTimes times); // bridge-facing parse seam
}
record ClosestMiss(RecordedRequest request, List<FailedPredicate> failedPredicates) {}
record FailedPredicate(Predicate predicate, JsonValue actual) {} // actual: raw — shape follows the predicate
// an off-contract envelope is a CommunicationError (the RiftException hierarchy — §1.4), as in EngineInfo/ApplyResult.read
public final class VerificationTimes { // factories re-exported on RiftDsl
static VerificationTimes times(int n); static VerificationTimes exactly(int n); // alias
static VerificationTimes atLeast(int n); static VerificationTimes atMost(int n);
static VerificationTimes between(int min, int max); static VerificationTimes never();
}
verify and recorded(match) are evaluated by the engine (above). Core's PredicateEvaluator, a
JVM copy of Mountebank's predicate semantics, is deprecated for removal (#337) and no longer called:
it had drifted from the engine three times (#330, #335, and rift 0.24.0's array rules, rift#1553 /
rift#1579), which is the failure mode an engine-evaluated design exists to rule out.
On failure, VerificationException extends AssertionError renders the engine's verdict, with its
closest non-matching request when it reports one:
Verification failed for imposter :4545 ("users")
Expected: GET /api/users/1 — exactly 1 time, but was 0.
3 recorded requests.
Closest miss:
✗ GET /api/users/2 → 404 in 3 ms
failed {"equals":{"path":"/api/users/1"}} — actual {"path":"/api/users/2"}
Each request line is RecordedRequest.summary(); the outcome (→ 404 in 3 ms) appears on rift ≥
0.18.0. verifyNoInteractions lists up to 10 of the recorded requests instead ("…, most recent
first:"). recordRequests must be on for verification — verify on a non-recording imposter
throws InvalidDefinition("imposter :4545 does not record requests — add .record()").
8. RecordedRequest (typed, lossless)¶
public record RecordedRequest(
String method, String path,
Map<String, List<String>> query,
Map<String, List<String>> headers,
String body, // raw; binary arrives base64 per engine contract
Optional<Instant> timestamp,
Optional<String> requestFrom, // client ip:port
Optional<String> flowId,
Map<String, String> pathParams, // when routePattern matched
JsonValue raw) { // lossless escape hatch
public OptionalInt status(); // rift >= 0.18.0: status it was answered with (#228)
public OptionalLong latencyMs(); // rift >= 0.18.0: ms to answer; absent while in flight
public String summary(); // "METHOD path → status in N ms", or "METHOD path"
// without an outcome; empty method "?", path "/"
public static RecordedRequest read(JsonValue value); // lenient: never throws
public Optional<JsonValue> bodyAsJson(); // empty if not parseable
public <T> T bodyAs(Class<T> type); // via the registered RiftBodyCodec (§10)
public Optional<String> header(String name); // first value; case-sensitive, matching the wire key
}
RecordedRequest deliberately carries no index. The engine's journal index is per response,
not per entry — it rides in x-rift-next-index on savedRequests and in id: on the SSE stream,
and the body stays a bare array. An index field here would be empty on every path (rift#603, and
the ruling on #130).
8.1 RecordedPage — the tail cursor¶
public record RecordedPage(
List<RecordedRequest> requests,
OptionalLong nextIndex, // x-rift-next-index; empty = DO NOT advance
boolean truncated) {} // x-rift-truncated; retention evicted unseen entries
Tailing by tracking an offset into recorded() looks equivalent and is not: array positions shift
under the 10k retention cap and under DELETE savedRequests, so an offset silently skips or replays
entries. The engine assigns a stable, 1-based, per-port index instead.
| Field | Contract |
|---|---|
nextIndex present |
Pass back verbatim as the next since. A present 0 is a real cursor: nothing recorded yet. |
nextIndex empty |
Older engine / no stable indices / degraded partial read — indistinguishable by design, because the answer to all three is the same: keep the cursor you hold and poll again. Never an error, never synthesized from an array offset. |
truncated |
A hole in your view: the cap evicted entries you had not seen. Re-baseline with recordedPage(). A signal, not an error — deleting data you asked to delete is not a hole, so a cursor held across a clear is never flagged. |
Canonical tail: recordedPage() once, then recordedSince(nextIndex) on an interval, updating the
cursor each time and re-baselining on truncated. Server-side match= filtering composes with the
cursor via additive overloads — see #138.
Space.recordedPage(filters...) / Space.recordedSince(cursor, filters...) (#149) are the same
cursor scoped to one flow: the SDK prepends the space's flow_id clause to the caller's filters
and delegates to the same transport read. The index stays the imposter's journal index — a
space cursor and an imposter cursor are the same number and interchangeable, and a space tail's
cursor advances even while only other flows record (the engine advances past filter-rejected
entries). A caller-supplied flow_id clause is rejected with IllegalArgumentException: clauses
AND, so a second flow_id either duplicates the scope or silently selects nothing. Because the
flow_id clause is always present, every space cursor call is a filtered read — a transport that
could not filter server-side would refuse per §8.2 rather than widen. All of them can: the FFI
transport delegates rather than evaluating clauses itself.
Transport SPI: RiftTransport.recordedSince(port, OptionalLong, List<MatchClause>) returns the raw
RecordedSlice{requests, nextIndex, truncated}. Its default serves the full list with an empty
cursor, which is exactly what a cursor-less engine does. Every shipped transport overrides it — the
in-process FFI one delegates to its own admin server (#175), since the C-ABI carries neither the
cursor nor the clauses.
8.2 MatchClause — server-side filtering¶
public sealed interface MatchClause {
static MatchClause header(String name, String value); // match=header:<Name>=<Value>
static MatchClause flowId(String flowId); // match=flow_id=<Value>
static MatchClause method(String method); // match=method=<Verb> (engine >= 0.15.0)
static MatchClause path(String path); // match=path=<Path> (engine >= 0.15.0)
}
method and path (#148) need a rift engine ≥ 0.15.0; the SDK's compatibility floor stays lower
because every other clause works below it. There is no sniffing and no fallback — an older engine
rejects the clause with a 400 that surfaces as InvalidDefinition, which is the correct answer: a
tail that quietly returned unfiltered pages would cross-contaminate exactly what the filter exists to
separate. method is compared case-sensitively and path against the bare recorded path —
still percent-encoded, never carrying the query string, which the engine keeps as a separate field.
MatchClause is not RequestMatch. They answer different questions and do not convert:
RequestMatch (§7.7) |
MatchClause |
|
|---|---|---|
| What | Mountebank predicate set | journal/stream filter clause |
| Evaluated by | the engine's predicate engine, via POST /verify |
the engine's journal filter, via match= |
| Grammar | full — equals/contains/matches/jsonPath/xpath/and/or/not |
closed — exact equality on header, flow id, method, path |
| Used for | verify(match, times), recorded(match) |
recordedPage/recordedSince/clearRecorded filters, Space.recordedPage/recordedSince (auto-injected flow_id, #149), /events?match= (#131) |
The grammar is closed because the engine's is: it rejects a clause it cannot parse with a 400 rather
than serving everything, since a filter that silently widened would cross-contaminate correlated
scenarios. The sealed type mirrors that — an invalid clause is not constructible. The header name is
held to the HTTP token grammar, which is stricter than the engine's own empty-name check on purpose:
a name containing = would not 400, it would re-split into a different name and value and filter
on something the caller never asked for. Silently meaning something else is the failure mode the
closed grammar exists to prevent, so the one input that could cause it is rejected at construction.
The same rule rejects clauses the engine would accept but could never match, because a filter that
silently returns nothing is that failure wearing the opposite mask: a non-token method, and a
path that is blank, relative, or carries a ?/# (compared whole against a bare path, it would
match nothing forever without ever erroring). Query-parameter and body predicates are deliberately
absent — the engine's grammar excludes them, so RequestMatch with a client-side filter remains the
answer there.
Clauses AND together and are applied after the cursor cut, and the cursor advances past entries a clause rejected — so a filtered page can be empty while its cursor still moves, and a filtered tail never re-scans a range it has already judged.
Encoding is the transport's job, not the caller's. The clause is percent-encoded whole: the engine
splits a query pair on its first = and decodes only afterwards, so the :/= that give a clause
its structure survive while a %, & or space in a caller's value cannot escape into the query. A
space must ride as %20 — the engine decodes URIs, not forms, so + would stay a literal +.
A transport that could not filter server-side would refuse a filtered read or scoped clear rather than widen it: answering unfiltered would return the entries the caller excluded, and a widened clear would delete the ones they kept. Neither is expressible client-side anyway — a recorded entry carries no resolved flow id. Every transport the SDK ships can filter, the FFI one by delegating (#175), so the refusal is the SPI's contract for a custom transport rather than a state any shipped one is in.
8.3 EventStream — the push tail¶
EventStream events(EventStreamOptions options); // on Rift; GET /events
public sealed interface RiftEvent {
OptionalLong seq(); // the SSE id:; a gap means reconcile
record Hello(String engineVersion, long seqAtConnect, List<String> types, OptionalInt port)
record RequestRecorded(OptionalLong seq, int port, OptionalLong index, Optional<String> flowId,
RecordedRequest request)
record ImposterChanged(OptionalLong seq, Action action, OptionalInt port) // ALL_DELETED has no port
record Lagged(long missed) // non-fatal: your view has a hole
}
public interface EventStream extends AutoCloseable, Iterable<RiftEvent> { }
Pull, not push-to-a-callback: hasNext() blocks. rift-scala's bridge wraps one blocking connector
(its D4), and both its backends build a stream from a blocking iterator in a line
(ZStream.fromIteratorZIO, Stream.fromBlockingIterator) — so D6's promise that the SSE upgrade
leaves their stream types unchanged holds. A callback API would force every wrapper to rebuild a
queue-and-thread to un-invert it.
Deliberately not Stream<RecordedRequest>: lifecycle changes and Lagged are not recorded
requests, and flattening them away drops exactly the signals a tail needs to stay correct.
How iteration ends — three causes, three meanings:
| Cause | Behaviour |
|---|---|
close() |
hasNext() returns false — a graceful end |
Engine disconnects, or goes silent past idleTimeout (default 45s = three missed 15s heartbeats) |
hasNext() throws EngineUnavailable |
| Engine refuses the connect (401, bad filter) | events(...) throws; you never get a stream |
Death is loud on purpose: ending quietly on a dropped connection is indistinguishable from "nothing
is happening", and a tail cannot tell those apart. Lagged is not fatal — it is the stream saying
your view has a hole, and iteration continues.
No replay and no auto-reconnect. The stream is one connection's worth of events; polling stays
the source of truth. RequestRecorded.index is the same journal index RecordedPage.nextIndex()
reports, so the canonical loop is:
recordedPage() → baseline cursor
events() → hello → iterate, tracking RequestRecorded.index
→ on Lagged / seq gap / EngineUnavailable:
recordedSince(lastIndex) → reconnect → resume
Reconnect policy is the caller's: a retry schedule belongs to whatever drives the loop
(Schedule, fs2 retries), not baked into a blocking connection.
EventStreamOptions: types(REQUESTS, LIFECYCLE) (default both), port(int),
match(MatchClause...) (§8.2 — request events only), idleTimeout(Duration).
Capability probe: events() throws UnsupportedOperationException when streaming is unavailable —
an engine too old to serve /events (404). The caller's move is to poll, which is the supported
baseline, not a degraded mode. A 404 for a port(...) that names no imposter is not that: the
SDK confirms with GET /imposters/{port} and throws ImposterNotFound, as every per-port call does.
Transport coverage is total, embedded included: the FFI transport delegates events() to the same
lazily-started in-process admin server it already delegates replaceAllImposters to, and that
server's stream taps the engine's admin event bus — which hangs off the imposter manager, so the
FFI data plane's own traffic and lifecycle changes publish to it. The cost is that the first
events() call there starts that server, unless EmbeddedOptions.serveAdminEagerly already did.
Transport note: the SPI's events() returns the typed stream rather than raw JsonValue — a
long-lived stream has no JSON envelope to hand back. Its reader is one daemon thread per stream
feeding a bounded queue, so a slow consumer becomes TCP backpressure and the engine answers with
lagged, exactly as it is designed to; handing the body to the shared HttpClient executor would
park a pool thread every other admin call needs.
9. Intercept (TLS-MITM) — typed surface¶
public interface InterceptRuleSet { // declares rules; Intercept adds each, replaceRules stages them (#262)
InterceptRule serve(String host, IsSpec response); // rule: answer host directly
InterceptRule forward(String host, String target); // "port" | "host:port" | "http(s)://host:port" — host/https need rift >= 0.20.0
InterceptRule redirectTo(String host, Imposter imposter);
InterceptRuleBuilder rule(); // predicate-scoped + optional-host: rule().host(h).when(match).serve/forward/redirectTo
}
public interface Intercept extends InterceptRuleSet, AutoCloseable {
InetSocketAddress address(); // for ProxySelector / http.proxyHost
URI uri();
Optional<URI> engineAddress(); // the engine's reported bind address; empty when attached
ProxySelector proxySelector(); // convenience for java.net.http clients
List<InterceptRule> replaceRules(Consumer<? super InterceptRuleSet> declare); // one atomic swap (rift >= 0.20.0)
List<InterceptRule> replaceRules(List<InterceptRule> rules); // re-install a filtered/reordered rules()
boolean removeRule(InterceptRule rule); // rules() -> filter (echo-normalized) -> replace; not atomic vs. a concurrent add
List<InterceptRule> rules(); void clearRules();
InterceptTrust trust();
Optional<CaMaterial> caMaterial(); // generateCa() only: record CaMaterial(certPem, keyPem)
@Override void close(); // stops an owned listener (re-arms intercept()); attached: clears rules
}
public interface InterceptTrust {
String caPem();
SSLContext sslContext(); // in-memory truststore with the CA (hermetic SUT)
SSLContext sslContextWithSystemCAs(); // CA + JVM default anchors (SUT also calls real HTTPS)
void exportTruststore(TruststoreFormat format, String password, Path out); // PKCS12 | JKS
void exportTruststoreWithSystemCAs(TruststoreFormat format, String password, Path out); // + system anchors
}
public final class InterceptOptions { // builder
// port (0 = OS-assigned), host (IP literal); committed CA (both-or-neither, else ephemeral) as:
// ca(Path cert, Path key) // engine reads the files from its own fs
// ca(String|byte[] cert, key) | ca(KeyStore, pw) // inline PEM in the start body (rift >= 0.13.4) — no mount
// generateCa() // engine mints one; retrieve via intercept.caMaterial()
// -> intercept.caMaterial(): Optional<Intercept.CaMaterial(String certPem, String keyPem)> (generateCa only)
}
serve projects the IsSpec onto the engine's serve action: a numeric status, headers and a text
body. A repeated header goes out as one line per value, which needs rift ≥ 0.18.0; on an older
engine it is refused with InvalidDefinition unless the version check is off (#231). Anything
else — behaviors, any _rift extension (templating, script, faults, stateOps), a binary body —
is rejected with InvalidDefinition rather than silently dropped (#207).
replaceRules stages the declared rules (every serve check applies) and sends them in one PUT
/intercept/rules / rift_intercept_replace_rules; an engine before 0.20.0 is refused with
InvalidDefinition before the lambda runs, and an embedded library without the symbol still loads
(the call throws EngineUnavailable). A forward target with a host or https scheme is gated the
same way; a loopback http target keeps the port-only wire every engine reads. The JUnit 5 extension
re-applies an InterceptRuleSet rules method per test with one replaceRules, falling back to clear
+ add on an older engine (#262).
Remote transport maps to /intercept/* admin endpoints (rift ≥ 0.13.3 POST /intercept starts a
listener at runtime, #493); embedded maps to rift_start_intercept / rift_intercept_*.
InterceptOptions.attach(host, port) binds to a listener the engine started at launch
(--intercept-port / RIFT_INTERCEPT_PORT) instead of starting one — used by
RiftContainer.withInterceptPort(...) + interceptOptions(); withInterceptCa(...) launches it with a
committed CA, which attach(host, port, CaMaterial) carries back as caMaterial() after checking it
against the served certificate (#259). RiftContainer.withExposedInterceptPort(...)
instead exposes a port with no listener at launch, for a runtime start (with the caller's CA) that
client() maps to Docker's port; the two modes are mutually exclusive, refused eagerly, because the
engine runs one listener (#258). A started listener's bind host must be
an IP literal; an attach host may be any reachable host, and an IPv6 one is bracketed in uri().
Target ergonomics: the rift-java-demo ~30-line hand-rolled flow becomes ~8 lines (issue #12's goal).
10. Body codec SPI (rift-java-jackson becomes real)¶
package io.github.achirdlabs.rift.codec;
public interface RiftBodyCodec {
JsonValue toJson(Object value);
<T> T fromJson(JsonValue json, Class<T> type);
}
- Discovered via
ServiceLoader(first hit wins; explicitRiftDsl.useBodyCodec(codec)override for non-classpath setups). - Enables DSL overloads:
okJson(Object),IsSpec.withBodyFromCodec(Object),equalTo(Object),deepEquals(Object), andRecordedRequest.bodyAs(Class<T>). - Without a codec on the classpath these overloads throw
IllegalStateExceptionnaming therift-java-jacksoncoordinates. Core stays zero-dep.
11. JUnit 5 (rift-java-junit5)¶
Two-tier, Testcontainers/WireMock conventions:
@RiftTest // AUTO transport, per-test reset
class UserApiTest {
@RiftImposter // static spec field → created once per class
static ImposterSpec users = imposter("users").record()
.stub(onGet("/api/users/1").willReturn(okJson("{\"id\":1}")));
@Test
void findsUser(@InjectImposter("users") Imposter users, // injection is explicit: @InjectImposter,
@InjectRift Rift rift) { // @InjectRift, @InjectIntercept (fields too)
// point SUT at users.uri() …
users.verify(onGet("/api/users/1"), times(1));
}
}
@RiftTest(transport = Transport.EMBEDDED, // EMBEDDED | SPAWN | CONNECT | AUTO
reset = Reset.PER_TEST, // PER_TEST (default) | PER_CLASS | NONE
adminUri = "…", // CONNECT only; property-resolvable
dumpRecordedOnFailure = true) // default false: failed test → recorded dump
Transport.AUTO(default): embedded ifRift.isEmbeddedAvailable(), otherwise spawn; a spawn that cannot start fails with its ownEngineUnavailable.- Intercept:
@RiftIntercept(class) starts a listener for the class;@RiftInterceptRules(astatic voidmethod, paramsIntercept/@InjectRift/@InjectImposter) declares rules, applied on start and re-applied after each per-test rules reset;@InjectInterceptinjects the live handle. CA (caCert/caKey) andexportTruststoreattributes cover the shared-CA / containerized-SUT flows. - One
Riftengine per test class (static) — per-method engine via instance-field@RegisterExtension. Reset.PER_TEST: between tests, recorded requests, scenario state and recorded proxy responses are cleared (imposters are NOT recreated — ports stay stable for the class lifetime). Flow state is not cleared, and stubs a test added are not removed.- Programmatic tier:
@RegisterExtension
static RiftTestExtension rift = RiftTestExtension.newInstance()
.transport(Transport.SPAWN).spawnOptions(SpawnOptions.builder()…build()) // or embeddedOptions /
.imposter(imposter("users")…) // connectOptions
.reset(Reset.PER_TEST)
.build();
- On test failure with
dumpRecordedOnFailure, the extension publishes each imposter's recorded requests as a JUnit report entry keyedrift.recorded.<name>, oneRecordedRequest.summary()per line. The failure message itself is unchanged.
12. Spring Boot (rift-java-spring, new module)¶
Model: wiremock-spring-boot (named instances + user-chosen property keys), not
@ServiceConnection (wrong abstraction for generic HTTP deps — there is no ConnectionDetails
type to target).
@SpringBootTest(webEnvironment = RANDOM_PORT)
@EnableRift(transport = Transport.AUTO)
@ConfigureImposter(name = "users", baseUrlProperty = "user-client.base-url")
@ConfigureImposter(name = "payments", baseUrlProperty = "payment-client.base-url",
spec = PaymentImposters.class) // Supplier<ImposterSpec> for pre-stubbing
class OrderServiceIT {
@InjectImposter("users") Imposter users; // field injection
@InjectRift Rift rift;
@Test void ordersCallUsers() {
users.addStub(onGet("/api/users/1").willReturn(okJson("{\"id\":1}")));
// … drive the app; its user-client.base-url already points at the imposter …
users.verify(onGet("/api/users/1"), times(1));
}
// parameter injection (equivalent) — zero-config via @EnableRift's @ExtendWith meta-annotation
@Test void ordersCallPayments(@InjectImposter("payments") Imposter payments, @InjectRift Rift r) {
payments.addStub(onPost("/pay").willReturn(created()));
}
}
Implementation contract (so the implementer has zero decisions):
- A ContextCustomizerFactory (registered in META-INF/spring.factories) starts one Rift
engine per application context (cached with the context), creates the named imposters
before the Environment is frozen, and registers a MapPropertySource
("rift", highest test precedence) with each baseUrlProperty → imposter.uri() (and
optional portProperty). Same-named config across test classes → same cache key → context
reuse is preserved (the wiremock-spring-boot lesson: never pollute the context with beans).
- A TestExecutionListener resets per test method (same semantics as JUnit5 Reset.PER_TEST)
and handles @InjectImposter/@InjectRift field injection; a RiftParameterResolver
(auto-registered by @ExtendWith meta-annotated on @EnableRift) handles the same
annotations as @Test/lifecycle method parameters — no extra @ExtendWith required.
- Engine shutdown via context-closed event.
- No Spring beans are added to the user's context; the module depends only on
spring-test/spring-context (provided scope) + core.
13. Wire-model fidelity additions¶
- Every aggregate record (
ImposterDefinition,Stub,IsResponse,ProxyResponse, and eachResponsevariant —Is,Proxy,Inject,Fault,RiftScript) gains a trailingMap<String, JsonValue> extracomponent: unknown keys are preserved on read and re-emitted (insertion order) on write — mirroringBehavior.Unknown. This is required for corpus replay of real engine output (issues #7/#14) and future-proofs against engine additions. - The same
extracarrier sits on both_riftblocks and on_rift.flowState(RiftResponseExtension,RiftConfig,RiftFlowStateConfig; #226). rift 0.18.0 addeddatasetto the response-level block (itsstateOpsis modelled —List<StateOp>, §7.3, #227) andsequencingto the imposter-level one; flowState carries provider-store options. The DSL never fills these maps, so its output is unchanged; a modeled key placed inextrais rejected at construction. - Lenient reads already handled (statusCode string/number,
behaviorsalias,allowCorsfrom pre-0.22 exports, and a non-booleanallowCORSread by JavaScript truthiness, rift#1453). A leftoverflowState.backendlands inextra(rift 0.22.0 dropped it, rift#1463). Add: flat/recorded response form (top-level statusCode/headers/body withoutiswrapper, engine issue #304) — read asIs, write canonical. rift-verify's_verifystays a rawJsonValuepassthrough (it is a corpus annotation, not an engine field; the SDK never interprets it).
14. Versioning, BOM, capability negotiation¶
rift-java-bompins all modules + the natives classifier jars. The natives are versioned with the SDK (0.3.x) and bundle the pinned engine'slibrift_ffi(rift.engine.version, 0.18.0).record EngineInfo(String version, String commit, Set<String> features, Set<String> serveOptions)— fromGET /config/rift_build_info.featurespowers honest capability negotiation for the conformance harness (Plane-Brequire(caps…));serveOptionsgates embeddedupstreamTrust.- Version preflight:
versionCheck FAIL|WARN|OFFagainstRiftImpl.MIN_ENGINE_VERSION(0.13.1, the C-ABI v2 floor; not bumped per release), uniform across the three transports. Newer features carry their own floors, checked atcreate/replaceAll(§7.3).
15. What is deliberately NOT in v1¶
- Reactive/streaming recorded-request tail. Both primitives now exist — the poll cursor
(
recordedPage()/recordedSince(), §8.1, #130) and the push stream (events(), §8.3, #131). The streaming sugar stays downstream (rift-scala'sZStream/fs2 tails wrapevents()'s iterator); this SDK ships the connection, not the effect system's stream type. - OpenAPI → imposter import (differentiator; backlog).
- TCP/SMTP protocols — the engine itself is HTTP/HTTPS only.
16. Issue mapping¶
| Surface | Issue |
|---|---|
| Client + handles + remote transport | #4 (expanded) |
| Spawn transport | #5 (expanded) |
| Verification | #6 (expanded) |
| FFM bridge + transport SPI | #8 (expanded) |
| Natives | #9 (unchanged) |
Rift.embedded() + ServiceLoader |
#10 (expanded) |
| jdk21 dual-publish | #11 (unchanged) |
| Intercept | #12 (expanded) |
| JUnit 5 | #13 (expanded) |
| DSL v2 completeness + response typing | #20 |
Wire-model fidelity + ImposterDefinition rename |
#21 |
| Body codec SPI + Jackson | #22 |
| Spring Boot module | #23 |
| BOM | #24 |
Record/playback sugar (startRecording, @RiftGolden) |
#25 (shipped) |
| Testcontainers module | #26 (shipped) |