summaryrefslogtreecommitdiffstats
path: root/container-core
diff options
context:
space:
mode:
authorBjørn Christian Seime <bjorncs@vespa.ai>2024-02-15 14:17:05 +0100
committerBjørn Christian Seime <bjorncs@vespa.ai>2024-02-15 15:19:43 +0100
commit0df7382fcebf4d5768a59f7a15ac23391ba1023f (patch)
tree054c94c833adb38811eb325ca05b0705d8062298 /container-core
parentfe7cfe2a7c6c549acedd217f73fa4fc6340c81eb (diff)
Add simple Slime wrapper throwing 400 on missing/invalid content
Diffstat (limited to 'container-core')
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/Json.java174
-rw-r--r--container-core/src/main/java/com/yahoo/restapi/RestApiMappers.java1
-rw-r--r--container-core/src/test/java/com/yahoo/restapi/JsonTest.java95
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"));
+ }
+}