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
JsonModelfor 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 aswitchand 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
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. |
JsonModel over UTF-8 JSON bytes, for example a
memory-mapped file: MappedJson.JsonPathQuery to a
JSON value through a JsonModel.JsonPathQuery.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.