Using JsonPath safely with Jacksonโ€™s JsonNode tree model

When working with JSON in Java, Jacksonโ€™s JsonNode tree model is a popular choice โ€” especially in framework code that processes or transforms data. But what if you want to query that tree using flexible JsonPath expressions?

Jayway JsonPath is a powerful tool, but using it with pre-parsed Jackson trees isnโ€™t as obvious as youโ€™d think โ€” especially when youโ€™re aiming for safe, predictable behavior in production.

In this post, Iโ€™ll share a few techniques I found helpful for reliably applying JsonPath to JsonNode trees, making your queries safer, clearer, and better suited to framework-level code.

Using JsonPath with JsonNode trees

Jayway JsonPath offers an obvious API to parse JSON from scratch (from a file, string or InputStream) but is less obvious how to use with an already-parsed tree.

Hereโ€™s how to set it up:

Configuration config = Configuration.builder()
    .jsonProvider(new JacksonJsonNodeJsonProvider())
    .mappingProvider(new JacksonMappingProvider())
    .build();

Using JsonPath to query for JsonNodes directly

The JayWay JsonPath API seems originally designed to support application-code query usecases where the types are simple & known for each query. It uses generic value coercion, which โ€œmagicallyโ€ tries to cast simple values automatically; but leads to quite many surprises.

The API can take a โ€˜typeโ€™ Class parameter to control answer type. This works for simple values (strings or ints, etc), but when you want a plural result (a List) it cannot control the type of the list items.

Fortunately JayWay have added a workaround โ€“ the โ€˜typeโ€™ TypeRef parameter.

Hereโ€™s how to use it:

List<JsonNode> authors = JsonPath
    .using(config)
    .parse(jsonNode)
    .read("$.store.book[*].author", new TypeRef<List<JsonNode>>() {});

Clarity on Plurality

At an end-user level, I believe itโ€™s important that APIs are clear about plurality; whether a result should be singular or many.

Ideally an API should offer distinct method signatures for these two cases; for example:

  • a โ€˜getValue()โ€™ method answering a single result, possibly with defaulting,
  • a โ€˜getValues()โ€™ method answering a list which is empty if nothing found.

The JayWay JsonPath API tends to blur these cases. Surprises are common like:

  • A single match returning a String, but multiple matches returning a List.
  • A missing match returning null, an empty list, or even throwing an exception โ€” depending on configuration
  • Empty results for scalar paths (like "$.foo.bar") may throw a PathNotFoundException.

For these reasons, I generally recommend to wrap JayWay JsonPath usage within an application to help distinguish single-value from list semantics. While itโ€™s possible to use a static helper, itโ€™s better API to design an instantiated object which can hold context.

Hereโ€™s an example helper:

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jayway.jsonpath.*;
import com.jayway.jsonpath.spi.json.JacksonJsonNodeJsonProvider;
import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider;

import java.util.Collections;
import java.util.List;

public class JsonPather {

    private static final ObjectMapper objectMapper = new ObjectMapper();

    private static final Configuration pathConfig = Configuration.builder()
            .jsonProvider(new JacksonJsonNodeJsonProvider(objectMapper))
            .mappingProvider(new JacksonMappingProvider(objectMapper))
            .options(Option.SUPPRESS_EXCEPTIONS)
            .build();

    private final DocumentContext context;

    public JsonPather(JsonNode root) {
        this.context = JsonPath.using(pathConfig).parse(root);
    }

    /** Get a single typed value at the given JSONPath. */
    public <T> T getValue(String jsonPath, Class<T> type) {
        try {
            return context.read(jsonPath, type);
        } catch (PathNotFoundException e) {
            return null;
        }
    }

    /** Get a single typed value with fallback default. */
    public <T> T getValue(String jsonPath, Class<T> type, T defaultVal) {
        T result = getValue(jsonPath, type);
        return (result != null) ? result : defaultVal;
    }

    /** Get a list of typed values by converting matching JsonNodes. */
    public <T> List<T> getValues(String jsonPath, Class<T> type) {
        List<JsonNode> nodes = getNodes(jsonPath);
        return nodes.stream()
                .map(node -> objectMapper.convertValue(node, type))
                .toList();
    }
    
    // --------------------------------------------------------------


    /** Get a single JsonNode at the given JSONPath. */
    public JsonNode getNode(String jsonPath) {
        return context.read(jsonPath, JsonNode.class);
    }

    /** Get list of JsonNode values matching the JSONPath. */
    public List<JsonNode> getNodes(String jsonPath) {
        try {
            return context.read(jsonPath, new TypeRef<List<JsonNode>>() {});
        } catch (PathNotFoundException e) {
            return Collections.emptyList();
        }
    }
    
}

Usage is simple:

JsonPather pather = new JsonPather(root);
String author = pather.getValue("$.store.book[0].author", String.class);
List<String> authors = pather.getValues("$.store.book[*].author", String.class);

Glad you made it this far! If youโ€™ve got tips, edge cases, or battle scars from working with JsonPath and Jackson, Iโ€™m all ears. Letโ€™s swap ideas and make our parsers a little sharper.

Leave a Reply

Your email address will not be published. Required fields are marked *