jsonlens API

jsonlens reads, queries, and changes JSON values in the representation of any JSON library. It implements JSONPath (RFC 9535), JSON Pointer (RFC 6901), and JSON Patch (RFC 6902) for one model of JSON values.

  • No JSON library binding. You give jsonlens a JsonModel for your JSON type. It reads your values directly. It does not copy or convert them, and the results are your own node instances.
  • No exceptions. Operations return a sealed Result: a value or an error record. You examine it with a switch and record patterns.
  • No runtime dependencies. The JSpecify nullness annotations are necessary only at compile time.
  • Traceable. Each requirement of the three RFCs has a test. The build fails when a requirement has no test.

Quick start

// 1. Read the JSON value with your own JSON library. Keep numbers exact.
JsonMapper mapper = JsonMapper.builder()
        .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
        .build();
JsonNode root = mapper.readTree(BOOKSTORE);

// 2. Parse the query. A query that is not valid gives an error value.
Result<JsonPathQuery, ParseError> parsed =
        JsonPathParser.standard().parse("$.store.book[?@.price < 10].title");

// 3. Apply the query to the value through a JsonModel.
switch (parsed) {
    case Result.Ok(JsonPathQuery query) -> {
        Result<List<Node<JsonNode>>, EvaluationError> result =
                JsonPathEvaluator.standard().evaluate(query, root, JacksonJsonModel.INSTANCE);
        switch (result) {
            case Result.Ok(List<Node<JsonNode>> nodes) -> {
                for (Node<JsonNode> node : nodes) {
                    lines.add(node.path() + " = " + node.value());
                }
            }
            case Result.Err(EvaluationError error) -> lines.add("Overflow: " + error.message());
        }
    }
    case Result.Err(ParseError error) -> lines.add("Bad query: " + error.message());
}
// lines:
//   $['store']['book'][0]['title'] = "Sayings of the Century"
//   $['store']['book'][2]['title'] = "Moby Dick"

The parse and the evaluation are two steps. You can parse a query one time and apply it to many values. Result.flatMap(java.util.function.Function) chains the two steps:

// Result.flatMap chains the parse and the evaluation. The error type is a String here.
Result<List<Node<JsonNode>>, String> authors = JsonPathParser.standard()
        .parse("$..author")
        .mapErr(ParseError::message)
        .flatMap(query -> JsonPathEvaluator.standard()
                .evaluate(query, root, JacksonJsonModel.INSTANCE)
                .mapErr(EvaluationError::message));

int count = authors.fold(List::size, error -> 0); // 4

Modules

Module Purpose Runtime dependencies
ca.marcusdunn.jsonlens.model JsonModel and the other model interfaces, Result, and Maybe none
ca.marcusdunn.jsonlens.path.core The query syntax tree and NormalizedPath model
ca.marcusdunn.jsonlens.path.parser JsonPathParser: query text to a validated query path-core
ca.marcusdunn.jsonlens.path.evaluator JsonPathEvaluator: applies a query to a JSON value path-core
ca.marcusdunn.jsonlens.jackson JacksonJsonModel for Jackson 3 JsonNode model, Jackson databind
ca.marcusdunn.jsonlens.mapped MappedJson: a read-only model over UTF-8 bytes model
ca.marcusdunn.jsonlens.pointer JsonPointer: RFC 6901 JSON Pointer for the values of any model model
ca.marcusdunn.jsonlens.patch JsonPatch: RFC 6902 JSON Patch, applied in place to any editable model model, pointer
ca.marcusdunn.jsonlens.testkit Tests for your own model: JsonModelContract, ModelVerifier, and ComplianceKit. Use it only in tests. model, path-core, mapped, path-parser, path-evaluator, patch, JUnit Jupiter

The Kotlin module jsonlens-kotlinx-serialization has models for kotlinx.serialization JsonElement trees and @Serializable Kotlin objects. Javadoc cannot read Kotlin, so it is not in this documentation: see its Dokka pages. Its javadoc JAR has the same pages.

Use only the modules that you need. A validation library can use only the parser. An engine with its own parser can use only the evaluator.

Your own JSON type

Implement JsonModel for your node type. This model reads plain Map and List values:

// A model for values made of Map, List, String, Number, and Boolean.
// JSON null is the sentinel JsonNull.NULL, because a node must not be Java null.
enum JsonNull { NULL }

static class CollectionsModel implements JsonModel<Object> {

    @Override
    public JsonKind kind(Object node) {
        return switch (node) {
            case Map<?, ?> map -> JsonKind.OBJECT;
            case List<?> list -> JsonKind.ARRAY;
            case String string -> JsonKind.STRING;
            case Number number -> JsonKind.NUMBER;
            case Boolean bool -> bool ? JsonKind.TRUE : JsonKind.FALSE;
            default -> JsonKind.NULL; // JsonNull.NULL
        };
    }

    @Override
    public int arrayLength(Object array) {
        return ((List<?>) array).size();
    }

    @Override
    public Maybe<Object> element(Object array, int index) {
        List<?> list = (List<?>) array;
        return index >= 0 && index < list.size() ? Maybe.some(node(list.get(index))) : Maybe.none();
    }

    @Override
    public MemberCursor<Object> memberCursor(Object object) {
        Iterator<? extends Map.Entry<?, ?>> entries = ((Map<?, ?>) object).entrySet().iterator();
        return new MemberCursor<>() {
            private Map.Entry<?, ?> current = Map.entry("", JsonNull.NULL);

            @Override
            public boolean next() {
                if (!entries.hasNext()) {
                    return false;
                }
                current = entries.next();
                return true;
            }

            @Override
            public JsonString name() {
                return JsonString.of((String) current.getKey());
            }

            @Override
            public Object value() {
                return node(current.getValue());
            }
        };
    }

    @Override
    public JsonString stringValue(Object string) {
        // JsonString.of does not copy the String.
        return JsonString.of((String) string);
    }

    @Override
    public JsonNumber numberValue(Object number) {
        // The model chooses the representation. JsonNumber.of covers the common types.
        return switch (number) {
            case BigDecimal decimal -> JsonNumber.of(decimal);
            case BigInteger integer -> JsonNumber.of(integer);
            case Double d -> JsonNumber.of(d.doubleValue()).orElse(JsonNumber.of(0)); // NaN is not JSON
            default -> JsonNumber.of(((Number) number).longValue());
        };
    }

    // Optional: these three methods have defaults that walk the cursor. A Map does it faster.
    @Override
    public int memberCount(Object object) {
        return ((Map<?, ?>) object).size();
    }

    @Override
    public Maybe<Object> member(Object object, JsonString name) {
        Map<?, ?> map = (Map<?, ?>) object;
        String key = JsonString.copyOf(name);
        return map.containsKey(key) ? Maybe.some(node(map.get(key))) : Maybe.none();
    }

    @Override
    public boolean hasDuplicate(Object object, JsonString name) {
        return false; // A Map cannot hold duplicate names.
    }

    // Replaces Java null with the sentinel.
    private static Object node(Object value) {
        return value == null ? JsonNull.NULL : value;
    }
}
Map<String, Object> root = new LinkedHashMap<>();
root.put("name", "jsonlens");
root.put("tags", Arrays.asList("json", null, "rfc9535"));

JsonPathQuery query = JsonPathParser.standard().parse("$.tags[?@ == null]").orElse(null);
List<Node<Object>> nodes = JsonPathEvaluator.standard()
        .evaluate(query, root, new CollectionsModel())
        .orElse(List.of());
// nodes: [Node[value=NULL, path=$['tags'][1]]]

Test the model with the test kit. A contract test gives the model and a parser; it runs fixed cases, ModelVerifier on random documents, and the RFC 9535 test suite:

// The contract test of a model: give the model and a parser of its JSON library.
// The test runs fixed cases, the verifier on random documents, and the RFC 9535 test suite.
static class CollectionsModelContractTest extends JsonModelContract<Object> {

    @Override
    protected JsonModel<Object> model() {
        return new CollectionsModel();
    }

    @Override
    protected Object parse(String json) {
        return TestkitSnippets.parse(json); // The parser must keep numbers exact.
    }
}

Errors are values

Operation Result type Error type
JsonPathParser.parse(String) JsonPathQuery ParseError
JsonPathEvaluator.evaluate(ca.marcusdunn.jsonlens.path.core.query.JsonPathQuery, Object, ca.marcusdunn.jsonlens.model.JsonModel) List<Node<N>> EvaluationError
JsonPathParser.withFunctions(java.util.List) JsonPathParser FunctionRegistrationError
JsonPathEvaluator.withFunctions(java.util.List) JsonPathEvaluator ExtensionError

All error types are sealed interfaces of records. A switch can match the errors that you want to handle in a special way:

String describe = switch (JsonPathParser.standard().parse("$[?length(@.*) > 3]")) {
    case Result.Ok(JsonPathQuery query) -> "valid: " + query;
    case Result.Err(ParseError.NonSingularQuery(int position)) ->
            "@.* can select more than one node, at position " + position;
    case Result.Err(ParseError.UnknownFunction(int position, String name)) ->
            "no function " + name;
    case Result.Err(ParseError error) -> error.message(); // all other errors
};
// describe: "@.* can select more than one node, at position 10"

Function extensions

RFC 9535 defines the functions length, count, match, search, and value. You can add more functions. The parser needs the signature, and the evaluator needs the implementation:

// first(NodesType): ValueType. The first node of a nodelist, or Nothing.
// A read-only extension: it runs with each JsonModel, also with a read-only model.
static final class First implements FunctionExtension {

    static final FunctionSignature SIGNATURE =
            new FunctionSignature("first", FunctionType.VALUE, List.of(FunctionType.NODES));

    @Override
    public FunctionSignature signature() {
        return SIGNATURE;
    }

    @Override
    public <N> Instance<N> apply(List<Instance<N>> arguments, JsonModel<N> model) {
        // The evaluator gives one instance for each parameter, of the declared type.
        List<Node<N>> nodes = ((Instance.NodesInstance<N>) arguments.getFirst()).nodes();
        // A ValueType result is an existing node, or Nothing.
        return new Instance.ValueInstance<>(nodes.isEmpty() ? Maybe.none() : Maybe.some(nodes.getFirst().value()));
    }
}
// The parser needs the signature, to check that queries are well-typed.
Result<JsonPathParser, FunctionRegistrationError> parser =
        JsonPathParser.withFunctions(List.of(First.SIGNATURE));
// The evaluator needs the implementation.
Result<JsonPathEvaluator, ExtensionError> evaluator =
        JsonPathEvaluator.withFunctions(List.of(new First()));

JsonPathQuery query = parser.orElse(JsonPathParser.standard())
        .parse("$[?first(@.*) > 4]")
        .orElse(null);
List<Node<JsonNode>> nodes = evaluator.orElse(JsonPathEvaluator.standard())
        .evaluate(query, root, JacksonJsonModel.INSTANCE)
        .orElse(List.of());
// nodes: the path $[1]

Behavior that the RFC leaves open

Subject Decision
The order of object members The order of JsonModel.memberCursor(Object). The Jackson model gives document order.
Numbers outside the I-JSON range They compare exactly, with JsonModel.compareNumbers. The exponent has no limit.
^ and $ in match() and search() They are anchors, as the JSONPath Compliance Test Suite expects.
Regular expressions jsonlens has its own I-Regexp (RFC 9485) implementation. It is checking and runs in linear time.
Very large results A nodelist larger than the limit gives an overflow error. See JsonPathEvaluator.Limits.
Modules
Module
Description
A JsonModel for Jackson 3 JsonNode values: JacksonJsonModel.
A read-only JsonModel over UTF-8 JSON bytes, for example a memory-mapped file: MappedJson.
The JSON model of jsonlens: the interfaces between the library and the JSON values of the caller, and the result types.
RFC 6902 JSON Patch: a sequence of operations that changes a JSON document.
The JSONPath types of jsonlens: the query syntax tree, function signatures, and Normalized Paths.
The RFC 9535 JSONPath evaluator: applies a JsonPathQuery to a JSON value through a JsonModel.
The RFC 9535 JSONPath parser: query text to a validated JsonPathQuery.
RFC 6901 JSON Pointer: a string that identifies a value in a JSON document.
Tests for a JsonModel: ModelVerifier checks the rules of the model on any document, the contract classes run fixed cases and the verifier as JUnit tests, and ComplianceKit runs the RFC 9535 and RFC 6902 test suites on the model.