Content Negotiation with ASP.NET Core REST APIs – Part 4 – Client Side Codecs

Part 4 demonstrates using Codecs and the Transcoder in the client of a Web API.

This post is part of a series:

  1. What is Content Negotiation
  2. ASP.NET Core Support for Content Negotation
  3. Codecs and Transcoders
  4. Using Codecs in Clients (this post)
  5. Using Codecs for Data Persistence (coming soon)

ROI – On the Client Side

Part 3 introduced Codecs and the Transcoder for server side usage. These extracted formatting logic into reusable, testable components. In Part 4, let’s reuse these on the client and realize our investment.

The client makes requests to the server and handles the responses. Both requests and/or responses may include content representations in the body. These representations need to be encoded and decoded. The codecs and the transcoder are purpose built for this.

The code snippets below come from the CodecExample.Client project.

Meet Flurl – Fluent Url

I’m a fan of fluent APIs. Flurl is a pleasure to use for building and executing HTTP requests with .NET. We’ll leverage it here to keep the code succinct and intuitive. See their excellent docs (flurl.dev) for details, tutorials and examples.

Flurl makes the simple cases easy without a steep cliff when scenarios get more complex. In our case, we need access to headers and direct access to the request / response body streams. These are exposed via the .NET framework’s HttpResponseMessage, HttpRequestMessage and HttpContent implementations.

GET /WeatherForcast

Below is an example of the code to issue a GET request. The request includes multiple mediatypes in the accept header, and will decode whichever of these the server returns.

GetForecasts()
/// <summary>
/// Get a collection of forecasts.
/// Uses server content negotiation to select the preferred media type.
/// 
/// Accepts: 
///     application/json; Domain=Example.WeatherForecastCollection.Custom; Version=1      q=0.900
///     application/json; Domain=Example.WeatherForecastCollection.Custom; Version=2      q=0.500
///     application/json; Domain=Example.WeatherForecastCollection.Serialized; Version=1; q=0.100 
/// </summary>
public async Task<IEnumerable<WeatherForecast>> GetForecasts()
{
    // Accept multiple media types
    // Includes quality paramters to influence preference for which mediatype to select.
    var acceptedMediaTypes = string.Join(", ",
        "application/json; Domain=Example.WeatherForecastCollection.Custom; Version=1; q=0.500",
        "application/json; Domain=Example.WeatherForecastCollection.Custom; Version=2; q=0.900",
        "application/json; Domain=Example.WeatherForecastCollection.Serialized; Version=1; q=0.100",
    );

    string CodecExampleServiceUri = "https://localhost:5001";

    IFlurlResponse response = await CodecExampleServiceUri
                                .AppendPathSegment("WeatherForecast")
                                .WithHeader("Accept", acceptedMediaTypes)
                                .GetAsync();

    return await DecodeResponse<IEnumerable<WeatherForecast>>(response);
}

Flurl’s GetAsync() returns an IFlurlResponse. We pass that to our DecodeResponse() method to decode the response body’s representation into the specified resource instance. The response’s mediatype is read from the Content-Type header and passed into the transcoder.

DecodeResponse()
/// <summary>
/// Decodes the response representation into the specified resource type
/// using the content type header to select the proper codec.
/// </summary>
/// <typeparam name="T">The type of resource to return.</typeparam>
/// <param name="response">The IFlurlResponse to be parsed.</param>
/// <returns>The resource decoded by the transcoder and codecs.</returns>
private async Task<T> DecodeResponse<T>(IFlurlResponse response)
{
    // Throw exception if not successfull
    response.ResponseMessage.EnsureSuccessStatusCode();

    // The transcoder needs to know what the server actually returned.
    // This is in the Content-Type header of the response.
    var contentType = response.ResponseMessage.Content.Headers.ContentType;
    var mediaType = MediaTypeHeaderValue.Parse(contentType.ToString());  // Use MS class. NET6 only.

    // Log a few details about the request / response to the console.
    var request = response.ResponseMessage.RequestMessage;

    Console.WriteLine($"Handling Response");
    Console.WriteLine($"\tRequest URI:           {request.Method} {request.RequestUri}");
    Console.WriteLine($"\tRequest Accept:        {request.Headers.Accept}");

    Console.WriteLine($"\tResponse Status:       {response.StatusCode}");
    Console.WriteLine($"\tResponse Content-Type: {mediaType}");

    var stream = await response.GetStreamAsync();

    var decoderContext = new DecoderContext()
    {
        InputStream = stream,
        MediaType = mediaType,
        ModelType = typeof(T)
    };

    var responseObject = await Transcoder.ReadAsync(decoderContext);

    return (T)responseObject;
}

POST /WeatherForecast

Below is an example of the code to issue a POST request. The request includes a WeatherForecast to send to the server, which is encoded using the transcoder.

PostForecastV1Custom()
/// <summary>
/// Post a forecast to the server and receive a response with a forecast.
/// 
/// Sends (Content-Type):
///     application/json; Domain=Example.WeatherForecast.Custom; Version=1
/// 
/// Accepts: 
///     application/json; Domain=Example.WeatherForecast.Custom; Version=2;
/// </summary>
public async Task<WeatherForecast> PostForecastV1Custom(WeatherForecast forecast)
{
    // Create the HttpContent that will be posted to the server.
    // This uses the transcoder to encode the resource into a MemoryStream for sending to the server.
    var httpPostContent = await CreateHttpRequestContent<WeatherForecast>(
              forecast,
              "application/json; Domain=Example.WeatherForecast.Custom; Version=2");

    // POST the request to the server.
    var response = await CodecExampleServiceUri
              .AppendPathSegments("WeatherForecast")
              .WithHeader("Accept", "application/json; Domain=Example.WeatherForecast.Custom; Version=2")
              .PostAsync(httpPostContent);

    // Decode the response.
    return await DecodeResponse<WeatherForecast>(response);
}

Flurl’s PostAsync() accepts an instance of HttpContent to specify the request’s body. We create that in the CreateHttpRequestContent() method using the transcoder and appropriate encoder. The HttpContent also carries the request’s Content-Type header.

CreateHttpRequestContent()
/// <summary>
/// Use the transcoder to encode the resource into the desired media type.
/// Then create and populate an HttpContent to send to the server.
/// </summary>
/// <typeparam name="T">The type to use on the EncoderContext.ObjectType.</typeparam>
/// <param name="resource">The resource instance to encode into the HTTP request body.</param>
/// <param name="desiredMediaType">The mediatype that the transcoder should use.</param>
/// <returns>
/// An HttpContent that can be used in the HTTP request body.
/// The ContentType header will be set to the actual mediatype that the transcoder used.
/// </returns>
private async Task<HttpContent> CreateHttpRequestContent<T>(object resource, string desiredMediaType)
{
    // Create the content to post.
    var encoderContext = new EncoderContext()
    {
        DesiredMediaType = MediaTypeHeaderValue.Parse(desiredMediaType),
        Object = resource,
        ObjectType = typeof(T),
        OutputStream = new MemoryStream(),

        // This must not send a BOM (byte order mark) or else the server will fail to parse the JSON.
        Encoding = UTF8EncodingWithoutBOM
    };

    await Transcoder.WriteAsync(encoderContext);

    // Move back to start of stream.
    encoderContext.OutputStream.Position = 0; 

    // Create HttpContent and set the Content-Type header to the media type that was encoded.
    var httpPostContent = new StreamContent(encoderContext.OutputStream);
    httpPostContent.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(encoderContext.ActualMediaType.ToString());

    return httpPostContent;
}

Client Setup

The Client example is a simple console application to use the WeatherForecastClient and output to the console. Setting up the client is relatively simple:

  1. Create a single Transcoder.
  2. Create all relevant codecs and add to the Transcoder.
  3. Create the WeatherForecastClient, passing the transcoder via the constructor.
Program.cs
public class Program
{

    /// <summary>
    /// Uses the WeatherForecastClient to make various HTTP requests to the CodecExample server.
    /// </summary>
    public static async Task Main(string[] args)
    {
        // -------------------
        // Setup
        // -------------------
        var transcoder = GetTranscoder();
        var client = new WeatherForecastClient(transcoder);

        // -------------------
        // Use the client ... 
        // -------------------

        var forecasts = await client.GetForecasts();

        var forecastToSend = new WeatherForecast() { 
                     Date = DateTime.Now, 
                     Summary = "Raining cats!", 
                     TemperatureC = 25 };
        var forecastPostReply = await client.PostForecastV1Custom(forecastToSend);
    }

    /// <summary>
    /// Shows a typical client side setup of the transcoder.
    /// 
    /// Note:
    ///     1. We use a single instance of the transcoder
    ///     2. It has all known/supported codecs.
    ///     
    /// </summary>
    private static Transcoder GetTranscoder()
    {
        var transcoder = new Transcoder();

        // V1 Custom Codecs
        transcoder.Encoders.Add(new WeatherForecastCustomV1Encoder());
        transcoder.Encoders.Add(new WeatherForecastCollectionCustomV1Encoder());
        transcoder.Decoders.Add(new WeatherForecastCustomV1Decoder());
        transcoder.Decoders.Add(new WeatherForecastCollectionCustomV1Decoder());

        // V2 Custom Codecs
        transcoder.Encoders.Add(new WeatherForecastCustomV2Encoder());
        transcoder.Encoders.Add(new WeatherForecastCollectionCustomV2Encoder());
        transcoder.Decoders.Add(new WeatherForecastCustomV2Decoder());
        transcoder.Decoders.Add(new WeatherForecastCollectionCustomV2Decoder());

        // V1 Serilization-based Codecs
        transcoder.Encoders.Add(new WeatherForecastSerializedV1Encoder());
        transcoder.Encoders.Add(new WeatherForecastCollectionSerializedV1Encoder());
        transcoder.Decoders.Add(new WeatherForecastSerializedV1Decoder());
        transcoder.Decoders.Add(new WeatherForecastCollectionSerializedV1Decoder());

        // Other
        transcoder.Encoders.Add(new ValidationProblemsEncoder());

        return transcoder;
    }
}

ROI Realized – What did we gain?

While simple, this example demonstrates some important capabilities. Some of these capabilities are difficult to achieve.

1 – Versioning and Content-Negotiation on the Client

The transcoder and codecs encapsulate the logic to select and apply the proper encoder or decoder. This keeps the client methods simple and clean.

Use of versioned mediatypes means that this client will continue to work properly even if the next version of the server adds support for a new mediatype version. In that case, the server supports both versions. The older client will continue to send an Accept header with the versions that it supports. The server will reply with one of those supported versions.

This decouples the deployment of the client and the server. The server can go first, and then the clients can upgrade on their own time-frames. This is a critically important capability.

2 – Clean support for multiple representations

Multiple codecs can read/write a single resource Type (class). The client method can return this single .net Type regardless of which representation (mediatype) was returned by the server.

In contrast, without the codecs, the client might need multiple resource types for deserialization. Additionally, the method might need mapping logic to map from an old class structure into the class that the method actually returns. This incurs extra code, extra computation, extra memory allocations, and extra GC work to clean up.

3 – Code Reuse

The same classes used on the server can be easily reused on the clients. These codecs can encapsulate many tricky details and edge cases. They can be rigorously tested to ensure correctness.

In contrast, a client implemented by code generation or manually by the consumer of the API may fail to properly handle some scenarios.

4 – Contract Oriented

Tools like OpenAPI Specifications (swagger) allow us to properly and precisely define the schema and operations that the Web API exposes. This contract is important. It is the foundation that clients and servers agree to.

.NET code can insulate us from what is actually flowing through the network. Refactoring the .NET code can result in accidental changes to what is sent or expected. This can be dangerous.

Use of codecs like the “custom” codecs in the example make the schema of the representations explicit and easily understood. These codecs are also less susceptible to refactoring dangers

Custom codecs also make it much simpler to output data in any structure. This is not easy to do with the serialize/deserialize operations, which require that the JSON mimic the classes. But there are cases where this is not the best structure for the representation.

Conclusions

The Client for the CodecExample shows how to use the Transcoder and Codecs on the client. The encapsulated formatting logic makes for powerful clients, capable of handling multiple representations with minimal complexity. The codecs are easily reused between the server and the client, ensuring correctness in the clients.

In Part 5, we’ll use the Transcoder and Codecs for doing server-side data-persistence to document oriented databases. This further extends the ROI of the new concepts to a third use case.


Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

Create a website or blog at WordPress.com

Up ↑

%d bloggers like this: