diff --git a/docs/design/sdk-api.md b/docs/design/sdk-api.md index edbcb65..4f68e05 100644 --- a/docs/design/sdk-api.md +++ b/docs/design/sdk-api.md @@ -620,6 +620,7 @@ static LookupSpec lookupKeyFromHeader(String name) // key.from {"headers": // .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 diff --git a/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/LookupGrammarIT.java b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/LookupGrammarIT.java index 711a39a..b5e710f 100644 --- a/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/LookupGrammarIT.java +++ b/rift-java-conformance/src/test/java/io/github/achirdlabs/rift/conformance/LookupGrammarIT.java @@ -54,6 +54,25 @@ Stream indexDelimiterAndHeaderKeyResolveTheRow() { }); } + @TestFactory + Stream aMultiCharacterDelimiterSplitsTheRow() { + return gated("lookup reads a '||'-delimited CSV (rift#1566, rift >= 0.24.0)", () -> { + Path dir = Files.createTempDirectory("rift-lookup-"); + Path csv = Files.writeString(dir.resolve("pipes.csv"), "id||name\n7||seven\n"); + try (Rift rift = engine(dir)) { + Imposter imp = rift.create(imposter("lookup-pipes") + .stub(onGet("/tenant").willReturn(ok().withTextBody("${row}[name]") + .lookup(lookupKeyFromHeader("X-Tenant").using(regex(".+")) + .fromCsv(csv.toString(), "id", "||").into("${row}"))))); + + assertEquals("seven", get(imp, "/tenant", "7")); + } finally { + Files.deleteIfExists(csv); + Files.deleteIfExists(dir); + } + }); + } + private static String get(Imposter imp, String path, String tenant) throws Exception { HttpRequest.Builder request = HttpRequest.newBuilder(imp.uri().resolve(path)).timeout(Duration.ofSeconds(5)).GET(); if (tenant != null) { diff --git a/rift-java-core/src/main/java/io/github/achirdlabs/rift/dsl/LookupSpec.java b/rift-java-core/src/main/java/io/github/achirdlabs/rift/dsl/LookupSpec.java index db34257..7f02fd9 100644 --- a/rift-java-core/src/main/java/io/github/achirdlabs/rift/dsl/LookupSpec.java +++ b/rift-java-core/src/main/java/io/github/achirdlabs/rift/dsl/LookupSpec.java @@ -28,7 +28,7 @@ public final class LookupSpec { private final OptionalInt index; private final Optional csvPath; private final Optional csvKeyColumn; - private final Optional csvDelimiter; + private final Optional csvDelimiter; private final Optional into; private LookupSpec( @@ -37,7 +37,7 @@ private LookupSpec( OptionalInt index, Optional csvPath, Optional csvKeyColumn, - Optional csvDelimiter, + Optional csvDelimiter, Optional into) { this.keyFrom = keyFrom; this.using = using; @@ -101,9 +101,37 @@ public LookupSpec fromCsv(String path, String keyColumn) { * encoding on its own, so it would reach the engine as {@code ?} */ public LookupSpec fromCsv(String path, String keyColumn, char delimiter) { - if (Character.isSurrogate(delimiter)) { - throw new IllegalArgumentException(String.format( - "csv delimiter must be a whole character, not half of a surrogate pair (U+%04X)", (int) delimiter)); + return fromCsv(path, keyColumn, String.valueOf(delimiter)); + } + + /** + * {@link #fromCsv(String, String, char)} for a delimiter of several characters, such as {@code "||"} + * — {@code csv.delimiter}, sent as given. Needs rift ≥ 0.24.0 when it is longer than one + * character (rift#1566, as Mountebank accepts it); an older engine refuses the imposter with + * {@code 400}. + * + * @throws NullPointerException if {@code delimiter} is null + * @throws IllegalArgumentException if {@code delimiter} is empty (the engine reads that as + * {@code ,}, which {@link #fromCsv(String, String)} already says) or + * holds half of a surrogate pair, which would reach the engine as + * {@code ?} + */ + public LookupSpec fromCsv(String path, String keyColumn, String delimiter) { + Objects.requireNonNull(delimiter, "delimiter"); + if (delimiter.isEmpty()) { + throw new IllegalArgumentException( + "csv delimiter must not be empty; use fromCsv(path, keyColumn) for the engine's default ','"); + } + for (int i = 0; i < delimiter.length(); i++) { + char c = delimiter.charAt(i); + boolean paired = Character.isHighSurrogate(c) && i + 1 < delimiter.length() + && Character.isLowSurrogate(delimiter.charAt(i + 1)); + if (paired) { + i++; + } else if (Character.isSurrogate(c)) { + throw new IllegalArgumentException(String.format( + "csv delimiter must be a whole character, not half of a surrogate pair (U+%04X)", (int) c)); + } } return new LookupSpec(keyFrom, using, index, Optional.of(path), Optional.of(keyColumn), Optional.of(delimiter), into); } @@ -132,7 +160,7 @@ JsonValue build() { JsonObject.Builder csv = JsonObject.builder() .put("path", new JsonString(path)) .put("keyColumn", new JsonString(keyColumn)); - csvDelimiter.ifPresent(d -> csv.put("delimiter", new JsonString(String.valueOf(d)))); + csvDelimiter.ifPresent(d -> csv.put("delimiter", new JsonString(d))); JsonObject fromDataSource = JsonObject.builder().put("csv", csv.build()).build(); return JsonObject.builder() diff --git a/rift-java-core/src/test/java/io/github/achirdlabs/rift/dsl/LookupSpecWireTest.java b/rift-java-core/src/test/java/io/github/achirdlabs/rift/dsl/LookupSpecWireTest.java index 46d57f1..92b56a8 100644 --- a/rift-java-core/src/test/java/io/github/achirdlabs/rift/dsl/LookupSpecWireTest.java +++ b/rift-java-core/src/test/java/io/github/achirdlabs/rift/dsl/LookupSpecWireTest.java @@ -9,6 +9,7 @@ import static io.github.achirdlabs.rift.dsl.RiftDsl.regex; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** The {@code lookup} grammar the engine accepts (#290): {@code key.index}, {@code csv.delimiter}, an object {@code key.from}. */ class LookupSpecWireTest { @@ -70,4 +71,43 @@ void theTwoArgumentFromCsvClearsAnEarlierDelimiter() { lookupKey("body").using(jsonPath("$.code")).fromCsv("p.csv", "code", ';').fromCsv("p.csv", "code") .into("$ROW").build().toJson()); } + + @Test + void aMultiCharacterDelimiterIsSentVerbatim() { + assertEquals("{\"key\":{\"from\":\"body\",\"using\":{\"method\":\"jsonpath\",\"selector\":\"$.code\"}}," + + "\"fromDataSource\":{\"csv\":{\"path\":\"p.csv\",\"keyColumn\":\"code\",\"delimiter\":\"||\"}},\"into\":\"$ROW\"}", + lookupKey("body").using(jsonPath("$.code")).fromCsv("p.csv", "code", "||").into("$ROW").build().toJson()); + } + + @Test + void aOneCharacterStringDelimiterIsTheSameAsTheCharForm() { + assertEquals(lookupKey("body").using(jsonPath("$.code")).fromCsv("p.csv", "code", ';').into("$ROW").build().toJson(), + lookupKey("body").using(jsonPath("$.code")).fromCsv("p.csv", "code", ";").into("$ROW").build().toJson()); + } + + @Test + void anEmptyDelimiterIsRefusedSinceTheTwoArgumentFormAlreadyMeansTheDefault() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> lookupKey("body").fromCsv("p.csv", "code", "")); + assertEquals("csv delimiter must not be empty; use fromCsv(path, keyColumn) for the engine's default ','", + e.getMessage()); + } + + @Test + void aNullDelimiterIsRefused() { + assertThrows(NullPointerException.class, () -> lookupKey("body").fromCsv("p.csv", "code", (String) null)); + } + + @Test + void anUnpairedSurrogateInAStringDelimiterIsRefused() { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> lookupKey("body").fromCsv("p.csv", "code", "|\uD83D")); + assertEquals("csv delimiter must be a whole character, not half of a surrogate pair (U+D83D)", e.getMessage()); + } + + @Test + void aSupplementaryCharacterDelimiterIsAccepted() { + assertTrue(lookupKey("body").using(jsonPath("$.code")).fromCsv("p.csv", "code", "\uD83D\uDE00") + .into("$ROW").build().toJson().contains("\"delimiter\":\"\uD83D\uDE00\"")); + } }