diff options
author | Bjørn Christian Seime <bjorncs@vespa.ai> | 2024-02-15 14:17:05 +0100 |
---|---|---|
committer | Bjørn Christian Seime <bjorncs@vespa.ai> | 2024-02-15 15:19:43 +0100 |
commit | 0df7382fcebf4d5768a59f7a15ac23391ba1023f (patch) | |
tree | 054c94c833adb38811eb325ca05b0705d8062298 /container-core | |
parent | fe7cfe2a7c6c549acedd217f73fa4fc6340c81eb (diff) |
Add simple Slime wrapper throwing 400 on missing/invalid content
Diffstat (limited to 'container-core')
3 files changed, 270 insertions, 0 deletions
diff --git a/container-core/src/main/java/com/yahoo/restapi/Json.java b/container-core/src/main/java/com/yahoo/restapi/Json.java new file mode 100644 index 00000000000..f6800c05668 --- /dev/null +++ b/container-core/src/main/java/com/yahoo/restapi/Json.java @@ -0,0 +1,174 @@ +package com.yahoo.restapi; + +import com.yahoo.slime.Inspector; +import com.yahoo.slime.ObjectTraverser; +import com.yahoo.slime.Slime; +import com.yahoo.slime.SlimeUtils; +import com.yahoo.slime.Type; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.OptionalLong; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import static com.yahoo.slime.Type.ARRAY; +import static com.yahoo.slime.Type.STRING; + +/** + * A {@link Slime} wrapper that throws {@link RestApiException.BadRequest} on missing members or invalid types. + * + * @author bjorncs + */ +public class Json implements Iterable<Json> { + + private final Inspector inspector; + // Used to keep track of the path to the current node for error messages + private final String path; + + public static Json of(Slime slime) { return of(slime.get()); } + public static Json of(Inspector inspector) { return new Json(inspector, ""); } + public static Json of(String json) { return of(SlimeUtils.jsonToSlime(json)); } + + private Json(Inspector inspector, String path) { + this.inspector = Objects.requireNonNull(inspector); + this.path = Objects.requireNonNull(path); + } + + public Json f(String field) { return field(field); } + public Json field(String field) { + requireType(Type.OBJECT); + return new Json(inspector.field(field), path.isEmpty() ? field : "%s.%s".formatted(path, field)); + } + + public Json a(int index) { return entry(index); } + public Json entry(int index) { + requireType(ARRAY); + return new Json(inspector.entry(index), "%s[%d]".formatted(path, index)); + } + + public int length() { return inspector.children(); } + public boolean has(String field) { return inspector.field(field).valid(); } + public boolean isPresent() { return inspector.valid(); } + public boolean isMissing() { return !isPresent(); } + + public Optional<String> asOptionalString() { return Optional.ofNullable(asString(null)); } + public String asString() { requireType(STRING); return inspector.asString(); } + public String asString(String defaultValue) { + if (isMissing()) return defaultValue; + return asString(); + } + + public OptionalLong asOptionalLong() { return isMissing() ? OptionalLong.empty() : OptionalLong.of(asLong()); } + public long asLong() { requireType(Type.LONG, Type.DOUBLE); return inspector.asLong(); } + public long asLong(long defaultValue) { + if (isMissing()) return defaultValue; + return asLong(); + } + + public OptionalDouble asOptionalDouble() { return isMissing() ? OptionalDouble.empty() : OptionalDouble.of(asDouble()); } + public double asDouble() { requireType(Type.LONG, Type.DOUBLE); return inspector.asDouble(); } + public double asDouble(double defaultValue) { + if (isMissing()) return defaultValue; + return asDouble(); + } + + public Optional<Boolean> asOptionalBool() { return isMissing() ? Optional.empty() : Optional.of(asBool()); } + public boolean asBool() { requireType(Type.BOOL); return inspector.asBool(); } + public boolean asBool(boolean defaultValue) { + if (isMissing()) return defaultValue; + return asBool(); + } + + public List<Json> toList() { + var list = new ArrayList<Json>(length()); + forEachEntry(json -> list.add(json)); + return List.copyOf(list); + } + + public String toJson(boolean pretty) { return SlimeUtils.toJson(inspector, !pretty); } + + public boolean isString() { return inspector.type() == STRING; } + public boolean isArray() { return inspector.type() == ARRAY; } + public boolean isLong() { return inspector.type() == Type.LONG; } + public boolean isDouble() { return inspector.type() == Type.DOUBLE; } + public boolean isBool() { return inspector.type() == Type.BOOL; } + public boolean isNumber() { return isLong() || isDouble(); } + public boolean isObject() { return inspector.type() == Type.OBJECT; } + + @Override + public Iterator<Json> iterator() { + requireType(ARRAY); + return new Iterator<>() { + private int current = 0; + @Override public boolean hasNext() { return current < length(); } + @Override public Json next() { return entry(current++); } + }; + } + + public void forEachField(BiConsumer<String, Json> consumer) { + requireType(Type.OBJECT); + inspector.traverse((ObjectTraverser) (name, inspector) -> { + consumer.accept(name, field(name)); + }); + } + + public void forEachEntry(BiConsumer<Integer, Json> consumer) { + requireType(ARRAY); + for (int i = 0; i < length(); i++) { + consumer.accept(i, entry(i)); + } + } + + public void forEachEntry(Consumer<Json> consumer) { + requireType(ARRAY); + for (int i = 0; i < length(); i++) { + consumer.accept(entry(i)); + } + } + + private void requireType(Type... types) { + requirePresent(); + if (!List.of(types).contains(inspector.type())) throw createInvalidTypeException(types); + } + + private void requirePresent() { if (isMissing()) throw createMissingMemberException(); } + + private RestApiException.BadRequest createInvalidTypeException(Type... expected) { + var expectedTypesString = Arrays.stream(expected).map(this::toString).collect(Collectors.joining("' or '", "'", "'")); + var pathString = path.isEmpty() ? "JSON" : "JSON member '%s'".formatted(path); + return new RestApiException.BadRequest( + "Expected %s to be a %s but got '%s'" + .formatted(pathString, expectedTypesString, toString(inspector.type()))); + } + + private RestApiException.BadRequest createMissingMemberException() { + return new RestApiException.BadRequest(path.isEmpty() ? "Missing JSON" : "Missing JSON member '%s'".formatted(path)); + } + + private String toString(Type type) { + return switch (type) { + case NIX -> "null"; + case BOOL -> "boolean"; + case LONG -> "integer"; + case DOUBLE -> "float"; + case STRING, DATA -> "string"; + case ARRAY -> "array"; + case OBJECT -> "object"; + }; + } + + @Override + public String toString() { + return "Json{" + + "inspector=" + inspector + + ", path='" + path + '\'' + + '}'; + } +} diff --git a/container-core/src/main/java/com/yahoo/restapi/RestApiMappers.java b/container-core/src/main/java/com/yahoo/restapi/RestApiMappers.java index 3dd0988a50f..3204c6c348a 100644 --- a/container-core/src/main/java/com/yahoo/restapi/RestApiMappers.java +++ b/container-core/src/main/java/com/yahoo/restapi/RestApiMappers.java @@ -31,6 +31,7 @@ public class RestApiMappers { static List<RequestMapperHolder<?>> DEFAULT_REQUEST_MAPPERS = List.of( new RequestMapperHolder<>(Slime.class, RestApiMappers::toSlime), + new RequestMapperHolder<>(Json.class, ctx -> toSlime(ctx).map(Json::of)), new RequestMapperHolder<>(JsonNode.class, ctx -> toJsonNode(ctx, ctx.jacksonJsonMapper())), new RequestMapperHolder<>(String.class, RestApiMappers::toString), new RequestMapperHolder<>(byte[].class, RestApiMappers::toByteArray), diff --git a/container-core/src/test/java/com/yahoo/restapi/JsonTest.java b/container-core/src/test/java/com/yahoo/restapi/JsonTest.java new file mode 100644 index 00000000000..276c9b55ea4 --- /dev/null +++ b/container-core/src/test/java/com/yahoo/restapi/JsonTest.java @@ -0,0 +1,95 @@ +package com.yahoo.restapi; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author bjorncs + */ +class JsonTest { + + @Test + void parses_json_correctly() { + var text = + """ + { + "string": "bar", + "integer": 42, + "floaty": 8.25, + "bool": true, + "array": [1, 2, 3], + "quux": { + "corge": "grault" + } + } + """; + var json = Json.of(text); + + // Primitive members + assertEquals("bar", json.f("string").asString()); + assertTrue(json.f("string").asOptionalString().isPresent()); + assertEquals("bar", json.f("string").asOptionalString().get()); + assertEquals(42L, json.f("integer").asLong()); + assertEquals(42D, json.f("integer").asDouble()); + assertEquals(8.25D, json.f("floaty").asDouble()); + assertEquals(8L, json.f("floaty").asLong()); + assertTrue(json.f("bool").asBool()); + + // Array member + assertEquals(3, json.f("array").length()); + assertEquals(1L, json.f("array").entry(0).asLong()); + assertEquals(2L, json.f("array").entry(1).asLong()); + assertEquals(3L, json.f("array").entry(2).asLong()); + json.f("array").forEachEntry((i, entry) -> assertEquals(i + 1, entry.asLong())); + int counter = 0; + for (var entry : json.f("array")) { + assertEquals(++counter, entry.asLong()); + } + + // Object member + assertEquals("grault", json.f("quux").f("corge").asString()); + json.f("quux").forEachField((name, child) -> { + assertEquals("corge", name); + assertEquals("grault", child.asString()); + }); + } + + @Test + void throws_on_missing_and_invalid_members() { + var text = + """ + { + "string": "bar" + } + """; + var json = Json.of(text); + + var exception = assertThrows(RestApiException.BadRequest.class, () -> json.f("unknown").asString()); + assertEquals("Missing JSON member 'unknown'", exception.getMessage()); + + exception = assertThrows(RestApiException.BadRequest.class, () -> json.a(0)); + assertEquals("Expected JSON to be a 'array' but got 'object'", exception.getMessage()); + + exception = assertThrows(RestApiException.BadRequest.class, () -> json.f("string").f("unknown")); + assertEquals("Expected JSON member 'string' to be a 'object' but got 'string'", exception.getMessage()); + + exception = assertThrows(RestApiException.BadRequest.class, () -> json.f("string").asLong()); + assertEquals("Expected JSON member 'string' to be a 'integer' or 'float' but got 'string'", exception.getMessage()); + } + + @Test + void fallback_to_default_if_field_missing() { + var text = + """ + { + "string": "bar" + } + """; + var json = Json.of(text); + assertEquals("foo", json.f("unknown").asString("foo")); + assertEquals("foo", json.f("unknown").asOptionalString().orElse("foo")); + assertEquals("bar", json.f("string").asString("foo")); + assertEquals("bar", json.f("string").asOptionalString().orElse("foo")); + } +} |