Tarmil / FSharp.SystemTextJson

System.Text.Json extensions for F# types
MIT License
329 stars 45 forks source link

Serialize Tuple as an object #116

Open isaacabraham opened 2 years ago

isaacabraham commented 2 years ago

It would be nice to be able to serialize a tuple directly to an object rather than a list e.g. ("name", "foo") => { "name" : "foo" }. I can't see if there's any way to do that. My use case is that I want to serialize a list of tuples to JSON in the above style rather than as a list of lists. I can use a Map / Dictionary here, except that that enforces uniqueness of the key, which I don't want.

Any ideas?

Tarmil commented 2 years ago

To be clear, you would like something like this?

JsonSerializer.Serialize [("name", "foo"); ("other", "bar")]
// --> [{"name":"foo"},{"other":"bar"}]

I think that's a bit too specific and niche to include in FSharp.STJ. But you can absolutely implement a dedicated Converter that handles (string * _) list this way.

open System.Text.Json
open System.Text.Json.Serialization
open FSharp.Core.CompilerServices

type LookupListConverter<'V>() =
    inherit JsonConverter<(string * 'V) list>()

    override this.Read(reader, typeToConvert, options) =
        if reader.TokenType <> JsonTokenType.StartArray then raise (JsonException "Expected array")
        let mutable l = ListCollector()
        while reader.Read() && reader.TokenType <> JsonTokenType.EndArray do
            if reader.TokenType <> JsonTokenType.StartObject then raise (JsonException "Expected object")
            if not (reader.Read() && reader.TokenType = JsonTokenType.PropertyName) then raise (JsonException "Expected non-empty object")
            let key = reader.GetString()
            let value = JsonSerializer.Deserialize<'V>(&reader, options)
            if not (reader.Read() && reader.TokenType = JsonTokenType.EndObject) then raise (JsonException "Expected single-property object")
            l.Add((key, value))
        l.Close()

    override this.Write(writer, value, options) =
        writer.WriteStartArray()
        for k, v in value do
            writer.WriteStartObject()
            writer.WritePropertyName(k)
            JsonSerializer.Serialize<'V>(writer, v, options)
            writer.WriteEndObject()
        writer.WriteEndArray()

type LookupListConverter() =
    inherit JsonConverterFactory()

    override this.CanConvert(typeToConvert) =
        typeToConvert.IsGenericType &&
        typeToConvert.GetGenericTypeDefinition() = typedefof<_ list> &&
        let tparam = typeToConvert.GetGenericArguments()[0]
        tparam.IsGenericType &&
        tparam.GetGenericTypeDefinition() = typedefof<_ * _> &&
        tparam.GetGenericArguments()[0] = typeof<string>

    override this.CreateConverter(typeToConvert, options) =
        let tparam = typeToConvert.GetGenericArguments().[0].GetGenericArguments().[1]
        typedefof<LookupListConverter<_>>.MakeGenericType(tparam)
            .GetConstructor([||])
            .Invoke([||])
        :?> JsonConverter

// Usage:
let options = JsonSerializerOptions()
options.Converters.Add(ListLookupConverter())
JsonSerializer.Serialize([("name", "foo"); ("other", "bar")], options)
abelbraaksma commented 1 year ago

Quite often, two-tuples are used in (unique or not) KV setting, similar to how the dict constructor works. Perhaps we could consider a setting like TwoTupleAsKeyValueObjects.

I usually work around this limitation by using a record instead of a tuple, but it’d be nice if this were configurable. I do get the “it’s too niche” comment though, but is it really? ;).

Tarmil commented 1 year ago

I do get the “it’s too niche” comment though, but is it really? ;).

Well, I was basing it on the fact that I don't think I've ever seen this kind of JSON in the wild, but apparently it's common enough that multiple people are requesting it 😄

We could add an option for this. What should it apply to exactly though? Any 'K * 'V tuple? Specifically string * 'V? Only lists of these? My guess would be to apply it specifically to (string * 'V) list, like in my code above, but I'm open to suggestions (and MRs).

abelbraaksma commented 1 year ago

@Tarmil Any enumerable seems reasonable (not just list, I mean), and yes, string * 'T seems sensible enough. I can imagine it being useful to other types than string, but it’s probably better to be strict.

It’ll be behind an option, so if we want different behaviour, like people wanting this for all tuples, we can enable that explicitly later.

@isaacabraham you wrote the OP, what do you think?