2

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).

void.pointer
  • 24,859
  • 31
  • 132
  • 243
  • Do you need to handle/support both versions at the same time? – Peter Csala Oct 11 '21 at 09:29
  • 1
    @PeterCsala Yes. And the determination of which to use is based on the version of the API, which is obtained via another endpoint. But that information is already obtained by the time this object will be serialized/deserialized. – void.pointer Oct 12 '21 at 15:10

2 Answers2

1

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
Peter Csala
  • 17,736
  • 16
  • 35
  • 75
  • This is an interesting approach but 2 points: 1) Instead of two separate types, I'd like to always use V2 in my code but if I get a V1, do the work to convert it to a V2 after deserializing and 2) How would you apply this for serializing (sending `Payload` in the reuqest)? I would need to first check the API version and either serialize V2 directly, or convert V2 back to a V1 and serialize that. – void.pointer Oct 14 '21 at 23:01
  • @void.pointer I've addressed both of your concerns. Please check my updated post. – Peter Csala Oct 15 '21 at 07:30
  • @void.pointer Did my proposed solution work for you? – Peter Csala Oct 18 '21 at 16:28
  • 1
    Yes this solution works rather good. I took a few days to let the "code settle", so to speak. I've been happy with it. Thank you for the solution. I also tossed in AutoMapper to help transition/convert between V1 and V2, which made it even more elegant, IMO. – void.pointer Oct 20 '21 at 18:16
0

I know I'm late to the party but I figured I'll share one approach that seems to work quite well, which is using C# operators. This works with both implicit and explicit operators.

I figured I'll share it here if it's helpful to someone.

So my DTOs looks something like this:

public class PersonDto
{
    public string Name { get; set; }
    
    public RolesDto Roles { get; set; }
    
}

public class RolesDto : List<RoleDto>
{
    public static explicit operator RolesDto(string value)
    {
        var roles = new RolesDto();
        
        if (string.IsNullOrEmpty(value))
            return roles;

        var roleNames = value.Split(',').ToList();
        roleNames.ForEach((x) => {
            roles.Add(new RoleDto()
            {
                Name = x
            });
        });
        
        return roles;
    }
    
}

public class RoleDto
{
    public string Name { get; set; }
    
    public static explicit operator RoleDto(string value)
    {
        return new RoleDto()
        {
            Name = value
        };

    }
    
}

The PersonDto looks something like this when serialized:

{name:'Sven',roles:[{name:'Host'}]}

However, using the operators I can also deserialize these representations:

Here with roles as a string[]:

var instance = JsonConvert.DeserializeObject<PersonDto>("{name:'Sven',roles:['Host','Guide']}");

And here as a comma-separated string

JsonConvert.DeserializeObject<PersonDto>("{name:'Sven',roles:'Host,Guide'}");