From db245fbde8ab6546520747917ab7ab5b94ec17dc Mon Sep 17 00:00:00 2001 From: Valerij Fredriksen Date: Mon, 26 Feb 2024 15:49:47 +0100 Subject: Move Json to vespajlib --- .../java/ai/vespa/json/InvalidJsonException.java | 11 + vespajlib/src/main/java/ai/vespa/json/Json.java | 241 +++++++++++++++++++++ .../src/test/java/ai/vespa/json/JsonTest.java | 128 +++++++++++ 3 files changed, 380 insertions(+) create mode 100644 vespajlib/src/main/java/ai/vespa/json/InvalidJsonException.java create mode 100644 vespajlib/src/main/java/ai/vespa/json/Json.java create mode 100644 vespajlib/src/test/java/ai/vespa/json/JsonTest.java (limited to 'vespajlib') diff --git a/vespajlib/src/main/java/ai/vespa/json/InvalidJsonException.java b/vespajlib/src/main/java/ai/vespa/json/InvalidJsonException.java new file mode 100644 index 00000000000..6b1b039966c --- /dev/null +++ b/vespajlib/src/main/java/ai/vespa/json/InvalidJsonException.java @@ -0,0 +1,11 @@ +// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package ai.vespa.json; + +/** + * @author freva + */ +public class InvalidJsonException extends IllegalArgumentException { + public InvalidJsonException(String message) { + super(message); + } +} diff --git a/vespajlib/src/main/java/ai/vespa/json/Json.java b/vespajlib/src/main/java/ai/vespa/json/Json.java new file mode 100644 index 00000000000..b88c804c728 --- /dev/null +++ b/vespajlib/src/main/java/ai/vespa/json/Json.java @@ -0,0 +1,241 @@ +package ai.vespa.json; + +import com.yahoo.slime.Cursor; +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.math.BigDecimal; +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 java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import static com.yahoo.slime.Type.ARRAY; +import static com.yahoo.slime.Type.STRING; + +/** + * A {@link Slime} wrapper that throws {@link InvalidJsonException} on missing members or invalid types. + * + * @author bjorncs + */ +public class Json implements Iterable { + + 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 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 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 toList() { + var list = new ArrayList(length()); + forEachEntry(json -> list.add(json)); + return List.copyOf(list); + } + + public Stream stream() { return StreamSupport.stream(this.spliterator(), false); } + + 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 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 consumer) { + requireType(Type.OBJECT); + inspector.traverse((ObjectTraverser) (name, inspector) -> { + consumer.accept(name, field(name)); + }); + } + + public void forEachEntry(BiConsumer consumer) { + requireType(ARRAY); + for (int i = 0; i < length(); i++) { + consumer.accept(i, entry(i)); + } + } + + public void forEachEntry(Consumer 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 InvalidJsonException 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 InvalidJsonException( + "Expected %s to be a %s but got '%s'" + .formatted(pathString, expectedTypesString, toString(inspector.type()))); + } + + private InvalidJsonException createMissingMemberException() { + return new InvalidJsonException(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 + '\'' + + '}'; + } + + /** Provides a fluent API for building a {@link Slime} instance. */ + public static class Builder { + protected final Cursor cursor; + + public static Builder.Array newArray() { return new Builder.Array(new Slime().setArray()); } + public static Builder.Object newObject() { return new Builder.Object(new Slime().setObject()); } + public static Builder.Object existingObject(Cursor cursor) { return new Builder.Object(cursor); } + + private Builder(Cursor cursor) { this.cursor = cursor; } + + public static class Array extends Builder { + private Array(Cursor cursor) { super(cursor); } + + public Builder.Array add(Builder.Array array) { + SlimeUtils.copyArray(array.cursor, cursor.addArray()); return this; + } + public Builder.Array add(Builder.Object object) { + SlimeUtils.copyObject(object.cursor, cursor.addObject()); return this; + } + public Builder.Array add(Json json) { + SlimeUtils.addValue(json.inspector, cursor.addObject()); return this; + } + /** Note: does not return {@code this}! */ + public Builder.Array addArray() { return new Array(cursor.addArray()); } + /** Note: does not return {@code this}! */ + public Builder.Object addObject() { return new Object(cursor.addObject()); } + + public Builder.Array add(String value) { cursor.addString(value); return this; } + public Builder.Array add(long value) { cursor.addLong(value); return this; } + public Builder.Array add(double value) { cursor.addDouble(value); return this; } + public Builder.Array add(boolean value) { cursor.addBool(value); return this; } + } + + public static class Object extends Builder { + private Object(Cursor cursor) { super(cursor); } + + public Builder.Object set(String field, Builder.Array array) { + SlimeUtils.copyArray(array.cursor, cursor.setArray(field)); return this; + } + public Builder.Object set(String field, Builder.Object object) { + SlimeUtils.copyObject(object.cursor, cursor.setObject(field)); return this; + } + public Builder.Object set(String field, Json json) { + SlimeUtils.setObjectEntry(json.inspector, field, cursor); return this; + } + /** Note: does not return {@code this}! */ + public Builder.Array setArray(String field) { return new Array(cursor.setArray(field)); } + /** Note: does not return {@code this}! */ + public Builder.Object setObject(String field) { return new Object(cursor.setObject(field)); } + + public Builder.Object set(String field, String value) { cursor.setString(field, value); return this; } + public Builder.Object set(String field, long value) { cursor.setLong(field, value); return this; } + public Builder.Object set(String field, double value) { cursor.setDouble(field, value); return this; } + public Builder.Object set(String field, boolean value) { cursor.setBool(field, value); return this; } + public Builder.Object set(String field, BigDecimal value) { cursor.setString(field, value.toPlainString()); return this; } + } + + public Cursor slimeCursor() { return cursor; } + public Json build() { return Json.of(cursor); } + } +} diff --git a/vespajlib/src/test/java/ai/vespa/json/JsonTest.java b/vespajlib/src/test/java/ai/vespa/json/JsonTest.java new file mode 100644 index 00000000000..293e99227a7 --- /dev/null +++ b/vespajlib/src/test/java/ai/vespa/json/JsonTest.java @@ -0,0 +1,128 @@ +package ai.vespa.json; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @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(InvalidJsonException.class, () -> json.f("unknown").asString()); + assertEquals("Missing JSON member 'unknown'", exception.getMessage()); + + exception = assertThrows(InvalidJsonException.class, () -> json.a(0)); + assertEquals("Expected JSON to be a 'array' but got 'object'", exception.getMessage()); + + exception = assertThrows(InvalidJsonException.class, () -> json.f("string").f("unknown")); + assertEquals("Expected JSON member 'string' to be a 'object' but got 'string'", exception.getMessage()); + + exception = assertThrows(InvalidJsonException.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")); + } + + @Test + void builds_expected_json() { + var expected = + """ + { + "string": "bar", + "integer": 42, + "floaty": 8.25, + "bool": true, + "array": [ + 1, + 2, + 3 + ], + "quux": { + "corge": "grault" + } + } + """; + var json = Json.Builder.newObject() + .set("string", "bar") + .set("integer", 42) + .set("floaty", 8.25) + .set("bool", true) + .set("array", Json.Builder.newArray().add(1).add(2).add(3)) + .set("quux", Json.Builder.newObject().set("corge", "grault")) + .build() + .toJson(true); + assertEquals(expected, json); + } +} -- cgit v1.2.3