1

Is there something like XmlRootAttribute that can be used with System.Text.JsonSerializer?

I need to be able to download data from this vendor using both XML an JSON. See sample data here:

{
    "categories": [
        {
            "id": 125,
            "name": "Trade Balance",
            "parent_id": 13
        }
    ]
}

Note the data elements are wrapped by an array named categories. When downloading using XML I can set the root element to categories and the correct object is returned (see XMLClient below ). When using JSONClient however I cannot (or do not know how) to set the root element. Best workaround I could find is to use JsonDocument which creates a string allocation. I could also create some wrapper classes for the JSON implementation but that is a lot of work that involves not only creating additional DTOs but also requires overwriting many methods on BaseClient. I also don't want to write converters - it pretty much defeats the purpose of using well-known serialization protocols. (Different responses will have a different root wrapper property name. I know the name in runtime, but not necessarily at compile time.)

public class JSONClient : BaseClient
{
    protected override async Task<T> Parse<T>(string uri, string root)
    {
        uri = uri + (uri.Contains("?") ? "&" : "?") + "file_type=json";
        var document = JsonDocument.Parse((await Download(uri)), new JsonDocumentOptions { AllowTrailingCommas = true });
        string json = document.RootElement.GetProperty(root).GetRawText(); // string allocation 
        return JsonSerializer.Deserialize<T>(json);
    }
}

public class XMLClient : BaseClient
{
    protected override async Task<T> Parse<T>(string uri, string root)
    {
        return (T)new XmlSerializer(typeof(T), new XmlRootAttribute(root)).Deserialize(await Download(uri)); // want to do this - using JsonSerializer
    }
}


public abstract class BaseClient
{
    protected virtual async Task<Stream> Download(string uri)
    {
        uri = uri + (uri.Contains("?") ? "&" : "?") + "api_key=" + "xxxxx";
        var response = await new HttpClient() { BaseAddress = new Uri(uri) }.GetAsync(uri);
        return await response.Content.ReadAsStreamAsync();
    }

    protected abstract Task<T> Parse<T>(string uri, string root) where T : class, new();

    public async Task<Category> GetCategory(string categoryID)
    {
        string uri = "https://api.stlouisfed.org/fred/category?category_id=" + categoryID;
        return (await Parse<List<Category>>(uri, "categories"))?.FirstOrDefault();
    }
}
dbc
  • 104,963
  • 20
  • 228
  • 340
  • 1
    Why not create a task `CategoryParent` with a `List Categories` property? Then you can just deserialize to that, and extract `Categories[0]` afterwards. – Jon Skeet Mar 12 '21 at 19:52
  • Because I have to do that for many classes - and I have to create overrides for all my methods in BaseClient. It's just ugly and adds a lot of code I will need to maintain. XmlRootAttribute is an elegant solution that solves this problem. If it's not available for JsonSerializer it should be. –  Mar 12 '21 at 19:55
  • 1
    Why would you have to do that for many classes? I've suggested using a generic type, so you should be able to use that generic type within `JSONClient.Parse`. (And no, personally I *don't* think it should be in JsonSerializer... you're basically trying to say "I want to add an extra layer to my model just temporarily" - that doesn't strike me as very clean, whereas deserializing to a generic type to make that *actually part of the model* seems pretty elegant.) – Jon Skeet Mar 12 '21 at 20:06
  • The root name varies from type to type in this api so I would need to create a wrapper for each type of data object. I see what you are saying about the extra layer - I don't want to create an extra layer I'm pretty much stuck with what the vendor has done. BTW it's pretty common to serialize json data in a "data" element. –  Mar 12 '21 at 20:12
  • @Sam - Your question doesn't include the JSON so it isn't entirely clear where the problem lies. Is it that root object wrapper `{ "categories": ... }` can sometimes have a different property name? – dbc Mar 12 '21 at 20:33
  • BTW you have a severe memory leak in your XML code. When you create an `XmlSerializer` with an override root element name, you must cache and reuse the serializer statically. See [Memory Leak using StreamReader and XmlSerializer](https://stackoverflow.com/q/23897145/3744182) and the [docs](https://docs.microsoft.com/en-us/dotnet/api/system.xml.serialization.xmlserializer?view=net-5.0#dynamically-generated-assemblies) for why. For an example of how to do it see `XmlSerializerFactory` from [Wrap properties with CData Section - XML Serialization C#](https://stackoverflow.com/a/34138648/3744182). – dbc Mar 12 '21 at 20:36
  • @dbc please see the link titled See sample data here. –  Mar 12 '21 at 20:36
  • I saw the title and linked documentation, but it's helpful for the question to be self-contained, as explained in [ask]. – dbc Mar 12 '21 at 20:36
  • 1
    @dbc this is code torn out of my project and higly simplified for this question. My project uses a dictionary of XmlSerializers. –  Mar 12 '21 at 20:38
  • @dbc >Is it that root object wrapper { "categories": ... } can sometimes have a different property name? Yes thats it. –  Mar 12 '21 at 20:44

1 Answers1

2

JSON has no concept of a root element (or element names in general), so there's no equivalent to XmlRootAttribute in System.Text.Json (or Json.NET for that matter). Rather, it has the following two types of container, along with several atomic value types:

  • Objects, which are unordered sets of name/value pairs. An object begins with {left brace and ends with }right brace.

  • Arrays, which are ordered collections of values. An array begins with [left bracket and ends with ]right bracket.

As System.Text.Json.JsonSerializer is designed to map c# objects to JSON objects and c# collections to JSON arrays in a 1-1 manner, there's no built-in attribute or declarative option to tell the serializer to automatically descend the JSON hierarchy until a property with a specific name is encountered, then deserialize its value to a required type.

If you need to access some JSON data that is consistently embedded in some wrapper object containing a single property whose name is known at runtime but not compile time, i.e.:

{
    "someRuntimeKnownWrapperPropertyName" : // The value you actually want
}

Then the easiest way to do that would be to deserialize to a Dictionary<string, T> where the type T corresponds to the type of the expected value, e.g.:

protected override async Task<T> Parse<T>(string uri, string root)
{
    uri = uri + (uri.Contains("?") ? "&" : "?") + "file_type=json";
    using var stream = await Download(uri); // Dispose here or not? What about disposing of the containing HttpResponseMessage?
    
    var options = new JsonSerializerOptions
    {
        AllowTrailingCommas = true,
        // PropertyNameCaseInsensitive = false, Uncomment if you need case insensitivity.
    };
    var dictionary = await JsonSerializer.DeserializeAsync<Dictionary<string, T>>(stream, options);
    // Throw an exception if the dictionary does not have exactly one entry, with the required name
    var pair = dictionary?.Single();
    if (pair == null || !pair.Value.Key.Equals(root, StringComparison.Ordinal)) //StringComparison.OrdinalIgnoreCase if required
        throw new JsonException();
    // And now return the value
    return pair.Value.Value;
}

Notes:

dbc
  • 104,963
  • 20
  • 228
  • 340
  • I appreciate your thoughtful answer. I understand Json has no concept of roots. What I'm asking for is a directive for the serializer that says "Start serializing when you encounter an object named "my root" and use that as the start of the json document." Since I cannot materialize my objects from a stream I think it's easier to just use the JsonDocument. Thanks also for your pointers about cleaning up. I removed all that code for brevity. –  Mar 12 '21 at 21:51
  • 2
    @Sam - *Start serializing when you encounter an object named "my root" and use that as the start of the json document.* - you can do that with a custom `JsonConverter`, see e.g. [System.Text.Json deserialization fails with JsonException “read to much or not enough”](https://stackoverflow.com/a/62155881/3744182) which has a converter that descends the incoming JSON hierarchy until it encounters an array, and deserializes that -- but you specifically stated that using a converter was not acceptable. – dbc Mar 12 '21 at 21:55