Getting nested properties with System.Text.Json
Asked Answered
B

5

18

I am working with System.Text.Json in my project as I am processing large files so also decided to use it for processing GraphQL responses.

Due to the nature of GraphQL sometimes I get highly nested responses that are not fixed and don't make sense to map to a class. I usually need to check a few properties on the response.

My issue is with JsonElement. To check nested properties feels very clumsy and I feel like there should be a better way to approach this.

For example take my below code simulating a response I get. I just want to check if 2 properties exist (id & originalSrc) and if they do get their value but it feels like I have made a meal of the code. Is there a better/clearer/more succinct way to write this?

var raw = @"{
""data"": {
""products"": {
    ""edges"": [
        {
            ""node"": {
                ""id"": ""gid://shopify/Product/4534543543316"",
                ""featuredImage"": {
                    ""originalSrc"": ""https://cdn.shopify.com/s/files/1/0286/pic.jpg"",
                    ""id"": ""gid://shopify/ProductImage/146345345339732""
                }
            }
        }
    ]
}
}
}";

var doc = JsonSerializer.Deserialize<JsonElement>(raw);

JsonElement node = new JsonElement();

string productIdString = null;

if (doc.TryGetProperty("data", out var data))
    if (data.TryGetProperty("products", out var products))
        if (products.TryGetProperty("edges", out var edges))
            if (edges.EnumerateArray().FirstOrDefault().ValueKind != JsonValueKind.Undefined && edges.EnumerateArray().First().TryGetProperty("node", out node))
                if (node.TryGetProperty("id", out var productId))
                    productIdString = productId.GetString();

string originalSrcString = null;

if(node.ValueKind != JsonValueKind.Undefined && node.TryGetProperty("featuredImage", out var featuredImage))
    if (featuredImage.TryGetProperty("originalSrc", out var originalSrc))
        originalSrcString = originalSrc.GetString();

if (!string.IsNullOrEmpty(productIdString))
{
    //do stuff
}

if (!string.IsNullOrEmpty(originalSrcString))
{
    //do stuff
}

It is not a crazy amount of code but checking a handful of properties is so common I would like a cleaner more readble approach.

Brierwood answered 2/5, 2020 at 3:5 Comment(0)
B
29

You could add a couple of extension methods that access a child JsonElement value by property name or array index, returning a nullable value if not found:

public static partial class JsonExtensions
{
    public static JsonElement? Get(this JsonElement element, string name) => 
        element.ValueKind != JsonValueKind.Null && element.ValueKind != JsonValueKind.Undefined && element.TryGetProperty(name, out var value) 
            ? value : (JsonElement?)null;
    
    public static JsonElement? Get(this JsonElement element, int index)
    {
        if (element.ValueKind == JsonValueKind.Null || element.ValueKind == JsonValueKind.Undefined)
            return null;
        // Throw if index < 0
        return index < element.GetArrayLength() ? element[index] : null;
    }
}

Now calls to access nested values can be chained together using the null-conditional operator ?.:

var doc = JsonSerializer.Deserialize<JsonElement>(raw);

var node = doc.Get("data")?.Get("products")?.Get("edges")?.Get(0)?.Get("node");

var productIdString = node?.Get("id")?.GetString();
var originalSrcString = node?.Get("featuredImage")?.Get("originalSrc")?.GetString();
Int64? someIntegerValue = node?.Get("Size")?.GetInt64();  // You could use "var" here also, I used Int64? to make the inferred type explicit.

Notes:

  • The extension methods above will throw an exception if the incoming element is not of the expected type (object or array or null/missing). You could loosen the checks on ValueKind if you never want an exception on an unexpected value type.

  • There is an open API enhancement request Add JsonPath support to JsonDocument/JsonElement #31068. Querying via JSONPath, if implemented, would make this sort of thing easier.

  • If you are porting code from Newtonsoft, be aware that JObject returns null for a missing property, while JArray throws on an index out of bounds. Thus you might want to use the JElement array indexer directly when trying to emulate Newtonsoft's behavior, like so, since it also throws on an index out of bounds:

    var node = doc.Get("data")?.Get("products")?.Get("edges")?[0].Get("node");
    

Demo fiddle here.

Backcourt answered 2/5, 2020 at 14:53 Comment(2)
Thanks! Been using this today and it has saved me a ton of time.Brierwood
this is awesome, saves so much time!Mozellemozes
M
6

To make my code a little more readable I created a method that uses a dot-separated path with System.Text.Json similar to a path parameter for the SelectToken() method in Newtonsoft.Json.

JsonElement jsonElement = GetJsonElement(doc, "data.products.edges");

I then use jsonElement.ValueKind to check the return type.

private static JsonElement GetJsonElement(JsonElement jsonElement, string path)
{
    if (jsonElement.ValueKind == JsonValueKind.Null ||
        jsonElement.ValueKind == JsonValueKind.Undefined)
    {
        return default;
    }

    string[] segments =
        path.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries);

    for (int n = 0; n < segments.Length; n++)
    {
        jsonElement = jsonElement.TryGetProperty(segments[n], out JsonElement value) ? value : default;

        if (jsonElement.ValueKind == JsonValueKind.Null ||
            jsonElement.ValueKind == JsonValueKind.Undefined)
        {
            return default;
        }
    }

    return jsonElement;
}

I created another simple method to retrieve the value of the returned JsonElement as a string.

private static string GetJsonElementValue(JsonElement jsonElement)
{
    return
        jsonElement.ValueKind != JsonValueKind.Null &&
        jsonElement.ValueKind != JsonValueKind.Undefined ?
        jsonElement.ToString() :
        default;
}

Below are two functions applied to the OP's sample:

public void Test()
{
    string raw = @"{
        ""data"": {
        ""products"": {
            ""edges"": [
                {
                    ""node"": {
                        ""id"": ""gid://shopify/Product/4534543543316"",
                        ""featuredImage"": {
                            ""originalSrc"": ""https://cdn.shopify.com/s/files/1/0286/pic.jpg"",
                            ""id"": ""gid://shopify/ProductImage/146345345339732""
                        }
                    }
                }
            ]
        }
        }
    }";

    JsonElement doc = JsonSerializer.Deserialize<JsonElement>(raw);

    JsonElement jsonElementEdges = GetJsonElement(doc, "data.products.edges");

    string originalSrcString = default;
    string originalIdString = default;

    if (jsonElementEdges.ValueKind == JsonValueKind.Array)
    {
        int index = 0; // Get the first element in the 'edges' array

        JsonElement edgesFirstElem =
            jsonElementEdges.EnumerateArray().ElementAtOrDefault(index);

        JsonElement jsonElement =
            GetJsonElement(edgesFirstElem, "node.featuredImage.originalSrc");
        originalSrcString = GetJsonElementValue(jsonElement);

        jsonElement =
            GetJsonElement(edgesFirstElem, "node.featuredImage.id");
        originalIdString = GetJsonElementValue(jsonElement);
    }

    if (!string.IsNullOrEmpty(originalSrcString))
    {
        // do stuff
    }

    if (!string.IsNullOrEmpty(originalIdString))
    {
        // do stuff
    }
}
Market answered 27/3, 2021 at 20:41 Comment(0)
U
6

Thank Dave B for a good idea. I have improved it to be more efficient when accessing array elements without having to write too much code.

string raw = @"{
        ""data"": {
        ""products"": {
            ""edges"": [
                {
                    ""node"": {
                        ""id"": ""gid://shopify/Product/4534543543316"",
                        ""featuredImage"": {
                            ""originalSrc"": ""https://cdn.shopify.com/s/files/1/0286/pic.jpg"",
                            ""id"": ""gid://shopify/ProductImage/146345345339732""
                        }
                    }
                },
                {
                    ""node"": {
                        ""id"": ""gid://shopify/Product/123456789"",
                        ""featuredImage"": {
                            ""originalSrc"": ""https://cdn.shopify.com/s/files/1/0286/pic.jpg"",
                            ""id"": [
                                ""gid://shopify/ProductImage/123456789"",
                                ""gid://shopify/ProductImage/666666666""
                            ]
                        },
                        ""1"": {
                            ""name"": ""Tuanh""
                        }
                    }
                }
            ]
        }
        }
    }";

Usage is also quite simple

JsonElement doc = JsonSerializer.Deserialize<JsonElement>(raw);
JsonElement jsonElementEdges = doc.GetJsonElement("data.products.edges.1.node.1.name");



public static JsonElement GetJsonElement(this JsonElement jsonElement, string path)
        {
            if (jsonElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
                return default;

            string[] segments = path.Split(new[] {'.'}, StringSplitOptions.RemoveEmptyEntries);

            foreach (var segment in segments)
            {
                if (int.TryParse(segment, out var index) && jsonElement.ValueKind == JsonValueKind.Array)
                {
                    jsonElement = jsonElement.EnumerateArray().ElementAtOrDefault(index);
                    if (jsonElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
                        return default;

                    continue;
                }

                jsonElement = jsonElement.TryGetProperty(segment, out var value) ? value : default;

                if (jsonElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
                    return default;
            }

            return jsonElement;
        }

        public static string? GetJsonElementValue(this JsonElement jsonElement) => jsonElement.ValueKind != JsonValueKind.Null &&
                                                                                   jsonElement.ValueKind != JsonValueKind.Undefined
            ? jsonElement.ToString()
            : default;
Unseat answered 19/6, 2021 at 12:1 Comment(0)
C
4

I have developed a small library named JsonEasyNavigation, you can get it on github or from nuget.org. It allows you to navigate through JSON Domain Object Model using indexer-like syntax:

var jsonDocument = JsonDocument.Parse(json);
var nav = jsonDocument.ToNavigation();

ToNavigation() method converts JsonDocument into readonly struct named JsonNavigationElement. It has property and array item indexers, for example:

var item = nav["data"]["product"]["edges"][0];

Then you can check for actual items existince like this:

if (item.Exist)
{
   var id = item["id"].GetStringOrEmpty();
   // ...
}

I hope you will find it useful.

Copeland answered 2/11, 2021 at 22:24 Comment(1)
Does .NET's Json really need an additional library to do something like this, which should be very common? I mean, I have been using Newtonsoft Json, and thought of using .NET's, to reduce the number of external libraries, but if I have to add another library just to do common tasks like this, it defeats the very purpose of transitioning from Newtonsoft's. I may just keep using Newtonsoft Json.Rubrician
C
0

Depending on the type of JsonElement returned you have to handle it differently.

My case was that the returned element was ValueKind = Array : "[[47.751]]" So in order to get it I did created this method

private object GetValueFromJsonElement(WorkbookRange range)
{
    // The RootElement is the JsonElement
    var element = range.Values.RootElement.EnumerateArray().First()[0];
    switch (element.ValueKind)
    {
        case JsonValueKind.Number:
            return element.GetDouble();

        case JsonValueKind.String:
            return element.GetString();

        case JsonValueKind.True:
        case JsonValueKind.False:
            return element.GetBoolean();
        default:
            throw new InvalidOperationException("The Value Type returned is not handled");
    }
}Depending on the type of JsonElement  returned you have to handle it differently.
Carrera answered 7/9, 2022 at 9:36 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.