API Clients with .NET Core HttpClient and System.Text.Json

2020-02-04

Why

.NET Core 3.0 came with a new built-in serializer, which should be a bit faster than Newtonsoft.JSON, and a bit better integrated. This will be the default JSON serializer/deserializer for ASP.NET Core going forward.

How

Getting it to work is pretty straightforward. It's a matter of passing it a stream and some options. The tricky part is ISO-8601 datetimes, you'll have to write a custom converter to handle those. See bellow how to do that.

The server

const http = require("http");

http
  .createServer(function (req, res) {
    console.log(req.url);
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(`{"version": 42, "modifiedDate": "${new Date().toISOString()}" }`);
  })
  .listen(3030);

Calling the server will return a JSON with a version and a datetime string.

> curl http://localhost:3030/health
{"version": 42, "modifiedDate": "2020-02-23T16:27:57.046Z" }

The client.

using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;

namespace Api.Client
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var api = new Client("http://127.0.0.1:3030");
            var healthResult = await api.GetHealth();
            Console.WriteLine(healthResult.Version);
        }
    }

    class Client
    {
        static JsonSerializerOptions JsonOptions = new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            DictionaryKeyPolicy = JsonNamingPolicy.CamelCase
        };

        readonly string targetBase;
        readonly HttpClient httpClient;

        public Client(string targetBase)
        {
            this.targetBase = targetBase;
            this.httpClient = new HttpClient();
        }

        private async Task<T> GetAsync<T>(string path)
        {
            var response = await this.httpClient.GetAsync($"{this.targetBase}/{path}");
            using (var responseStream = await response.Content.ReadAsStreamAsync())
            {
                return await JsonSerializer.DeserializeAsync<T>(responseStream, JsonOptions);
            }
        }

        public Task<GetHealthResponse> GetHealth()
        {
            return this.GetAsync<GetHealthResponse>("health");
        }
    }

    class GetHealthResponse
    {
        public int Version { get; set; }
    }
}

Handling datetimes

To handle ISO-8601 datetime strings you'll have to write a converter for datetimes.

using System;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;

namespace Api.Client
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var api = new Client("http://127.0.0.1:3030");
            var healthResult = await api.GetHealth();

            Console.WriteLine(healthResult.ModifiedDate);
        }
    }

    class Client
    {
        static JsonSerializerOptions JsonOptions = new JsonSerializerOptions()
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
        };

        readonly string targetBase;
        readonly HttpClient httpClient;

        static Client()
        {
            JsonOptions.Converters.Add(new DateTimeConverter());
        }

        public Client(string targetBase)
        {
            this.targetBase = targetBase;
            this.httpClient = new HttpClient();
        }

        private async Task<T> GetAsync<T>(string path)
        {
            var response = await this.httpClient.GetAsync($"{this.targetBase}/{path}");
            using (var responseStream = await response.Content.ReadAsStreamAsync())
            {
                return await JsonSerializer.DeserializeAsync<T>(responseStream, JsonOptions);
            }
        }

        public Task<GetHealthResponse> GetHealth()
        {
            return this.GetAsync<GetHealthResponse>("health");
        }
    }

    class GetHealthResponse
    {
        public int Version { get; set; }
        public DateTime ModifiedDate { get; set; }
    }

    public class DateTimeConverter : JsonConverter<DateTime>
    {
        public override DateTime Read(
            ref Utf8JsonReader reader,
            Type typeToConvert,
            JsonSerializerOptions options)
        {
            // an additional allocation because datetime parse
            // accepts ReadonlySpan<char> while Utf8JsonReader.ValueSpan
            // returns a ReaonlySpan<byte>
            return DateTime.Parse(reader.GetString()).ToUniversalTime();
        }

        public override void Write(
            Utf8JsonWriter writer,
            DateTime value,
            JsonSerializerOptions options)
        {
            writer.WriteStringValue(value.ToUniversalTime().ToString("o"));
        }
    }
}

Conclusion

The System.Text.Json serializer is quite clean and fast, and a good alternative to Newtonsoft.JSON.