3

I have a C# Asp.Net MVC (5.2.7) app with support for WebApi 2.x targeting .Net 4.5.1. I am experimenting with F# and I added an F# library project to the solution. The web app references the F# library.

Now, I want to be able to have the C# WebApi controller return F# objects and also save F# objects. I have trouble serializing a F# record with an Option field. Here is the code:

C# WebApi controller:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;
using FsLib;


namespace TestWebApp.Controllers
{
  [Route("api/v1/Test")]
  public class TestController : ApiController
  {
    // GET api/<controller>
    public IHttpActionResult Get()
    {
      return Json(Test.getPersons);
    }

    // GET api/<controller>/5
    public string Get(int id)
    {
      return "value";
    }
    [AllowAnonymous]
    // POST api/<controller>
    public IHttpActionResult Post([FromBody] Test.Person value)
    {
      return Json(Test.doSomethingCrazy(value));
    }

    // PUT api/<controller>/5
    public void Put(int id, [FromBody]string value)
    {
    }

    // DELETE api/<controller>/5
    public void Delete(int id)
    {
    }
  }
}

FsLib.fs:

namespace FsLib

open System.Web.Mvc
open Option
open Newtonsoft.Json

module Test =

  [<CLIMutable>]
  //[<JsonConverter(typeof<Newtonsoft.Json.Converters.IdiomaticDuConverter>)>] 
  type Person = {
    Name: string; 
    Age: int; 
    [<JsonConverter(typeof<Newtonsoft.Json.FSharp.OptionConverter>)>]
    Children: Option<int> }

  let getPersons = [{Name="Scorpion King";Age=30; Children = Some 3} ; {Name = "Popeye"; Age = 40; Children = None}] |> List.toSeq
  let doSomethingCrazy (person: Person) = {
    Name = person.Name.ToUpper(); 
    Age = person.Age + 2 ;
    Children = if person.Children.IsNone then Some 1 else person.Children |> Option.map (fun v -> v + 1);  }

   let deserializePerson (str:string) = JsonConvert.DeserializeObject<Person>(str)  

Here is the OptionConverter:

namespace Newtonsoft.Json.FSharp

open System
open System.Collections.Generic
open Microsoft.FSharp.Reflection
open Newtonsoft.Json
open Newtonsoft.Json.Converters

/// Converts F# Option values to JSON
type OptionConverter() =
    inherit JsonConverter()

    override x.CanConvert(t) = 
        t.IsGenericType && t.GetGenericTypeDefinition() = typedefof<option<_>>

    override x.WriteJson(writer, value, serializer) =
        let value = 
            if value = null then null
            else 
                let _,fields = FSharpValue.GetUnionFields(value, value.GetType())
                fields.[0]  
        serializer.Serialize(writer, value)

    override x.ReadJson(reader, t, existingValue, serializer) =        
        let innerType = t.GetGenericArguments().[0]
        let innerType = 
            if innerType.IsValueType then typedefof<Nullable<_>>.MakeGenericType([|innerType|])
            else innerType        
        let value = serializer.Deserialize(reader, innerType)
        let cases = FSharpType.GetUnionCases(t)
        if value = null then FSharpValue.MakeUnion(cases.[0], [||])
        else FSharpValue.MakeUnion(cases.[1], [|value|])

I want to serialize the Option field to the value, if it is not None and to null if it is None. And vice-versa, null -> None, value -> Some value.

The serialization works fine:

[
    {
        "Name": "Scorpion King",
        "Age": 30,
        "Children": 3
    },
    {
        "Name": "Popeye",
        "Age": 40,
        "Children": null
    }
]

However, when I post to the url, Person parameter is serialized to null and the ReadJson method is not invoked. I used Postman (the chrome app) to post by selecting the Body -> x-www-form-urlencoded. I set up three parameters: Name=Blob,Age=103,Children=2.

In WebApiConfig.cs I have:

config.Formatters.JsonFormatter.SerializerSettings.Converters.Add(new Newtonsoft.Json.FSharp.OptionConverter());

However, if I just have this and remove the JsonConverter attribute from the Children field, it doesn't seem to have any effect.

This what gets sent to the server:

POST /api/v1/Test HTTP/1.1
Host: localhost:8249
Content-Type: application/x-www-form-urlencoded
Cache-Control: no-cache
Postman-Token: b831e048-2317-2580-c62f-a00312e9103b

Name=Blob&Age=103&Children=2

So, I don't know what's wrong, why the deserializer converts the object to null. If I remove the option field it works fine. Also if I remove the Children field from the payload it works fine as well. I do not understand why the ReadJson method of the OptionCoverter is not invoked.

Any ideas?

Thanks

An update: In the comments it was rightly pointed I did not post a application/json payload. I did it and the serialization still doesn't work properly.

Update 2: After more testing, this works:

public IHttpActionResult Post(/*[FromBody]Test.Person value */)
{
  HttpContent requestContent = Request.Content;
  string jsonContent = requestContent.ReadAsStringAsync().Result;

  var value = Test.deserializePerson(jsonContent);
  return Json(Test.doSomethingCrazy(value));
}

The following is the Linqpad code I used for testing the request (I didn't use postman):

var baseAddress = "http://localhost:49286/api/v1/Test";

var http = (HttpWebRequest) WebRequest.Create(new Uri(baseAddress));
http.Accept = "application/json";
http.ContentType = "application/json";
http.Method = "POST";

string parsedContent = "{\"Name\":\"Blob\",\"Age\":100,\"Children\":2}";
ASCIIEncoding encoding = new ASCIIEncoding();
Byte[] bytes = encoding.GetBytes(parsedContent);

Stream newStream = http.GetRequestStream();
newStream.Write(bytes, 0, bytes.Length);
newStream.Close();

var response = http.GetResponse();

var stream = response.GetResponseStream();
var sr = new StreamReader(stream);
var content = sr.ReadToEnd();

content.Dump();

Update 3:

This works just fine - i.e. I used a C# class:

   public IHttpActionResult Post(/*[FromBody]Test.Person*/ Person2 value)
    {
//      HttpContent requestContent = Request.Content;
//      string jsonContent = requestContent.ReadAsStringAsync().Result;
//
//      var value = Test.deserializePerson(jsonContent);
      value.Children = value.Children.HasValue ? value.Children.Value + 1 : 1;
      return Json(value);//Json(Test.doSomethingCrazy(value));
    }
   public class Person2
    {
      public string Name { get; set; }
      public int Age { get; set; }
      public int? Children { get; set; }
    }
boggy
  • 3,674
  • 3
  • 33
  • 56
  • Well `Name=Blob&Age=103&Children=2` isn't JSON, it's `application/x-www-form-urlencoded`. See [this answer](https://stackoverflow.com/a/55985011) to [What is the difference between form-data, x-www-form-urlencoded and raw in the Postman Chrome application?](https://stackoverflow.com/q/26723467) which shows both `Content-Type: application/x-www-form-urlencoded` and `Content-Type: application/json`. I think you need to send JSON to get your JSON converter invoked. – dbc Oct 02 '19 at 22:34
  • Maybe a duplicate? [How to send JSON Object from POSTMAN to Restful webservices](https://stackoverflow.com/q/58209838/3744182) and [postman - post application/json data](https://stackoverflow.com/q/29430228/3744182). – dbc Oct 02 '19 at 22:39
  • I didn't have time, but I am going to test it with a web page. It seems that Postman has all sorts of issues - actually I had issues with the full app and then I switched to chrome app because it wasn't working. – boggy Oct 02 '19 at 22:47
  • You guys are right! But I submitted an application/json payload and the ReadJson method is still not invoked. – boggy Oct 02 '19 at 23:04
  • In that case I'd try to narrow things down. First try calling `JsonConvert.DeserializeObject()` directly your types and the JSON payload and verify it actually works. Once you have unit-tested `ReadJson()` then address why it isn't getting called. (Maybe it is, and an exception is thrown?) – dbc Oct 02 '19 at 23:11
  • 1
    I tested it and it works just fine. This ```return Json(Test.deserializePerson($"{{\"Name\":\"Blob\",\"Age\":100,\"Children\":2}}"));``` deserializes properly and it invokes ReadJson where ```let deserializePerson (str:string) = JsonConvert.DeserializeObject(str)``` – boggy Oct 02 '19 at 23:24

2 Answers2

2

I found a fix, based on this post: https://stackoverflow.com/a/26036775/832783.

I added in my WebApiConfig Register method this line:

config.Formatters.JsonFormatter.SerializerSettings.ContractResolver = new DefaultContractResolver();

I still have to investigate what's happening here.

Update 1:

It turns out that calling Json(...) in the ApiController method creates a new JsonSerializerSettings object instead of using the default set in the global.asax. What this means is that any converters added there don't have any effect on the serialization to json when the Json() result is used.

boggy
  • 3,674
  • 3
  • 33
  • 56
2

Just to throw another option in, if you're willing to upgrade the framework version you could use the new System.Text.Json APIs which should be nice and fast, and the FSharp.SystemTextJson package gives you a custom converter with support for records, discriminated unions etc.

drkmtr
  • 164
  • 1
  • 6
  • Good to know. Unfortunately I am stuck with a big web app to .Net framework 4.5.1. Can this be plugged into the WebApi context? – boggy Oct 04 '19 at 17:15