The problem

We have the following JSON:

private const string Json = """
    {
      "property1": "Some string",
      "property2": 1,
      "property3": true,
      "nested": {
        "property4": "Some string 4",
        "property5": 2,
        "property6": true,
        "items": [3, 4, 5]
      },
      "items": [1, 2, 3]
    }
""";

We want to be able to get a property value without having to create a DTO and deserialize it, we just want to query the JSON directly.

The solution

I will add support for various usecases as I need them in my projects. Let's start with selecting and removing.

Selecting a specific property value

Let's write a test first that shows our intent.

[Fact]
public void SelectProperty_CanReturnFirstLevelProperty()
{
    var parsed = JsonNode.Parse(Json, JsonNodeOptions)!;
    var jsonObject = parsed.AsObject();

    var result = jsonObject.GetValue<bool>("property3");

    result.ShouldBeTrue();
}

The generic GetValue<T> method is our method. It's an extension method on the JsonObject type. Currently, it's rather limited, it doesn't support querying for specific items in an array for example. Support for that will be added in a future post, right now, we just want the above test to pass.

public static class JsonExtensions
{
    public static T? GetValue<T>(this JsonObject jsonObject, string jsonPath)
    {
        var result = SelectJsonNode(jsonObject, jsonPath);

        if (result == default)
        {
            return default;
        }
        
        return result.Node switch
        {
            JsonObject obj => obj.TryGetPropertyValue(result.PropertyName, out var property)
                ? property!.GetValue<T>()
                : default,
            JsonArray array => array.Deserialize<T>(),
            _ => result.Node!.GetValue<T>()
        };
    }

    private static (JsonNode? Node, string PropertyName) SelectJsonNode(this JsonObject jsonObject, string jsonPath)
    {
        if (jsonPath.Contains('.'))
        {
            var @object = jsonObject;
            foreach (var prop in new SpanSplitter(jsonPath.AsSpan(), '.'))
            {
                var propertyName = prop.ToString();
                if (@object.TryGetPropertyValue(propertyName, out var node))
                {
                    switch (node)
                    {
                        case JsonObject jsonNode:
                            @object = jsonNode;
                            break;
                        case JsonValue:
                            return (node.Parent!, propertyName);
                        default:
                            return (node, propertyName);
                    }
                }
                else
                {
                    return default;
                }
            }
        }

        if (jsonObject.TryGetPropertyValue(jsonPath, out var n))
        {
            return n switch
            {
                JsonValue => (n.Parent!, propertyPath: jsonPath),
                _ => (n, propertyPath: jsonPath)
            };            
        }

        return default;
    }
}

The heavy lifting is done in the SelectJsonNode method. It's responsible for taking a "json path" and traversing the JsonObject. SpanSplitter is responsible for splitting a span based on a char.
It allows us to enumerate the passed in json path.

SpanSplitter looks like this and is, somewhat, inspired by the code written by Gérald Barré.

public readonly ref struct SpanSplitter
{
    private readonly ReadOnlySpan<char> _input;
    private readonly char _splitOn;

    public SpanSplitter (ReadOnlySpan<char> input, char splitOn)
    {
        _input = input;
        _splitOn = splitOn;
    }

    public Enumerator GetEnumerator()
    {
        return new Enumerator(_input, _splitOn);
    }

    public ref struct Enumerator
    {
        private readonly ReadOnlySpan<char> _input;
        private readonly char _splitOn;
        private int _propertyPosition;
        public ReadOnlySpan<char> Current { get; private set; } = default;

        public Enumerator (ReadOnlySpan<char> input, char splitOn)
        {
            _input = input;
            _splitOn = splitOn;
            _propertyPosition = 0;
        }

        public bool MoveNext()
        {
            for (var i = _propertyPosition; i <= _input.Length; i++)
            {
                if (i != _input.Length && _input[i] != _splitOn)
                {
                    continue;
                }
                Current = _input [_propertyPosition..i];
                _propertyPosition = i + 1;
                return true;
            }

            return false;
        }
    }
}

If we run the test, the test is now green.
It also works for nested properties, proved by the following test:

[Fact]
public void SelectProperty_CanReturnNestedProperty()
{
    var parsed = JsonNode.Parse(Json, JsonNodeOptions)!;
    var jsonObject = parsed.AsObject();

    var result = jsonObject.GetValue<int>("nested.property5");

    result.ShouldBe(2);
}

Removing a specific property

Sometimes you want to remove a property from a JsonObject. There's built in support for removing a property on the first level, like this:

myJsonObject.Remove(propertyName);

But what if we want to remove nested properties?
Let's create a test...

[Fact]
public void RemoveProperty_CanRemoveNestedProperty()
{
    var parsed = JsonNode.Parse(Json, JsonNodeOptions)!;
    var jsonObject = parsed.AsObject();

    var result = jsonObject.RemoveProperty("nested.property5");

    var jsonString = result.ToJsonString(JsonSerializerOptions);
    var parsedResult = JsonNode.Parse(jsonString, JsonNodeOptions)!.AsObject();
    parsedResult["property1"]!.GetValue<string>().ShouldBe("Some string");
    parsedResult["property2"]!.GetValue<int>().ShouldBe(1);
    parsedResult["property3"]!.GetValue<bool>().ShouldBeTrue();
    parsedResult["nested"].ShouldNotBeNull();
    var nested = parsedResult["nested"]!.AsObject();
    nested["property4"]!.GetValue<string>().ShouldBe("Some string 4");
    nested["property5"].ShouldBeNull();
    nested["property6"]!.GetValue<bool>().ShouldBeTrue();
}

And then implement the functionality!

public static JsonObject RemoveProperty(this JsonObject jsonObject, string jsonPath)
{
    var (jsonNode, propertyName) = SelectJsonNode(jsonObject, jsonPath);
    switch (jsonNode)
    {
        case JsonObject obj:
            obj.Remove(propertyName);
            return jsonObject;
        default:
            throw new Exception($"{jsonNode!.GetType().Name} is not supported");
    }
}

We can reuse the SelectJsonNode method here. The only thing we need to do is to ensure that the returned JsonNode is an actual JsonObject. If it's an JsonObject, we call the built in Remove method and the property will be removed.

Removing multiple properties

We also want to be able to remove multiple properties at once.

[Fact]
public void RemoveProperties_CanRemoveNestedLevelProperties()
{
    var parsed = JsonNode.Parse(Json, JsonNodeOptions)!;
    var jsonObject = parsed.AsObject();
    var propertiesToRemove = new List<string> {"property2", "nested.property5"};

    var result = jsonObject.RemoveProperties(propertiesToRemove);

    var jsonString = result.ToJsonString(JsonSerializerOptions);
    var parsedResult = JsonNode.Parse(jsonString, JsonNodeOptions)!.AsObject();
    parsedResult["property1"]!.GetValue<string>().ShouldBe("Some string");
    parsedResult["property2"]!.ShouldBeNull();
    parsedResult["property3"]!.GetValue<bool>().ShouldBeTrue();
    parsedResult["nested"].ShouldNotBeNull();
    var nested = parsedResult["nested"]!.AsObject();
    nested["property4"]!.GetValue<string>().ShouldBe("Some string 4");
    nested["property5"].ShouldBeNull();
    nested["property6"]!.GetValue<bool>().ShouldBeTrue();
}

The new RemoveProperties extension method is really simple:

public static JsonObject RemoveProperties(this JsonObject jsonObject, IEnumerable<string> jsonPaths)
{
    foreach (var propertyPath in jsonPaths)
    {
        jsonObject.RemoveProperty(propertyPath);
    }

    return jsonObject;
}

Next up will be supporting querying objects in arrays, stay tuned.