BT

Facilitating the Spread of Knowledge and Innovation in Professional Software Development

Write for InfoQ

Topics

Choose your language

InfoQ Homepage Articles Creating and Using HTTP Client SDKs in .NET 6

Creating and Using HTTP Client SDKs in .NET 6

This item in japanese

Key Takeaways

  • Writing and maintaining HTTP Client SDKs is a very important skill for modern .NET developers working with distributed systems.
  • In order to properly manage HTTP connections, you need to design your API Clients to be ready to be consumed from any Dependency Injection container.
  • A good client SDK is composable, providing straightforward ways to configure and extend it.
  • Testing HTTP Client SDKs can be very beneficial in certain scenarios and it gives you additional confidence in your code.
  • There are many ways of developing HTTP Client SDKs. This article helps you to choose the right one according to your scenario.

 

Today's cloud-based, microservice-based or internet-of-things applications often depend on communicating with other systems across a network. Each service runs in its process and solves a bounded set of problems. Communication between services is based on a lightweight mechanism, often an HTTP resource API. 

From a .NET developer perspective, we want to provide a consistent and manageable way of integrating with a particular service in the form of a distributable package. Preferably, we also want to ship the service integration code we develop as a NuGet package and share it with other people, teams, or even organizations. In this article, I will share many aspects of creating and using HTTP Client SDKs using .NET 6.

Client SDKs provide a meaningful abstraction layer over remote service. Essentially, it allows making Remote Procedure Calls (RPC) The responsibility of a Client SDK is to serialize some data, send it to a remote destination, deserialize incoming data, and process a response.

HTTP Client SDKs are used in conjunction with APIs to:

  1. Speed-up the API integration process
  2. Provide a consistent and standard approach
  3. Give partial control to service owners over the way APIs are consumed

Writing an HTTP Client SDK

In this article, we will write a full-fledged Dad Jokes API Client. Its purpose is to serve dad jokes; let's have some fun. The source code can be found at GitHub.

When developing a Client SDK to be used with an API, it is a good idea to start from the interface contract (between the API and the SDK):


public interface IDadJokesApiClient
{
	Task<JokeSearchResponse> SearchAsync(
  		string term, CancellationToken cancellationToken);

	Task<Joke> GetJokeByIdAsync(
    	string id, CancellationToken cancellationToken);

	Task<Joke> GetRandomJokeAsync(CancellationToken cancellationToken);
}

public class JokeSearchResponse
{
	public bool Success { get; init; }

	public List<Joke> Body { get; init; } = new();
}

public class Joke
{
	public string Punchline { get; set; } = default!;

	public string Setup { get; set; } = default!;

	public string Type { get; set; } = default!;
}

The contract is created based on the API with which you are integrating. My general recommendations are to develop common-purpose APIs and follow the Robustness Principle and the Principle of least astonishment. But it is also totally fine if you want to modify and transform data contracts based on your needs and just think about it from a consumer perspective.

The bread and butter of HTTP-based integrations is the HttpClient. It contains everything you need to work with HTTP abstractions successfully.


public class DadJokesApiClient : IDadJokesApiClient
{
	private readonly HttpClient httpClient;

	public DadJokesApiClient(HttpClient httpClient) =>
    		this.httpClient = httpClient;
}

We usually use JSON over HTTP APIs, which is why since .NET 5 the System.Net.Http.Json namespace was added to BCL. It provides many extension methods for HttpClient and HttpContent that perform serialization and deserialization using System.Text.Json. If you don't have something complex and exotic, I suggest using System.Net.Http.Json because it frees you from writing boilerplate code. Not only is it boring, but it also is not trivial to get it right in the most efficient and bug-free way from the get-go. I suggest you check Steves' Gordon blog post - sending and receiving JSON using HttpClient

public async Task<Joke> GetRandomJokeAsync(CancellationToken cancellationToken)
{
	var jokes = await this.httpClient.GetFromJsonAsync<JokeSearchResponse>(
    	ApiUrlConstants.GetRandomJoke, cancellationToken);

	if (jokes is { Body.Count: 0 } or { Success: false })
	{
    	// consider creating custom exceptions for situations like this
    	throw new InvalidOperationException("This API is no joke.");
	}

	return jokes.Body.First();
}

Tip: You may want to create some centralized place to manage the endpoints URLs, like this:

public static class ApiUrlConstants
{
	public const string JokeSearch = "/joke/search";

	public const string GetJokeById = "/joke";

	public const string GetRandomJoke = "/random/joke";
}

Tip: If you need to deal with complex URIs - use Flurl. It provides a fluent URL-building experience.

public async Task<Joke> GetJokeByIdAsync(string id, CancellationToken cancellationToken)
{
	// $"{ApiUrlConstants.GetJokeById}/{id}"
	var path = ApiUrlConstants.GetJokeById.AppendPathSegment(id);

	var joke = await this.httpClient.GetFromJsonAsync<Joke>(path, cancellationToken);

	return joke ?? new();
}

Next, we must specify the required headers (and other required configurations). We want to provide a flexible mechanism for configuring HttpClient used as part of the SDK. In this case, we need to supply credentials in the custom header and specify a well-known "Accept" header.

Tip: Expose high-level building blocks as HttpClientExtensions. It makes it easy to discover API-specific configurations. For example, if you have a custom authorization mechanism, it should be supported by SDK (at least provide documentation for it).

public static class HttpClientExtensions
{
	public static HttpClient AddDadJokesHeaders(
    		this HttpClient httpClient, string host, string apiKey)
	{
    	var headers = httpClient.DefaultRequestHeaders;
    	headers.Add(ApiConstants.HostHeader, new Uri(host).Host);
    	headers.Add(ApiConstants.ApiKeyHeader, apiKey);

    	return httpClient;
	}
}

Client Lifetime

To construct our DadJokesApiClient we need to create a HttpClient. As you know, HttpClient implements IDisposable because there is an underlying unmanageable resource - TCP connection. Only a limited amount of concurrent TCP connections can be opened simultaneously on a single machine. This consideration also brings an important question - "Should I create HttpClient every time I need it or only once during an application startup?"

HttpClient is a shared object. This means that under the covers, it is reentrant and thread-safe. Instead of creating a new HttpClient instance for each execution, you should share a single instance of HttpClient. However, this approach also comes with its own set of issues. For example, the client will keep connections open for the lifespan of the application, it won't respect the DNS TTL settings, and it will never get DNS updates. So this isn't a perfect solution either.

You need to manage a pool of TCP connections disposed of from time to time to respect DNS updates. This is exactly what HttpClientFactory does. The official documentation describes HttpClientFactory as being "an opinionated factory for creating HttpClient instances to be used in your applications." We will see how to use it in a moment.

Each time you get an HttpClient object from the IHttpClientFactory, a new instance is returned. But each HttpClient uses an HttpMessageHandler that's pooled and reused by the IHttpClientFactory to reduce resource consumption. Pooling of handlers is desirable as each handler typically manages its underlying HTTP connections. Some handlers also keep connections open indefinitely, preventing the handler from reacting to DNS changes. HttpMessageHandler has a limited lifetime.

Below, you can see how HttpClientFactory comes into play when using HttpClient managed by dependency injection (DI).

Consuming API Clients

In our example, a basic usage scenario for consuming an API is a console application without a dependency injection container. The goal here is to give consumers the fastest way possible to access an existing API.

Create a static factory method that creates an API Client.

public static class DadJokesApiClientFactory
{
	public static IDadJokesApiClient Create(string host, string apiKey)
	{
    	var httpClient = new HttpClient()
    	{
        		BaseAddress = new Uri(host);
    	}
    	ConfigureHttpClient(httpClient, host, apiKey);

    	return new DadJokesApiClient(httpClient);
	}

	internal static void ConfigureHttpClient(
    		HttpClient httpClient, string host, string apiKey)
	{
    	ConfigureHttpClientCore(httpClient);
    	httpClient.AddDadJokesHeaders(host, apiKey);
	}

	internal static void ConfigureHttpClientCore(HttpClient httpClient)
	{
    	httpClient.DefaultRequestHeaders.Accept.Clear();
    	httpClient.DefaultRequestHeaders.Accept.Add(new("application/json"));
	}
}

This way, we can use IDadJokesApiClient from the console application:

var host = "https://dad-jokes.p.rapidapi.com";
var apiKey = "<token>";

var client = DadJokesApiClientFactory.Create(host, apiKey);
var joke = await client.GetRandomJokeAsync();

Console.WriteLine($"{joke.Setup} {joke.Punchline}");

Consuming API Clients. HttpClientFactory

The next step is to configure HttpClient as part of a dependency injection container. I will not go into details over this subject: there are a lot of good stuff on the internet. Once again, there is a really good article from Steve Gordon - HttpClientFactory in ASP.NET Core

To add a pooled HttpClient instance using DI, you need to use IServiceCollection.AddHttpClient from Microsoft.Extensions.Http.

Provide a custom extension method to add typed HttpClient in DI.

public static class ServiceCollectionExtensions
{
	public static IHttpClientBuilder AddDadJokesApiClient(
    	this IServiceCollection services,
    	Action<HttpClient> configureClient) =>
        	services.AddHttpClient<IDadJokesApiClient, DadJokesApiClient>((httpClient) =>
        	{
            	DadJokesApiClientFactory.ConfigureHttpClientCore(httpClient);
            	configureClient(httpClient);
        	});
}

Use the extension method as follows:

var host = "https://da-jokes.p.rapidapi.com";
var apiKey = "<token>";

var services = new ServiceCollection();

services.AddDadJokesApiClient(httpClient =>
{
	httpClient.BaseAddress = new(host);
	httpClient.AddDadJokesHeaders(host, apiKey);
});

var provider = services.BuildServiceProvider();
var client = provider.GetRequiredService<IDadJokesApiClient>();

var joke = await client.GetRandomJokeAsync();

logger.Information($"{joke.Setup} {joke.Punchline}");

As you see, you can use IHttpClientFactory outside of ASP.NET Core. For example, console applications, workers, lambdas, etc.

Let's see it running:

The interesting part here is that clients created by DI automatically log outgoing requests, making development and troubleshooting so much easier.

If you manipulate the format of the log template and add SourceContext and EventId you can see that HttpClientFactory adds additional handlers itself. This is useful when you try to troubleshoot issues related to HTTP request processing.

{SourceContext}[{EventId}] // pattern

System.Net.Http.HttpClient.IDadJokesApiClient.LogicalHandler [{ Id: 100, Name: "RequestPipelineStart" }]
	System.Net.Http.HttpClient.IDadJokesApiClient.ClientHandler [{ Id: 100, Name: "RequestStart" }]
	System.Net.Http.HttpClient.IDadJokesApiClient.ClientHandler [{ Id: 101, Name: "RequestEnd" }]
System.Net.Http.HttpClient.IDadJokesApiClient.LogicalHandler [{ Id: 101, Name: "RequestPipelineEnd" }]

The most common scenario is web applications. Here is .NET 6 MinimalAPI example:

var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
var configuration = builder.Configuration;
var host = configuration["DadJokesClient:host"];

services.AddDadJokesApiClient(httpClient =>
{
	httpClient.BaseAddress = new(host);
	httpClient.AddDadJokesHeaders(host, configuration["DADJOKES_TOKEN"]);
});

var app = builder.Build();

app.MapGet("/", async (IDadJokesApiClient client) => await client.GetRandomJokeAsync());

app.Run();

{
  "punchline": "They are all paid actors anyway,"
  "setup": "We really shouldn't care what people at the Oscars say,"
  "type": "actor"
}

Extending HTTP Client SDKs. Adding cross-cutting concerns via DelegatingHandler

HttpClient also provides an extension point: a message handler. It is a class that receives an HTTP request and returns an HTTP response. A wide variety of problems could be expressed as cross-cutting concerns. For example, logging, authentication, caching, header forwarding, auditing, etc. Aspect-oriented programming aims to encapsulate cross-cutting concerns into aspects to retain modularity. Typically, a series of message handlers are chained together. The first handler receives an HTTP request, does some processing, and gives the request to the next handler. At some point, the response is created and goes back up the chain.

// supports the most common requirements for most applications
public abstract class HttpMessageHandler : IDisposable
{}
// plug a handler into a handler chain
public abstract class DelegatingHandler : HttpMessageHandler
{}

Task. Assume you need to copy a list of headers from ASP.NET Core HttpContext and pass them to all outgoing requests made by Dad Jokes API client.

public class HeaderPropagationMessageHandler : DelegatingHandler
{
	private readonly HeaderPropagationOptions options;
	private readonly IHttpContextAccessor contextAccessor;

	public HeaderPropagationMessageHandler(
    	HeaderPropagationOptions options,
    	IHttpContextAccessor contextAccessor)
	{
    	this.options = options;
    	this.contextAccessor = contextAccessor;
	}

	protected override Task<HttpResponseMessage> SendAsync(
    	HttpRequestMessage request, CancellationToken cancellationToken)
	{
    	if (this.contextAccessor.HttpContext != null)
    	{
        	foreach (var headerName in this.options.HeaderNames)
        	{
            	var headerValue = this.contextAccessor
                	.HttpContext.Request.Headers[headerName];

            	request.Headers.TryAddWithoutValidation(
                	headerName, (string[])headerValue);
        	}
    	}

    		return base.SendAsync(request, cancellationToken);
	}
}

public class HeaderPropagationOptions
{
	public IList<string> HeaderNames { get; set; } = new List<string>();
}

We want to "plug" a DelegatingHandler into the HttpClient request pipeline.

For non-IHttpClientFactory scenarios, we want clients to specify a DelegatingHandler list to build an underlying chain for HttpClient.

//DadJokesApiClientFactory.cs
public static IDadJokesApiClient Create(
	string host,
	string apiKey,
	params DelegatingHandler[] handlers)
{
	var httpClient = new HttpClient();

	if (handlers.Length > 0)
	{
    	_ = handlers.Aggregate((a, b) =>
    	{
        	a.InnerHandler = b;
        	return b;
    	});
    	httpClient = new(handlers[0]);
	}
	httpClient.BaseAddress = new Uri(host);

	ConfigureHttpClient(httpClient, host, apiKey);

	return new DadJokesApiClient(httpClient);
}

So, without using a DI container, extending DadJokesApiClient could be done like this:

var loggingHandler = new LoggingMessageHandler(); //outermost
var authHandler = new AuthMessageHandler();
var propagationHandler = new HeaderPropagationMessageHandler();
var primaryHandler = new HttpClientHandler();  // the default handler used by HttpClient

DadJokesApiClientFactory.Create(
	host, apiKey,
	loggingHandler, authHandler, propagationHandler, primaryHandler);

// LoggingMessageHandler ➝ AuthMessageHandler ➝ HeaderPropagationMessageHandler ➝ HttpClientHandler

In DI container scenarios, on the other hand, we want to provide an auxiliary extension method to easily plug HeaderPropagationMessageHandler by using IHttpClientBuilder.AddHttpMessageHandler.

public static class HeaderPropagationExtensions
{
	public static IHttpClientBuilder AddHeaderPropagation(
    	this IHttpClientBuilder builder,
    	Action<HeaderPropagationOptions> configure)
	{
    	builder.Services.Configure(configure);
    	builder.AddHttpMessageHandler((sp) =>
    	{
        	return new HeaderPropagationMessageHandler(           	 
                sp.GetRequiredService<IOptions<HeaderPropagationOptions>>().Value,
            	sp.GetRequiredService<IHttpContextAccessor>());
    	});

    		return builder;
	}
}

Here is how extended MinimalAPI example looks like:

var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
var configuration = builder.Configuration;
var host = configuration["DadJokesClient:host"];

services.AddDadJokesApiClient(httpClient =>
{
	httpClient.BaseAddress = new(host);
	httpClient.AddDadJokesHeaders(host, configuration["DADJOKES_TOKEN"]);
}).AddHeaderPropagation(o => o.HeaderNames.Add("X-Correlation-ID"));

var app = builder.Build();

app.MapGet("/", async (IDadJokesApiClient client) => await client.GetRandomJokeAsync());

app.Run();

Sometimes functionality like this is reused by other services. You might want to take it one step further and factor out all shared code into a common NuGet package and use it in HTTP Client SDKs.

Third-Party Extensions

Not only can we write our message handlers. There are many useful NuGet packages provided and supported by the .NET OSS community. Here are my favorites:

Resiliency patterns - retry, cache, fallback, etc.: Very often, in a distrusted systems world, you need to ensure high availability by incorporating some resilience policies. Luckily, we have a built-in solution to build and define policies in .NET - Polly. There is out-of-the-box integration with IHttpClientFactory provided by Polly. This uses a convenience method, IHttpClientBuilder.AddTransientHttpErrorPolicy. It configures a policy to handle errors typical of HTTP calls: HttpRequestException HTTP 5XX status codes (server errors), HTTP 408 status code (request timeout).

services.AddDadJokesApiClient(httpClient =>
{
	httpClient.BaseAddress = new(host);
}).AddTransientHttpErrorPolicy(builder => builder.WaitAndRetryAsync(new[]
{
	TimeSpan.FromSeconds(1),
	TimeSpan.FromSeconds(5),
	TimeSpan.FromSeconds(10)
}));

For example, transient errors might be handled proactively by using Retry and Circuit Breaker patterns. Usually, we use a retry pattern when there is a hope that downstream service will self-correct eventually. Waiting between retries provides an opportunity for a downstream service to stabilize. It is common to use retries based on the Exponential Backoff algorithm. On paper, it sounds great, but in real-world scenarios, the retry pattern may be overused. Additional retries might be the source of additional load or spikes. In the worst case, resources in the caller may then become exhausted or excessively blocked, waiting for replies which will never come causing an upstream-cascading failure.

This is when the Circuit Breaker pattern comes into play. It detects the level of faults and prevents calls to a downstream service when a fault threshold is exceeded. Use this pattern when there is no chance of succeeding - for example, where a subsystem is completely offline or struggling under load. The idea behind Circuit Breaker is pretty straightforward, although, you might build something more complex on top of it. When faults exceed the threshold, calls are placed through the circuit, so instead of processing a request, we practice the fail-fast approach, throwing an exception immediately.

Polly is really powerful and it provides a way to combine resilience strategies. See PolicyWrap.
Here is a classification of the strategies you might want to use:

Designing reliable systems could be a challenging task, I suggest you investigate the subject on your own. Here is a good introduction - .NET microservices - Architecture e-book: Implement resilient applications

Authentication in OAuth2/OIDC: If you need to manage user and client access tokens, I suggest using IdentityModel.AspNetCore. It acquires, caches, and rotates tokens for you. See the docs.

// adds user and client access token management
services.AddAccessTokenManagement(options =>
{
	options.Client.Clients.Add("identity-provider", new ClientCredentialsTokenRequest
	{
    	Address = "https://demo.identityserver.io/connect/token",
    	ClientId = "my-awesome-service",
    	ClientSecret = "secret",
    	Scope = "api" 
	});
});
// registers HTTP client that uses the managed client access token
// adds the access token handler to HTTP client registration
services.AddDadJokesApiClient(httpClient =>
{
	httpClient.BaseAddress = new(host);
}).AddClientAccessTokenHandler();

Testing HTTP Client SDKs

You should be pretty comfortable designing and writing your HTTP Client SDKs by this time. The only thing left is to write some tests to ensure expected behavior. Note that it might be good to skip extensive unit testing and write more integration or e2e to ensure proper integration. For now, I will show you how to unit test DadJokesApiClient.

As you have seen previously, HttpClient is extensible. Furthermore, we can replace the standard HttpMessageHandler with the test version. So, instead of sending actual requests over the wire, we will use the mock. This technique opens tons of opportunities because we can simulate all kinds of HttpClient behaviors that otherwise could be hard to replicate in a normal situation.

Let's define reusable methods to create a mock of HttpClient that we will pass as a dependency to DadJokesApiClient.

public static class TestHarness
{
	public static Mock<HttpMessageHandler> CreateMessageHandlerWithResult<T>(
    	T result, HttpStatusCode code = HttpStatusCode.OK)
	{
    	var messageHandler = new Mock<HttpMessageHandler>();
    	messageHandler.Protected()
        	.Setup<Task<HttpResponseMessage>>(
            	"SendAsync",
            	ItExpr.IsAny<HttpRequestMessage>(),
            	ItExpr.IsAny<CancellationToken>())
        	.ReturnsAsync(new HttpResponseMessage()
        	{
            	StatusCode = code,
            	Content = new StringContent(JsonSerializer.Serialize(result)),
        	});

    	return messageHandler;
	}

	public static HttpClient CreateHttpClientWithResult<T>(
    	T result, HttpStatusCode code = HttpStatusCode.OK)
	{
    	var httpClient = new HttpClient(CreateMessageHandlerWithResult(result, code).Object)
    	{
        	BaseAddress = new("https://api-client-under-test.com"),
    	};

    	Return httpClient;
	}
}

From this point, unit testing is a pretty simple process:

public class DadJokesApiClientTests
{
	[Theory, AutoData]
	public async Task GetRandomJokeAsync_SingleJokeInResult_Returned(Joke joke)
	{
    	// Arrange
    	var response = new JokeSearchResponse
    	{
        	Success = true,
        	Body = new() { joke }
    	};
    	var httpClient = CreateHttpClientWithResult(response);
    	var sut = new DadJokesApiClient(httpClient);

    	// Act
    	var result = await sut.GetRandomJokeAsync();

    	// Assert
    	result.Should().BeEquivalentTo(joke);
	}

	[Fact]
	public async Task GetRandomJokeAsync_UnsuccessfulJokeResult_ExceptionThrown()
	{
    	// Arrange
    	var response = new JokeSearchResponse();
    	var httpClient = CreateHttpClientWithResult(response);
    	var sut = new DadJokesApiClient(httpClient);

    	// Act
    	// Assert
    	await FluentActions.Invoking(() => sut.GetRandomJokeAsync())
        		.Should().ThrowAsync<InvalidOperationException>();
	}
}

Using HttpClient is the most flexible approach. You have full control over integration with APIs. But, there is a downside, you need to write a lot of boilerplate code. In some situations, an API you are integrating with is trivial, so you don't need all capabilities provided by HttpClient,HttpRequestMessage,HttpResponseMessage.

Pros ➕:

  • Full control over behavior and data contracts. You can even write a "smart" API Client and move some logic inside SDK if it makes sense for a particular scenario. For example, you can throw custom exceptions, transform requests and responses, provide default values for headers, etc.
  • Full control over serialization and deserialization process
  • Easy to debug and troubleshoot. A stack trace is simple, and you can always spin up the debugger to see what is happening under the hood.

Cons ➖:

  • Need to write a lot of repetitive code
  • Someone should maintain a codebase in case of API changes and bugs. This is a tedious and error-prone process.

Writing HTTP Client SDK with a declarative approach

The less code, the fewer bugs.

Refit is an automatic type-safe REST library for .NET. It turns your REST API into a live interface. Refit uses System.Text.Json as the default JSON serializer.

Every method must have an HTTP attribute that provides the request method and relative URL.

using Refit;

public interface IDadJokesApiClient
{
	/// <summary>
	/// Searches jokes by term.
	/// </summary>
	[Get("/joke/search")]
	Task<JokeSearchResponse> SearchAsync(
    	string term,
    	CancellationToken cancellationToken = default);

	/// <summary>
	/// Gets a joke by id.
	/// </summary>
	[Get("/joke/{id}")]
	Task<Joke> GetJokeByIdAsync(
    	string id,
    	CancellationToken cancellationToken = default);

	/// <summary>
	/// Gets a random joke.
	/// </summary>
	[Get("/random/joke")]
	Task<JokeSearchResponse> GetRandomJokeAsync(
    	CancellationToken cancellationToken = default);
}

Refit generates type that implements IDadJokesApiClient based on information provided by Refit.HttpMethodAttribute

Consuming API Clients. Refit

The approach is the same as for vanilla HttpClient integration, but instead of constructing a client manually, we use the static method provided by Refit.

public static class DadJokesApiClientFactory
{
	public static IDadJokesApiClient Create(
    	HttpClient httpClient,
    	string host,
    	string apiKey)
	{
    	httpClient.BaseAddress = new Uri(host);

    	ConfigureHttpClient(httpClient, host, apiKey);

    	return RestService.For<IDadJokesApiClient>(httpClient);
	}
	// ...
}

For DI container scenarios, we can use the Refit.HttpClientFactoryExtensions.AddRefitClient extension method.

public static class ServiceCollectionExtensions
{
	public static IHttpClientBuilder AddDadJokesApiClient(
    	this IServiceCollection services,
    	Action<HttpClient> configureClient)
	{
    	var settings = new RefitSettings()
    	{
        	ContentSerializer = new SystemTextJsonContentSerializer(new JsonSerializerOptions()
        	{
            	PropertyNameCaseInsensitive = true,
            	WriteIndented = true,
        	})
    	};

    	return services.AddRefitClient<IDadJokesApiClient>(settings).ConfigureHttpClient((httpClient) =>
    	{
        	DadJokesApiClientFactory.ConfigureHttpClient(httpClient);
        	configureClient(httpClient);
    	});
	}
}

Usage:

var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;

Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateBootstrapLogger();
builder.Host.UseSerilog((ctx, cfg) => cfg.WriteTo.Console());

var services = builder.Services;

services.AddDadJokesApiClient(httpClient =>
{
	var host = configuration["DadJokesClient:host"];
	httpClient.BaseAddress = new(host);
	httpClient.AddDadJokesHeaders(host, configuration["DADJOKES_TOKEN"]);
});

var app = builder.Build();

app.MapGet("/", async Task<Joke> (IDadJokesApiClient client) =>
{
	var jokeResponse = await client.GetRandomJokeAsync();

	return jokeResponse.Body.First(); // unwraps JokeSearchResponse
});

app.Run();

Note, since the contract of the generated client should match the underlying data contract, we no longer have control of contract transformation, and this responsibility is delegated to consumers.

Let's see how the code above works in practice. The output of the MinimalAPI example is different because I've added Serilog logging.

{
  "punchline": "Forgery.",
  "setup": "Why was the blacksmith charged with?",
  "type": "forgery"
}

As usual, there are some pros and some cons:

Pros ➕:

  • Easy to use and develop API clients.
  • Highly configurable. Flexible enough to get things done.
  • No need for additional unit testing

Cons ➖:

  • Hard to troubleshoot. Sometimes it can be hard to understand how the generated code works. For example, there is a mismatch in configuration.
  • Requires other team members to understand how to read and write code developed with Refit.
  • Still consumes some time for medium/large APIs.

Honorable mentions: RestEase

Writing HTTP Client SDK using an automated approach

There is a way to automate HTTP Client SDKs fully. The OpenAPI/Swagger specification uses JSON and JSON Schema to describe a RESTful web API. The NSwag project provides tools to generate client code from these OpenAPI specifications. Everything can be automated via CLI (distributed via NuGet tool, build target, or NPM).

Dad Jokes API doesn't provide OpenAPI, so I wrote it manually. Fortunately, it was quite easy to do:

openapi: '3.0.2'
info:
  title: Dad Jokes API
  version: '1.0'
servers:
  - url: https://dad-jokes.p.rapidapi.com
paths:
  /joke/{id}:
	get:
  	description: ''
  	operationId: 'GetJokeById'
  	parameters:
  	- name: "id"
    	in: "path"
    	description: ""
    	required: true
    	schema:
      	type: "string"
  	responses:
    	'200':
      	description: successful operation
      	content:
        	application/json:
          	schema:
            	"$ref": "#/components/schemas/Joke"
  /random/joke:
	get:
  	description: ''
  	operationId: 'GetRandomJoke'
  	parameters: []
  	responses:
    	'200':
      	description: successful operation
      	content:
        	application/json:
          	schema:
            	"$ref": "#/components/schemas/JokeResponse"
  /joke/search:
	get:
  	description: ''
  	operationId: 'SearchJoke'
  	parameters: []
  	responses:
    	'200':
      	description: successful operation
      	content:
        	application/json:
          	schema:
            	"$ref": "#/components/schemas/JokeResponse"
components:
  schemas:
	Joke:
  	type: object
  	required:
  	- _id
  	- punchline
  	- setup
  	- type
  	properties:
    	_id:
      	type: string
    	type:
      	type: string
    	setup:
      	type: string
    	punchline:
      	type: string
	JokeResponse:
  	type: object
  	properties:
    	sucess:
      	type: boolean
    	body:
      	type: array
      	items:
        	$ref: '#/components/schemas/Joke'

Now, we want to generate HTTP Client SDK automatically. Let's use NSwagStudio.

Here is how the generated IDadJokesApiClient looks like (XML comments are deleted for brevity):

[System.CodeDom.Compiler.GeneratedCode("NSwag", "13.10.9.0 (NJsonSchema v10.4.1.0 (Newtonsoft.Json v12.0.0.0))")]
	public partial interface IDadJokesApiClient
	{
    	System.Threading.Tasks.Task<Joke> GetJokeByIdAsync(string id);
    
    	System.Threading.Tasks.Task<Joke> GetJokeByIdAsync(string id, System.Threading.CancellationToken cancellationToken);
    
    	System.Threading.Tasks.Task<JokeResponse> GetRandomJokeAsync();
    
    	System.Threading.Tasks.Task<JokeResponse> GetRandomJokeAsync(System.Threading.CancellationToken cancellationToken);
    
    	System.Threading.Tasks.Task<JokeResponse> SearchJokeAsync();
    
    	System.Threading.Tasks.Task<JokeResponse> SearchJokeAsync(System.Threading.CancellationToken cancellationToken);
	}

As usual, we want to provide the registration of typed clients as an extension method:

public static class ServiceCollectionExtensions
{
	public static IHttpClientBuilder AddDadJokesApiClient(
    	this IServiceCollection services, Action<HttpClient> configureClient) =>
        	services.AddHttpClient<IDadJokesApiClient, DadJokesApiClient>(
            	httpClient => configureClient(httpClient));
}

Usage:

var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
var services = builder.Services;

services.AddDadJokesApiClient(httpClient =>
{
	var host = configuration["DadJokesClient:host"];
	httpClient.BaseAddress = new(host);
	httpClient.AddDadJokesHeaders(host, configuration["DADJOKES_TOKEN"]);
});

var app = builder.Build();

app.MapGet("/", async Task<Joke> (IDadJokesApiClient client) =>
{
	var jokeResponse = await client.GetRandomJokeAsync();

	return jokeResponse.Body.First();
});

app.Run();

Let's run it and enjoy the last joke of this article:

{
  "punchline": "And it's really taken off,"
  "setup": "So I invested in a hot air balloon company...",
  "type": "air"
}

Pros ➕:

  • Based on the well-known specification
  • Supported by a rich set of tools and vibrant community
  • Fully automated, new SDK can be generated as part of CI/CD process every time OpenAPI specification is changed
  • Generate SDKs for multiple programming languages
  • Relatively easy to troubleshoot since we can see the code generated by the toolchain.

Cons ➖:

  • Can't be applied without proper OpenAPI specification
  • Hard to customize and control the contract of generated API Client

Honorable mentions: AutoRest, Visual Studio Connected Services

Choosing the right approach

In this article, we learned three different ways of producing SDK clients. The process of choosing the right approach can be simplified as follows:

I'm a simple man/woman/non-binary. I want to have full control over my HTTP Client integration.

Use the manual approach.

I'm a busy man/woman/non-binary, but I still want to have somewhat control.

Use a declarative approach.

I'm a lazy man/woman/non-binary. Do the thing for me.

Use an automated approach.

Decision chart:

Summary

In this article, we have reviewed different ways of developing HTTP Client SDKs. Choosing the right approach depends on specific use-case and requirements, but I hope this article gives you the foundations you need to make the best design decisions when designing your own Client SDK. Thank you.

About the Author

Rate this Article

Adoption
Style

BT