Building a .NET 7 Minimal API with Delegated Redis Caching

Dave Toland

17/11/2023

Building a .NET 7 Minimal API with Delegated Redis Caching

In the world of modern web development, the demand for efficient and responsive APIs has never been greater. Whether you're building a data-driven application, a mobile app, or a website, the ability to fetch and deliver data quickly is crucial. In this blog post, we'll explore a .NET 7 Minimal API that harnesses the power of Redis caching to deliver Formula 1 data from a 3rd-party source with lightning speed. We'll also dive into the mechanics of HTTP message handling, HttpClient injection, and Docker integration to make it all work seamlessly.

Delegated Redis Caching

Caching, if used correctly, is a great technique for improving the performance of web applications. In this project, we take caching to the next level by implementing it with Redis. This approach allows us to intercept incoming API requests, determine if the response is already cached, serve it from the cache if available, and retrieve it for the cache if not. Let's break down how it works:

HTTP Message Handler Interception

At the core of the caching mechanism is the DelegatingHandler which sits in the HTTP message handling pipeline. This handler allows us to capture each request, and from there we can create a cache entry for each unique request pattern, i.e. the combination of url and any query parameters.

The flow for this is as follows:

  1. A request arrives at the HttpClient.

  2. The DelegatingHandler intercepts the request.

  3. If the response is not cached, the request is passed through to the 3rd-party API.

  4. The response is then added to the cache using a unique key based on the request/query.

  5. For subsequent identical requests within the cache timeout period, the cached response is returned instead of making a new API call.

This interception mechanism ensures that we only hit the 3rd-party API when necessary, reducing response times and network load.

HttpClient Injection

To set up our HttpClient for making API requests, we utilize the AddHttpClient<T> method of the IServiceCollection. We then call AddHttpMessageHandler on that HttpClient, passing in our custom DelegatingHandler:

builder.Services.AddHttpClient<ApiSportsClient>()
    .AddHttpMessageHandler<CachedResponseHandler>();

This configuration ensures that all requests made through the ApiSportsClient will go through our caching mechanism.

CachedResponseHandler

The CachedResponseHandler is the heart of our caching strategy. It's a custom DelegatingHandler responsible for intercepting requests and providing responses. Here's the core of its functionality:

public class CachedResponseHandler : DelegatingHandler
{
    //..

    protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
    {
        var cacheKey = BuildCacheKey(request).ValueOrDefault; 
        var cacheRequest = await GetContentFromCache(cacheKey, ct);
        if (cacheRequest.IsSuccess)
            return cacheRequest.Value;
  
      var response = await base.SendAsync(request, ct);
      await CacheResponseContent(response, cacheKey, ct);
  
      return response;
    }

    //...
}

This handler manages the caching logic, ensuring that we only fetch data from the 3rd-party API when necessary.

Asynchronous Stream and Deserialization

When we do need to call the 3rd-party API, we make the most of asynchronous operations for efficiency. By using ReadAsStreamAsync on the HttpContent of the response and JsonSerializer.DeserializeAsync, we can stream and deserialize data directly from the response stream, eliminating the need for buffering or preloading:

public async Task<Result<IList<T>>> Get<T>(string url, CancellationToken cancellationToken)
{
    var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_uri, url.Trim('/')));
    using var result = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);

    using var contentStream = await result.Content.ReadAsStreamAsync(cancellationToken);
    var response = await JsonSerializer.DeserializeAsync<ApiResponse<T>>(contentStream, JsonOptions, cancellationToken);
    return response.Data.ToResult();
}

This efficient deserialization process keeps the API fast and responsive.

The repository with full source code for the F1 API and a step by step guide for getting it up and running with Docker in various IDEs can be found on my GitHub: https://github.com/davetoland/F1Api