Adding JsonPath support to custom object models in .NET

 
 
  • Gérald Barré

In Using JsonPath with System.Text.Json to query JSON in .NET, we used JsonNode as the input type. If your data is already loaded in domain classes, you can evaluate JsonPath directly on those classes without converting to JsonNode.

Meziantou.Framework.JsonPath exposes JsonPathNavigator<TValue> for this scenario. You provide JSON-like navigation semantics for your node type, and the evaluator does the rest. If you already know XPath in .NET, this design should feel familiar: you expose navigation primitives through a navigator, and the query engine handles expression evaluation.

#Define a real-world model

For this example, we use an e-commerce catalog with strongly typed classes.

C#
using Meziantou.Framework.Json;

public sealed class ProductCatalog
{
    public string Currency { get; init; } = "EUR";
    public List<Product> Products { get; init; } = [];
}

public sealed class Product
{
    public string Sku { get; init; } = string.Empty;
    public string Name { get; init; } = string.Empty;
    public bool IsActive { get; init; }
    public double Price { get; init; }
    public List<string> Tags { get; init; } = [];
    public ProductMetadata Metadata { get; init; } = new();
}

public sealed class ProductMetadata
{
    public string Category { get; init; } = string.Empty;
    public int Stock { get; init; }
}

#Implement JsonPathNavigator<TValue>

The navigator is the bridge between your classes and JsonPath. You need to implement these operations:

  • Node kind (GetKind)
  • Object access (TryGetPropertyValue, GetProperties)
  • Array access (GetArrayLength, TryGetElement)
  • Scalar extraction (TryGetString, TryGetNumber, TryGetBoolean)
C#
using Meziantou.Framework.Json;

public sealed class ProductCatalogNavigator : JsonPathNavigator<object?>
{
    public static ProductCatalogNavigator Instance { get; } = new();

    private ProductCatalogNavigator()
    {
    }

    public override JsonPathNodeKind GetKind(object? value)
    {
        return value switch
        {
            null => JsonPathNodeKind.Null,
            ProductCatalog or Product or ProductMetadata => JsonPathNodeKind.Object,
            List<Product> or List<string> => JsonPathNodeKind.Array,
            string => JsonPathNodeKind.String,
            double or int => JsonPathNodeKind.Number,
            bool => JsonPathNodeKind.Boolean,
            _ => JsonPathNodeKind.Null,
        };
    }

    public override bool TryGetPropertyValue(object? value, string name, out object? result)
    {
        switch (value)
        {
            case ProductCatalog catalog when name == "currency":
                result = catalog.Currency;
                return true;
            case ProductCatalog catalog when name == "products":
                result = catalog.Products;
                return true;
            case Product product when name == "sku":
                result = product.Sku;
                return true;
            case Product product when name == "name":
                result = product.Name;
                return true;
            case Product product when name == "isActive":
                result = product.IsActive;
                return true;
            case Product product when name == "price":
                result = product.Price;
                return true;
            case Product product when name == "tags":
                result = product.Tags;
                return true;
            case Product product when name == "metadata":
                result = product.Metadata;
                return true;
            case ProductMetadata metadata when name == "category":
                result = metadata.Category;
                return true;
            case ProductMetadata metadata when name == "stock":
                result = metadata.Stock;
                return true;
        }

        result = null;
        return false;
    }

    public override IEnumerable<JsonPathProperty<object?>> GetProperties(object? value)
    {
        return value switch
        {
            ProductCatalog catalog =>
            [
                new JsonPathProperty<object?>("currency", catalog.Currency),
                new JsonPathProperty<object?>("products", catalog.Products),
            ],
            Product product =>
            [
                new JsonPathProperty<object?>("sku", product.Sku),
                new JsonPathProperty<object?>("name", product.Name),
                new JsonPathProperty<object?>("isActive", product.IsActive),
                new JsonPathProperty<object?>("price", product.Price),
                new JsonPathProperty<object?>("tags", product.Tags),
                new JsonPathProperty<object?>("metadata", product.Metadata),
            ],
            ProductMetadata metadata =>
            [
                new JsonPathProperty<object?>("category", metadata.Category),
                new JsonPathProperty<object?>("stock", metadata.Stock),
            ],
            _ => [],
        };
    }

    public override int GetArrayLength(object? value)
        => value switch
        {
            List<Product> products => products.Count,
            List<string> tags => tags.Count,
            _ => 0,
        };

    public override bool TryGetElement(object? value, int index, out object? result)
    {
        switch (value)
        {
            case List<Product> products when index >= 0 && index < products.Count:
                result = products[index];
                return true;
            case List<string> tags when index >= 0 && index < tags.Count:
                result = tags[index];
                return true;
        }

        result = null;
        return false;
    }

    public override bool TryGetString(object? value, out string? result)
    {
        if (value is string s)
        {
            result = s;
            return true;
        }

        result = null;
        return false;
    }

    public override bool TryGetNumber(object? value, out double result)
    {
        switch (value)
        {
            case int i:
                result = i;
                return true;
            case double d:
                result = d;
                return true;
        }

        result = 0;
        return false;
    }

    public override bool TryGetBoolean(object? value, out bool result)
    {
        if (value is bool b)
        {
            result = b;
            return true;
        }

        result = false;
        return false;
    }
}

#Evaluate JsonPath on your classes

Once the navigator is in place, evaluate expressions the same way as with JsonNode.

C#
using Meziantou.Framework.Json;

var catalog = new ProductCatalog
{
    Currency = "EUR",
    Products =
    [
        new Product
        {
            Sku = "BK-001",
            Name = "C# in Depth",
            IsActive = true,
            Price = 39.9,
            Tags = ["dotnet", "book"],
            Metadata = new ProductMetadata { Category = "Books", Stock = 42 },
        },
        new Product
        {
            Sku = "BK-002",
            Name = "Legacy ASP.NET",
            IsActive = false,
            Price = 14.9,
            Tags = ["aspnet", "book"],
            Metadata = new ProductMetadata { Category = "Books", Stock = 0 },
        },
        new Product
        {
            Sku = "HW-010",
            Name = "Ergonomic Keyboard",
            IsActive = true,
            Price = 79.0,
            Tags = ["hardware"],
            Metadata = new ProductMetadata { Category = "Hardware", Stock = 5 },
        },
    ],
};

var path = JsonPath.Parse("$.products[?@.isActive == true && @.price < 50].name");
var matches = path.Evaluate(catalog, ProductCatalogNavigator.Instance);

foreach (var match in matches)
{
    // match.Path: $['products'][0]['name']
    // match.Value: string
    Console.WriteLine(match.Path);
}

var name = path.EvaluateValue(catalog, ProductCatalogNavigator.Instance);
Console.WriteLine(name); // C# in Depth

You can still choose lax or strict mode:

C#
var strictMatches = path.Evaluate(catalog, ProductCatalogNavigator.Instance, JsonPathEvaluationMode.Strict);

#Mapping rules to keep in mind

  • Return JsonPathNodeKind.Null for null nodes.
  • Return false from TryGetPropertyValue and TryGetElement when a member or index is missing.
  • Arrays are zero-based.
  • GetProperties defines object property enumeration order.

With this adapter approach, JsonPath can query any tree structure in your application while still using RFC 9535 syntax.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?