Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion docs/intercept.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,29 @@ intercept.redirectTo("api.partner.com", partnerImposter);
```

`serve`'s response is an `IsSpec` — the same response builder the DSL uses for stub responses
(`RiftDsl.ok()`, `okJson(...)`, `status(code)`, and so on). `redirectTo` rides the same
(`RiftDsl.ok()`, `okJson(...)`, `status(code)`, and so on) — but the engine's serve action is
narrower than that builder. It carries **only** a numeric status code, **single-valued** headers,
and a text body.

A response using anything beyond that is **rejected** with an `InvalidDefinition`, and the rule is
not registered:

- any behavior — `after`/`waitMs`, `decorate`, `repeat`, `copy`, `lookup`, `shellTransform`;
- any `_rift` extension — `templated()`, a script, or a fault (`withLatencyFault`,
`withErrorFault`, `withTcpFault`);
- a binary body (`withBinaryBody`), which the serve action can only carry as its base64 text;
- a repeated header — `withHeader(name, a, b)`.

```java
// Throws InvalidDefinition: the serve action has no fault concept, so without this the rule
// would register happily and then answer a plain 200.
intercept.serve("example.com", status(200).withTextBody("b").withTcpFault(Fault.CONNECTION_RESET_BY_PEER));

// Point at an imposter instead — it is a real stub, so every construct above works there.
intercept.redirectTo("example.com", faultyImposter);
```

`redirectTo` rides the same
wire-level `forward` action as `forward` itself, pointed at `imposter.port()`; a rule read back
from `intercept.rules()` therefore only ever reports `RuleKind.SERVE` or `RuleKind.FORWARD` — the
engine has no way to echo back that a `forward` action originated from `redirectTo`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,20 @@ public interface Intercept extends AutoCloseable {
/** A {@link ProxySelector} routing every request through this intercept — convenience for {@code java.net.http.HttpClient}. */
ProxySelector proxySelector();

/** Adds a rule answering requests to {@code host} directly with {@code response}, without contacting the real host. */
/**
* Adds a rule answering requests to {@code host} directly with {@code response}, without
* contacting the real host.
*
* <p>The engine's serve action carries only a numeric {@code statusCode}, <em>single-valued</em>
* {@code headers} and a text {@code body}. A response using anything else — any behavior
* ({@code wait}/{@code decorate}/{@code repeat}/{@code copy}/{@code lookup}/{@code
* shellTransform}), any {@code _rift} extension ({@code templated}, {@code script}, or a
* latency/error/TCP fault), a binary body, or a repeated header — is rejected here rather than
* silently dropped. Use {@link #redirectTo} to reach an imposter, which has full stub fidelity.
*
* @throws io.github.achirdlabs.rift.error.InvalidDefinition if {@code response} carries a
* construct the serve action cannot deliver; the rule is not registered
*/
InterceptRule serve(String host, IsSpec response);

/** Adds a rule forwarding requests to {@code host} on to {@code hostPort} (a {@code host:port} on localhost). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.github.achirdlabs.rift.dsl.IsSpec;
import io.github.achirdlabs.rift.error.CommunicationError;
import io.github.achirdlabs.rift.error.InvalidDefinition;
import io.github.achirdlabs.rift.json.JsonArray;
import io.github.achirdlabs.rift.json.JsonNumber;
import io.github.achirdlabs.rift.json.JsonObject;
Expand All @@ -10,6 +11,7 @@
import io.github.achirdlabs.rift.model.IsResponse;
import io.github.achirdlabs.rift.model.Predicate;
import io.github.achirdlabs.rift.model.Response;
import io.github.achirdlabs.rift.model.ResponseMode;
import io.github.achirdlabs.rift.transport.RiftTransport;

import java.net.InetSocketAddress;
Expand Down Expand Up @@ -217,13 +219,16 @@ public void close() {
* {@code statusCode}, single-valued {@code headers}, and a plain-text {@code body} (see
* {@code ServeStub} in {@code intercept_rules.rs}) — narrower than the full {@code is} response
* shape a stub uses (multi-value headers, a structured JSON body, behaviors, faults). Anything
* beyond status/headers/body on the given response is not carried over: the intercept Serve
* action has no behaviors/faults concept.
* beyond status/headers/body is <em>rejected</em> rather than dropped — see
* {@link #requireDeliverable}.
*
* @throws InvalidDefinition if the response carries a construct the serve action cannot deliver
*/
private static JsonObject toServeStub(IsSpec response) {
if (!(response.build() instanceof Response.Is is)) {
throw new IllegalStateException("unreachable: IsSpec.build() always returns Response.Is");
}
requireDeliverable(is);
IsResponse ir = is.is();
JsonObject.Builder builder = JsonObject.builder();
builder.put("statusCode", JsonNumber.of(statusAsInt(ir.statusCode())));
Expand All @@ -240,6 +245,64 @@ private static JsonObject toServeStub(IsSpec response) {
return builder.build();
}

/**
* Rejects a response the intercept {@code serve} action cannot deliver.
*
* <p>The engine's {@code ServeStub} is only {@code {statusCode, headers, body}} with
* <em>single-valued</em> headers, and its deserializer does not use {@code deny_unknown_fields} —
* so a richer response posted here is accepted with a {@code 200} and then silently ignored at
* request time. That is worse than a rejection: a fault-injection test written against a
* {@code serve} rule stays green while asserting on the success response it never asked for.
*
* <p>Every offending construct is collected in one pass so a caller learns about all of them at
* once rather than one exception per round trip. Ordering is deterministic: behaviors keep their
* declaration order and headers are insertion-ordered by {@code JsonSupport.orderedCopy}.
*
* @throws InvalidDefinition naming every offending construct, and pointing at
* {@link Intercept#redirectTo}, which reaches a real imposter and so has full stub fidelity
*/
static void requireDeliverable(Response.Is is) {
List<String> undeliverable = new ArrayList<>();
is.behaviors().entries().forEach(behavior -> undeliverable.add("_behaviors." + behavior.key()));
is.rift().ifPresent(rift -> {
rift.fault().ifPresent(fault -> {
if (fault.latency().isPresent()) {
undeliverable.add("_rift.fault.latency (withLatencyFault)");
}
if (fault.error().isPresent()) {
undeliverable.add("_rift.fault.error (withErrorFault)");
}
if (fault.tcp().isPresent()) {
undeliverable.add("_rift.fault.tcp (withTcpFault)");
}
});
if (rift.script().isPresent()) {
undeliverable.add("_rift.script");
}
if (rift.templated()) {
undeliverable.add("_rift.templated (templated)");
}
});
IsResponse ir = is.is();
if (ir.mode() == ResponseMode.BINARY) {
undeliverable.add("a binary body (_mode=binary, withBinaryBody)");
}
ir.headers().forEach((name, values) -> {
if (values.size() > 1) {
undeliverable.add("repeated header '" + name + "'");
}
});
is.extra().keySet().forEach(key -> undeliverable.add("response key '" + key + "'"));
ir.extra().keySet().forEach(key -> undeliverable.add("is response key '" + key + "'"));

if (!undeliverable.isEmpty()) {
throw new InvalidDefinition("intercept serve cannot deliver " + String.join(", ", undeliverable)
+ " — the engine's serve action carries only statusCode, single-valued headers and body, so"
+ " the rule would be registered and then answer a response you did not ask for."
+ " Use redirectTo(imposter) for full stub fidelity.");
}
}

private static int statusAsInt(String statusCode) {
try {
return Integer.parseInt(statusCode);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,16 @@ public InterceptRuleBuilder when(RequestMatch match) {
return this;
}

/** Answers matching requests inline with {@code response}; the real host is never contacted. */
/**
* Answers matching requests inline with {@code response}; the real host is never contacted.
*
* <p>Restricted to what the engine's serve action can carry — status, single-valued headers and
* a text body. See {@link Intercept#serve} for the full deliverable set; anything outside it is
* rejected rather than silently dropped.
*
* @throws io.github.achirdlabs.rift.error.InvalidDefinition if {@code response} carries a
* construct the serve action cannot deliver; the rule is not registered
*/
public InterceptRule serve(IsSpec response) {
return intercept.addServeRule(host, predicates, response, RuleKind.SERVE);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package io.github.achirdlabs.rift.error;

/** The engine rejected an imposter/stub/config definition (HTTP 400). */
/**
* A definition was rejected: either by the engine (HTTP 400), or by this SDK before it was sent,
* when the target wire format cannot carry what the definition asks for — see
* {@link io.github.achirdlabs.rift.Intercept#serve}, whose action is narrower than the response
* builder it accepts.
*/
public final class InvalidDefinition extends RiftException {

public InvalidDefinition(String message) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
* base class.
*
* <ul>
* <li>{@link InvalidDefinition} — the engine rejected a definition (HTTP 400).
* <li>{@link InvalidDefinition} — a definition was rejected, by the engine (HTTP 400) or by this
* SDK when the target wire format cannot carry it.
* <li>{@link EngineUnavailable} — the engine could not be reached at all (connection refused,
* spawn failure, a failed version preflight).
* <li>{@link CommunicationError} — the engine answered successfully but the response body could
Expand Down
Loading
Loading