Search code examples
f#json.netflurlclass-visibility

F# internal visibility changes Record constructor behavior


I'm caling an API using Flurl.

//# models.fs
module models =

    type Ticker = { Ask :decimal; Bid :decimal; Last: decimal; High :decimal; Timestamp :int; } 

//# Client.fs
namespace MyLibrary
// ... some code
    url.GetJsonAsync<models.Ticker>()

This works and I can access the ticker.Ask property.

The class models.Ticker is visible from another C# project and the construcor is this:

public Ticker(decimal ask, decimal bid, decimal last, decimal high, int timestamp);

I don't want expose the models module and the Ticker class/record so I changed the visibility to internal:

# models.fs
module internal models =

    type Ticker = { Ask :decimal; Bid :decimal; Last: decimal; High :decimal; Timestamp :int; } 

The code still "compile" but when I run it I have this exception:

  • Newtonsoft.Json.JsonSerializationException: Unable to find a constructor to use for type MyProject.models+Ticker. A class should either have a default constructor, one constructor with arguments or a constructor marked with the JsonConstructor attribute. Path 'high', line 1, position 8. at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateNewObject(JsonReader reader, JsonObjectContract objectContract, JsonProperty containerMember, JsonProperty containerProperty, String id, Boolean& createdFromNonDefaultCreator) at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.CreateObject(JsonReader reader, Type objectType, JsonContract contract, JsonProperty member, JsonContainerContract containerContract, JsonProperty containerMember, Object existingValue) at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.Deserialize(JsonReader reader, Type objectType, Boolean checkAdditionalContent) at Newtonsoft.Json.JsonSerializer.DeserializeInternal(JsonReader reader, Type objectType) at Newtonsoft.Json.JsonSerializer.Deserialize[T](JsonReader reader) at Flurl.Http.Configuration.NewtonsoftJsonSerializer.Deserialize[T](Stream stream) at Flurl.Http.HttpResponseMessageExtensions.ReceiveJson[T](Task`1 response)} System.Exception {Newtonsoft.Json.JsonSerializationException}*

enter image description here

This is the full code example, I'm new with F#, maybe needed to explain the problem:

open Flurl.Http
// open mynamespace where is models

type IClient =
    abstract member GetAsk : decimal   

type MyClient() =

    let getTicker () = 
        let url = "https://www.bitstamp.net/api/v2/ticker/XRPUSD"
        url.GetJsonAsync<models.Ticker>()                

    interface IClient with
        member  __.GetAsk =         
            let ticker = getTicker().Result
            ticker.Ask 

I'm using the Client from a C# project. I imagine the constructor of Ticker changed because of the visibility. But, why? How can I maintain the Ticker class hidden and make the code works as expected?

[Update: use a class]
Using a class

type internal Ticker(ask:decimal, bid :decimal, last: decimal, high :decimal, timestamp :int) =
    member this.Ask = ask    

does the job as expected. If internal it is not visible externally but it is "usable" by the JsonDeserializer.
I'm still confused for the behavior when I use a Record.


Solution

  • Your problem is that essentially all .NET serializers including Json.NET, XmlSerializer, System.Text.Json and so on are designed to serialize the public members of types -- even if the type itself is non-public 1. However, as explained in the F# documentation Rules for Access Control:

    • Accessibility for individual fields of a record type is determined by the accessibility of the record itself. That is, a particular record label is no less accessible than the record itself.

    Thus the fields of an internal record are not public. And there is no way to make them public according to the issue Fields and methods on internal types are impossible to make public #2820.

    To work around this, you are going to need to create a custom contract resolver that, for any nonpublic F# record type, considers all record fields to be public. In addition it will be necessary to invoke the constructor manually because the constructor will also have been marked as nonpublic.

    The following contract resolver does the job:

    type RecordContractResolver() =
        inherit DefaultContractResolver()    
        
        let IsPrivateRecordType(t : Type) = 
            (not t.IsPublic && FSharpType.IsRecord(t, BindingFlags.NonPublic))
        let CreateParameterizedConstructor(c : ConstructorInfo) = 
            ObjectConstructor<Object>(fun a -> c.Invoke(a))  // There's no easy way to manufacture a delegate from a constructor without Reflection.Emit, just call Invoke()
    
        override this.GetSerializableMembers(objectType : Type) = 
            // F# declares nonpublic record members to be nonpublic themselves, so Json.NET won't discover them.  Fix that by adding them in.
            let members = base.GetSerializableMembers(objectType)
            if IsPrivateRecordType(objectType) then
                let baseMembers = HashSet members // Some members might already be included if marked with [JsonProperty]
                let addedMembers = 
                    FSharpType.GetRecordFields(objectType, BindingFlags.Instance ||| BindingFlags.NonPublic)
                    |> Seq.filter (fun p -> p.CanRead && p.GetIndexParameters().Length = 0 && not (baseMembers.Contains(p)) && not (Attribute.IsDefined(p, typeof<JsonIgnoreAttribute>)))
                for p in addedMembers do
                    members.Add(p)
            members
    
        override this.CreateProperty(m : MemberInfo, memberSerialization : MemberSerialization) =
            // Even though F# record members are readable, Json.NET marks them as not readable because they're not public.  Fix that.
            let property = base.CreateProperty(m, memberSerialization)
            if (IsPrivateRecordType(m.DeclaringType)
                && not property.Readable
                && m :? PropertyInfo
                && FSharpType.GetRecordFields(m.DeclaringType, BindingFlags.Instance ||| BindingFlags.NonPublic).Contains(m :?> PropertyInfo)) then
                property.Readable <- true
            property
    
        override this.CreateObjectContract(objectType : Type) = 
            // Pick the constructor with the longest argument list.
            // Adapted from this answer https://stackoverflow.com/a/35865022 By Zoltán Tamási https://stackoverflow.com/users/1323504/zolt%c3%a1n-tam%c3%a1si
            // To https://stackoverflow.com/questions/23017716/json-net-how-to-deserialize-without-using-the-default-constructor
            let contract = base.CreateObjectContract(objectType)
            if IsPrivateRecordType(objectType) then
                let constructors = objectType.GetConstructors(BindingFlags.Instance ||| BindingFlags.Public ||| BindingFlags.NonPublic) |> Seq.sortBy (fun c -> c.GetParameters().Length)
                let c = constructors.LastOrDefault()
                if not (Object.ReferenceEquals(c, null)) then
                    contract.OverrideCreator <- CreateParameterizedConstructor(c)
                    for p in (this.CreateConstructorParameters(c, contract.Properties)) do
                        contract.CreatorParameters.Add(p)
            contract    
    

    Then to manually serialize and deserialize models.Ticker, you would set up your JsonSerializerSettings as follow:

    let internal t = {Ask = 0.7833m; Bid = 0.7833m; Last = 0.7833m; High = 0.7900m; Timestamp  = 1010101} 
    
    let settings = new JsonSerializerSettings()
    settings.ContractResolver <- new RecordContractResolver()
    
    let json = JsonConvert.SerializeObject(t, settings)
    let internal t2 = JsonConvert.DeserializeObject<models.Ticker>(json, settings)
    

    And to configure Flurl.Http with custom settings see their documentation page Configuration : Serializers, which, when translated to F#, should look something like:

    Flurl.Http.FlurlHttp.Configure(fun s -> 
        let settings = new JsonSerializerSettings()
        settings.ContractResolver <- new RecordContractResolver()
        s.JsonSerializer <- NewtonsoftJsonSerializer settings
        )
    

    Notes:

    • RecordContractResolver only modifies the serialization of nonpublic F# record types. Contracts for all other types are unchanged.

    • Newtonsoft recommends to cache contract resolvers for best performance.

    • I tested the contract resolver standalone, but could not test it with Flurl. The Flurl code was written against Flurl.Http version 3.2.4 which is the latest released version at the time of writing this answer.

    • Flurl.Http 4.0 is set to replace Json.NET with System.Text.Json so you will have the same problem all over again if you upgrade. And unfortunately System.Text.Json's more limited contract customization in .NET 7 does not allow for customization of parameterized constructors, so it may prove even more difficult to deserialize internal F# records in that release.

    Demo fiddle here.


    1 One notable exception to this is the deprecated BinaryFormatter which serializes all fields whether public or private. However, according to the docs:

    BinaryFormatter serialization is obsolete and should not be used.