Search code examples
c#json.netflurl

How do I support a backward breaking change to JSON payload in Flurl / Json.net?


An API I depend on (that I have no control over) previously took a JSON structure like so:

{
  "values": "one, two, three"
}

The API made a backward-breaking change to this payload, and converted the comma-separated values in a string to a JSON array:

{
  "values": ["one", "two", "three"]
}

Using Flurl, the way I deserialized this was:

public class Payload
{
    public string Values { get; set; } = "";
}

// somewhere else where I make the API call
public async Task<Payload> PutPayload(Payload data)
{
    return await "https://theapi.com"
        .PutJsonAsync(data)
        .ReceiveJson<Payload>();
}

I can no longer use string Values but use something like List<string> Values. Here is what I want:

  • I want my code to change to use List<string> Values for the property in the object. All code will be updated to use this new type.
  • If I detect the "old" API, I want to convert the string -> List<string> (via string split) as transparently as reasonably possible. Basically, I don't want my code knowing about two different types for this object, or two versions of this object.

My path to trying to do this was to inherit a class from JsonConverter<string> (in the Newtonsoft.Json package) and apply it to my Payload class:

internal class ConvertCsvStringToArray : JsonConverter<string>
{
    // Implement WriteJson() and ReadJson() somehow
}

public class Payload
{
    [JsonConverter(typeof(ConvertCsvStringToArray))]
    public List<string> Values { get; set; } = new();
}

First, I don't know if I'm headed in the right direction on this. What gave me pause was that it didn't make sense to create a converter for a string type, because I feel like that only works when I implement JsonConverter<string>.ReadJson(). For WriteJson(), it gives me a string but really what I need to do is alter how I write List<string> (it either goes out normally as itself, or I "box" it back into a comma separated list of values as a string.

How can I properly do this? The reason why I liked the JsonConverter approach is that it gives me that "conversion transparency" I really want (my code won't know it's happening since the conversion logic happens behind the scenes).


Solution

  • You can solve this problem in the following way:

    Let's suppose you have defined the different payload classes like this:

    public abstract class Payload<T>
    {
        [JsonProperty("values")]
        public T Values { get; set; }
    }
    
    public class PayloadV1: Payload<string>
    {
    }
    
    public class PayloadV2 : Payload<List<string>>
    {
    }
    

    Then you can take advantage of Json schema to describe how a json should look like for each version:

    private readonly JSchema schemaV1;
    private readonly JSchema schemaV2;
    ...
    var generator = new JSchemaGenerator();
    schemaV1 = generator.Generate(typeof(PayloadV1));
    schemaV2 = generator.Generate(typeof(PayloadV2));
    

    And finally all you need to do is to perform some validation:

    public async Task<Payload> PutPayload(Payload data)
    {
        var json = await "https://theapi.com"
            .PutJsonAsync(data)
            .ReceiveString();
        
        var semiParsed = JObject.Parse(json);
        if(semiParsed.IsValid(schemaV1))
        {
           return JsonConvert.DeserializeObject<PayloadV1>(json);
        }
        else if(semiParsed.IsValid(schemaV2))
        {
           return JsonConvert.DeserializeObject<PayloadV2>(json);
        }
        throw new NotSupportedException("...");
    }
    

    UPDATE #1: Use V2 as primary and V1 as fallback

    In order to be able to use the V2 as the primary version you need some converters. I've implemented them via extension methods:

    public static class PayloadExtensions
    {
        private const string separator = ",";
    
        public static PayloadV2 ToV2(this PayloadV1 v1)
            => new() { Values = v1.Values.Split(separator).ToList() };
    
        public static PayloadV1 ToV1(this PayloadV2 v2)
            => new() { Values = string.Join(separator, v2.Values) };
    }
    

    I've used here C# 9's new expression to avoid repeating the type name

    With these in your hand you can amend the PutPayload method like this:

    public async Task<PayloadV2> PutPayload(PayloadV2 data, string url = "https://theapi.com")
    {
        var json = await url
            .PutJsonAsync(isV1Url(url) ? data.ToV1() : data)
            .ReceiveString();
    
        var semiParsed = JObject.Parse(json);
        if (semiParsed.IsValid(schemaV1))
        {
            return JsonConvert.DeserializeObject<PayloadV1>(json).ToV2();
        }
        else if (semiParsed.IsValid(schemaV2))
        {
            return JsonConvert.DeserializeObject<PayloadV2>(json);
        }
        throw new NotSupportedException("...");
    }
    
    • The method receives a V2 instance and returns a V2 instance
    • The url is received as a parameter to be able to refer to that multiple times
    • The PutJsonAsync receives either a V1 or a V2 instance depending on the url
    • If the response is in a v1 format then you need to call .ToV2() to return a V2 instance