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
9 changes: 9 additions & 0 deletions docs/design/sdk-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,15 @@ public sealed interface BehaviorChain<S extends ResponseSpec & BehaviorChain<S>>
// 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.
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
<maven.compiler.release>17</maven.compiler.release>

<!-- Pinned Rift engine release the natives + version preflight track (no leading 'v'). -->
<rift.engine.version>0.23.0</rift.engine.version>
<rift.engine.version>0.24.0</rift.engine.version>

<!-- rift-java-bom's resolve-through-bom invoker IT is self-validation, not a prerequisite for
the BOM artifact. It runs at integration-test (before install) and needs network to resolve
Expand Down
6 changes: 3 additions & 3 deletions rift-java-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ holding `manifest.json` and `corpus/`):
```bash
# Download + extract the corpus for the pinned engine version, then:
./mvnw -pl rift-java-conformance -am test \
-Drift.corpus.root=/path/to/sdk-conformance-v0.23.0
-Drift.corpus.root=/path/to/sdk-conformance-v0.24.0
```

Resolution order for the corpus: `-Drift.corpus.root` → `RIFT_CORPUS_ROOT` → `target/corpus`. When
Expand All @@ -69,7 +69,7 @@ cached on first use):

```bash
RIFT_IT=1 ./mvnw -pl rift-java-conformance -am verify \
-Drift.corpus.root=/path/to/sdk-conformance-v0.23.0
-Drift.corpus.root=/path/to/sdk-conformance-v0.24.0
```

On JDK 21 or 22+, `-am` also builds `rift-java-embedded` (or `-embedded-jdk21`), whose integration
Expand All @@ -80,7 +80,7 @@ Over the embedded transport (JDK 21 or 22+, with a `librift_ffi` for your platfo

```bash
RIFT_IT=1 CONFORMANCE_TRANSPORT=EMBEDDED ./mvnw -pl rift-java-conformance -am verify \
-Drift.corpus.root=/path/to/sdk-conformance-v0.23.0 \
-Drift.corpus.root=/path/to/sdk-conformance-v0.24.0 \
-Drift.ffi.lib=/path/to/librift_ffi-<platform>.<ext>
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
*/
enum ConformanceTransport {

/** The engine as a spawned process; relative {@code data/} paths resolve via the working directory. */
/**
* The engine as a spawned process. Relative {@code data/} paths resolve against the corpus root,
* which is also the {@code --scripts-dir} a lookup's CSV must sit under from rift 0.24.0 (rift#1617).
*/
SPAWN {
@Override
boolean isAvailable() {
Expand All @@ -25,6 +28,7 @@ Rift engine(Corpus corpus) {
return Rift.spawn(SpawnOptions.builder()
.version(corpus.engineVersion())
.workingDir(corpus.payloadRoot())
.scriptsDir(corpus.payloadRoot())
.allowInjection(true)
.build());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import io.github.achirdlabs.rift.SpawnOptions;
import org.junit.jupiter.api.DynamicTest;

import java.nio.file.Path;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assumptions.assumeTrue;
Expand Down Expand Up @@ -33,6 +34,18 @@ static Rift engine() {
};
}

/**
* The engine for the selected lane, reading lookup CSVs and modules under {@code scriptsDir}. A
* spawned engine needs it as {@code --scripts-dir} from rift 0.24.0 (rift#1617); the embedded
* host calls are not confined, so that lane ignores it.
*/
static Rift engine(Path scriptsDir) {
return switch (ConformanceTransport.selected()) {
case SPAWN -> Rift.spawn(SpawnOptions.builder().scriptsDir(scriptsDir).build());
case EMBEDDED -> Rift.embedded();
};
}

/**
* A claim that holds on every transport: gated only on the live-engine lane being enabled and
* the selected one being startable here.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ class LookupGrammarIT {
@TestFactory
Stream<DynamicTest> indexDelimiterAndHeaderKeyResolveTheRow() {
return gated("lookup keys on a regex capture group by index, reads a ';' CSV, and keys on a header", () -> {
Path csv = Files.createTempFile("rift-lookup-", ".csv");
Files.writeString(csv, "id;name\n7;seven\n42;forty-two\n");
try (Rift rift = engine()) {
Path dir = Files.createTempDirectory("rift-lookup-");
Path csv = Files.writeString(dir.resolve("tenants.csv"), "id;name\n7;seven\n42;forty-two\n");
try (Rift rift = engine(dir)) {
Imposter imp = rift.create(imposter("lookup")
.stub(onGet("/items/7/42").willReturn(ok().withTextBody("${row}[name]")
.lookup(lookupKey("path").using(regex("/items/(\\d+)/(\\d+)")).index(2)
Expand All @@ -49,6 +49,7 @@ Stream<DynamicTest> indexDelimiterAndHeaderKeyResolveTheRow() {
assertEquals("seven", get(imp, "/tenant", "7"), "the key came from the X-Tenant header");
} finally {
Files.deleteIfExists(csv);
Files.deleteIfExists(dir);
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public final class SpawnOptions {
private final String logLevel;
private final Map<String, String> env;
private final Optional<Path> workingDir;
private final Optional<Path> scriptsDir;
private final Optional<URI> mirrorUrl;
private final Duration startupTimeout;
private final Duration shutdownTimeout;
Expand All @@ -51,6 +52,7 @@ private SpawnOptions(Builder b) {
this.logLevel = b.logLevel;
this.env = Map.copyOf(b.env);
this.workingDir = b.workingDir;
this.scriptsDir = b.scriptsDir;
this.mirrorUrl = b.mirrorUrl;
this.startupTimeout = b.startupTimeout;
this.shutdownTimeout = b.shutdownTimeout;
Expand Down Expand Up @@ -104,6 +106,11 @@ public Optional<Path> workingDir() {
return workingDir;
}

/** The engine's {@code --scripts-dir}, if set; see {@link Builder#scriptsDir(Path)}. */
public Optional<Path> scriptsDir() {
return scriptsDir;
}

public Optional<URI> mirrorUrl() {
return mirrorUrl;
}
Expand Down Expand Up @@ -166,6 +173,7 @@ public static final class Builder {
private String logLevel = "info";
private final Map<String, String> env = new LinkedHashMap<>();
private Optional<Path> workingDir = Optional.empty();
private Optional<Path> scriptsDir = Optional.empty();
private Optional<URI> mirrorUrl = Optional.empty();
private Duration startupTimeout = Duration.ofSeconds(15);
private Duration shutdownTimeout = Duration.ofSeconds(5);
Expand Down Expand Up @@ -255,6 +263,19 @@ public Builder workingDir(Path workingDir) {
return this;
}

/**
* The engine's {@code --scripts-dir}: the only directory an imposter created through this
* client may read {@code lookup} CSV files, {@code require()} modules and {@code file:} scripts
* from. Unset by default. From rift 0.24.0 (rift#1611, rift#1617) an imposter written over the
* admin API reads none of these without it, and its {@code lookup} is refused with
* {@code 400}; a relative {@code lookup} path resolves against this directory, not
* {@link #workingDir(Path)}.
*/
public Builder scriptsDir(Path scriptsDir) {
this.scriptsDir = Optional.of(Objects.requireNonNull(scriptsDir, "scriptsDir"));
return this;
}

public Builder mirrorUrl(URI mirrorUrl) {
this.mirrorUrl = Optional.of(Objects.requireNonNull(mirrorUrl, "mirrorUrl"));
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ public record Behaviors(List<Behavior> entries) {
*/
private static final List<String> CANONICAL_ORDER = List.of("lookup", "copy", "shellTransform", "decorate");

/** The list-valued keys an array element holds one item of (rift#1639); shellTransform is already one step per command. */
private static final Set<String> SPLIT_IN_ARRAY_FORM = Set.of("copy", "lookup");

/**
* Reads the object form. An engine runs an object's transforming behaviors in {@link
* #CANONICAL_ORDER} whatever order they were written in, so they are read in that order: the
Expand Down Expand Up @@ -90,6 +93,9 @@ static Behaviors read(JsonObject obj) {
* The SDK writes one key per element, but the engine also accepts an element holding several
* keys and runs them in its fixed object order ({@code wait, lookup, copy, shellTransform,
* decorate}, then any other key alphabetically), so such an element is expanded in that order.
* A list-valued {@code copy} or {@code lookup} element, which an engine before 0.24.0 ran item by
* item, is read as one entry per item: that is what {@link #toJsonArray()} writes back, since
* rift 0.24.0 refuses the list there (rift#1639).
*/
static Behaviors readArray(JsonArray arr) {
List<Behavior> entries = new ArrayList<>();
Expand All @@ -101,11 +107,18 @@ static Behaviors readArray(JsonArray arr) {
entry.fields().entrySet().stream()
.sorted(Comparator.comparingInt((Map.Entry<String, JsonValue> e) -> engineRank(e.getKey()))
.thenComparing(Map.Entry::getKey))
.forEach(e -> entries.addAll(Behavior.readAll(e.getKey(), e.getValue())));
.forEach(e -> entries.addAll(readElementKey(e.getKey(), e.getValue())));
}
return new Behaviors(entries);
}

private static List<Behavior> readElementKey(String key, JsonValue value) {
if (SPLIT_IN_ARRAY_FORM.contains(key) && value instanceof JsonArray items && !items.items().isEmpty()) {
return items.items().stream().flatMap(item -> Behavior.readAll(key, item).stream()).toList();
}
return Behavior.readAll(key, value);
}

/** A key's position in the engine's fixed object order; any other key sorts after, alphabetically. */
private static int engineRank(String key) {
if (key.equals("wait")) {
Expand Down Expand Up @@ -187,11 +200,27 @@ Behaviors withoutResponseLevelRepeat() {
return block.size() == entries.size() ? this : new Behaviors(block);
}

/** The array form: one single-key element per entry, in entry order. Never lossy. */
/** These entries minus every {@code repeat}: what an array-form block may hold. */
Behaviors withoutRepeats() {
return new Behaviors(entries.stream().filter(entry -> !(entry instanceof Behavior.Repeat)).toList());
}

/**
* The array form: one single-key element per entry, in entry order. Never lossy. A list-valued
* {@code copy} or {@code lookup} is written as one element per item, which runs the same: from
* rift 0.24.0 an element holding a list is refused, as in Mountebank (rift#1639).
*/
JsonArray toJsonArray() {
return new JsonArray(entries.stream()
.map(entry -> (JsonValue) JsonObject.builder().put(entry.key(), entry.value()).build())
.toList());
List<JsonValue> elements = new ArrayList<>();
for (Behavior entry : entries) {
JsonValue value = entry.value();
if (SPLIT_IN_ARRAY_FORM.contains(entry.key()) && value instanceof JsonArray items && !items.items().isEmpty()) {
items.items().forEach(item -> elements.add(JsonObject.builder().put(entry.key(), item).build()));
} else {
elements.add(JsonObject.builder().put(entry.key(), value).build());
}
}
return new JsonArray(elements);
}

public boolean isEmpty() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.achirdlabs.rift.model;

import io.github.achirdlabs.rift.json.JsonNull;
import io.github.achirdlabs.rift.json.JsonObject;
import io.github.achirdlabs.rift.json.JsonString;
import io.github.achirdlabs.rift.json.JsonValue;
Expand Down Expand Up @@ -220,15 +221,22 @@ static Response read(JsonObject obj) {
* Reads behaviors from either shape: the {@code _behaviors} object every fixture uses, or the
* {@code behaviors} array-of-single-key-objects the engine itself emits in {@code GET /imposters}
* output. Accepting the array form keeps consuming engine output from silently dropping them.
*
* <p>Which block runs follows Mountebank's rule, which rift 0.24.0 adopted (rift#1709): a
* {@code behaviors} key (a {@code null} one included) or a response-level {@code repeat} voids
* {@code _behaviors} whole, its {@code repeat} too. A voided block is not read, so a read and a
* write never turn on behaviors the engine was ignoring.
*/
private static Behaviors readBehaviors(JsonObject obj) {
JsonValue underscore = obj.get("_behaviors");
if (underscore != null) {
return Behaviors.read(JsonSupport.requireObject(underscore, "_behaviors"));
}
JsonValue array = obj.get("behaviors");
if (array != null) {
return Behaviors.readArray(JsonSupport.requireArray(array, "behaviors"));
return array instanceof JsonNull
? Behaviors.EMPTY
: Behaviors.readArray(JsonSupport.requireArray(array, "behaviors"));
}
JsonValue underscore = obj.get("_behaviors");
if (underscore != null && obj.get("repeat") == null) {
return Behaviors.read(JsonSupport.requireObject(underscore, "_behaviors"));
}
return Behaviors.EMPTY;
}
Expand All @@ -242,17 +250,28 @@ private static Behaviors readBehaviors(JsonObject obj) {
* beside {@code is}, a block one inside the block — so a read followed by a write never moves
* it. That matters because the two spellings are not interchangeable across engine versions:
* rift 0.17.0 and earlier honour only the block one and silently ignore a response-level field.
*
* <p>Beside a response-level {@code repeat} the block is always the array: from rift 0.24.0 a
* {@code _behaviors} object next to a top-level {@code repeat} is ignored, as in Mountebank
* (rift#1709), while the array next to it runs. And the array never holds a {@code repeat}:
* rift 0.24.0 refuses a {@code {"repeat": n}} element and ignores one beside a behavior
* (rift#1582, rift#1672). So in the array form the effective {@code repeat} is written beside the
* response — a block one moves there when there is no response-level one — and any other is
* dropped, as no engine that runs the array form honours it.
*/
private static void writeBehaviors(JsonObject.Builder builder, Behaviors behaviors) {
behaviors.responseLevelRepeat().ifPresent(repeat -> builder.put("repeat", repeat.value()));
Optional<Behavior.Repeat> responseLevelRepeat = behaviors.responseLevelRepeat();
Behaviors block = behaviors.withoutResponseLevelRepeat();
if (block.isEmpty()) {
if (responseLevelRepeat.isEmpty() && !block.requiresArrayForm()) {
if (!block.isEmpty()) {
builder.put("_behaviors", block.toJsonValue());
}
return;
}
if (block.requiresArrayForm()) {
builder.put("behaviors", block.toJsonArray());
} else {
builder.put("_behaviors", block.toJsonValue());
behaviors.effectiveRepeat().ifPresent(repeat -> builder.put("repeat", repeat.value()));
Behaviors elements = block.withoutRepeats();
if (!elements.isEmpty()) {
builder.put("behaviors", elements.toJsonArray());
}
}

Expand Down Expand Up @@ -284,8 +303,13 @@ private static Behaviors readAllBehaviors(JsonObject obj) {
return foldResponseLevelRepeat(readBehaviors(obj), obj);
}

/**
* The legacy {@code extra}-map constructors: a caller putting {@code _behaviors} beside {@code
* repeat} there means both, so a {@code repeat} does not void the block as it does on the wire.
*/
private static Behaviors liftBehaviors(Map<String, JsonValue> extra) {
return readAllBehaviors(new JsonObject(extra));
JsonObject obj = new JsonObject(extra);
return foldResponseLevelRepeat(readBehaviors(JsonSupport.withoutKeys(obj, "repeat")), obj);
}

private static Map<String, JsonValue> withoutBehaviorKeys(Map<String, JsonValue> extra) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ static List<String> buildCommand(Path binary, SpawnOptions opts, int port, Path
cmd.add("--journal-max-bytes");
cmd.add(Long.toString(bytes));
});
opts.scriptsDir().ifPresent(dir -> {
cmd.add("--scripts-dir");
cmd.add(dir.toString());
});
opts.upstreamTrust().ifPresent(trust -> {
if (trust instanceof UpstreamTrust.CaFile file) {
cmd.add("--upstream-ca-file");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@ void anOutOfOrderArrayKeepsItsOrder() {
// An engine GET echoes an array in the order it was written, so this is also the read-back of DSL output.
String decorateFirst = "\"behaviors\":[{\"decorate\":\"d\"},{\"shellTransform\":\"cat\"}]";
assertEquals(expected(decorateFirst), written(decorateFirst));
String copyFirst = "\"behaviors\":[{\"copy\":[" + COPY + "]},{\"lookup\":[" + LOOKUP + "]}]";
assertEquals(expected(copyFirst), written(copyFirst));
String waitThenDecorateThenCopy = "\"behaviors\":[{\"wait\":100},{\"decorate\":\"d\"},{\"copy\":[" + COPY + "]}]";
// A list-valued element is written one item per element, which rift 0.24.0 requires (rift#1639).
assertEquals(expected("\"behaviors\":[{\"copy\":" + COPY + "},{\"lookup\":" + LOOKUP + "}]"),
written("\"behaviors\":[{\"copy\":[" + COPY + "]},{\"lookup\":[" + LOOKUP + "]}]"));
String waitThenDecorateThenCopy = "\"behaviors\":[{\"wait\":100},{\"decorate\":\"d\"},{\"copy\":" + COPY + "}]";
assertEquals(expected(waitThenDecorateThenCopy), written(waitThenDecorateThenCopy));
}

Expand Down Expand Up @@ -68,9 +69,10 @@ void waitRepeatAndUnknownKeysDoNotCountTowardsOrder() {
}

@Test
void anOutOfOrderDslChainKeepsItsUnknownAndRepeatEntriesInPlace() {
assertEquals("{\"is\":{\"statusCode\":\"200\"},\"behaviors\":[{\"decorate\":\"d\"},{\"repeat\":2},"
+ "{\"lookup\":[" + LOOKUP + "]}]}",
void anOutOfOrderDslChainKeepsItsUnknownEntryInPlaceAndMovesRepeatBesideTheResponse() {
// rift 0.24.0 refuses a {"repeat": n} element and a list-valued lookup element (rift#1582, rift#1639).
assertEquals("{\"is\":{\"statusCode\":\"200\"},\"repeat\":2,\"behaviors\":[{\"decorate\":\"d\"},"
+ "{\"lookup\":" + LOOKUP + "}]}",
status(200).decorate("d").repeat(2)
.withBehavior(new Behavior.Unknown("lookup", io.github.achirdlabs.rift.json.JsonValue.parse("[" + LOOKUP + "]")))
.build().toJsonValue().toJson());
Expand Down
Loading
Loading