aboutsummaryrefslogtreecommitdiffstats
path: root/vespajlib
diff options
context:
space:
mode:
authorValerij Fredriksen <valerijf@vespa.ai>2024-02-26 15:49:47 +0100
committerValerij Fredriksen <valerijf@vespa.ai>2024-02-26 15:49:47 +0100
commitdb245fbde8ab6546520747917ab7ab5b94ec17dc (patch)
tree113c6bb12dadb4a0815de9f9f33ec3c88aab1395 /vespajlib
parente3cb3fe6e1684cbb54b44b4ae26193d3bdd624a1 (diff)
Move Json to vespajlib
Diffstat (limited to 'vespajlib')
-rw-r--r--vespajlib/src/main/java/ai/vespa/json/InvalidJsonException.java11
-rw-r--r--vespajlib/src/main/java/ai/vespa/json/Json.java241
-rw-r--r--vespajlib/src/test/java/ai/vespa/json/JsonTest.java128
3 files changed, 380 insertions, 0 deletions
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<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 Stream<Json> 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<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 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);
+ }
+}