Problem
There is a history with the HttpClient but it’s now fixed. Long story short – if we useĀ IHttpClientFactory we are OK. Here, we are going to focus more on how to use that IHttpClientFactory correctly. Let’s start with an example from work (note: it’s not the exact code, but the idea was the same).
Let’s start:
public class CompanyCarsAdapter : ICompanyCarsAdapter
{
private readonly HttpClient httpClient;
private readonly IMapper mapper;
private readonly ILogger<CompanyCarsAdapter> logger;
public CompanyCarsAdapter(
HttpClient httpClient,
IMapper mapper,
ILogger<CompanyCarsAdapter> logger)
{
this.httpClient = httpClient;
this.mapper = mapper;
this.logger = logger;
}
public async Task CreateAsync(Car car)
{
string json =
"{" +
"\"Name\":\"" + car.Name +"\"," +
"\"Consumption\":" + car.Consumption + "," +
"\"NumberOfCylinders\": " + car.NumberOfCylinders + "," +
"\"HorsePower\":" + car.HorsePower + "," +
"\"WeightInKilograms\":" + car.Weight + "," +
"\"AccelerationInKilometersPerHour\":" + car.Acceleration + "," +
"\"Year\":\"" + car.Year + "\"," +
"\"Origin\":\"" + car.Origin + "\"" +
"}";
HttpRequestMessage message = new(HttpMethod.Post, "/api/v1/cars");
message.Content = new StringContent(json, Encoding.UTF8, "application/json");
HttpResponseMessage response = await this.httpClient.SendAsync(message);
if (!response.IsSuccessStatusCode)
{
string error = await response.Content.ReadAsStringAsync();
this.logger.LogError(error);
throw new ApplicationException(error);
}
}
}
There are a couple of things wrong with this method:
- What the fuck
- A lot of hardcoded strings that could be moved to constants
- Not propagating cancellation token
- There is response.EnsureSuccessStatusCode() that checks if response is successful, otherwise it throws an HttpRequestException.
- We can move logging to delegating handlers
There is one thing that is OK with this method
- It works
Solution
- There are two options you can use for serialization and deserialization
- Newtonsoft.Json
- a bit more mature
- has more features
- is additional dependency
- System.Text.Json
- relatively new
- less features than Newtonsoft.Json
- part of System namespace
- Newtonsoft.Json
- If you have to use external API that has “funny” naming conventions, dig deeper in aformentioned libraries, there is probably a way to handle it – attributes, converters etc.
- Propagate cancellation token
- Don’t hardcode strings in methods, transfer them to constants.
- Use a delegating handler for logging HTTP requests.
- Use a delegating handler for error handling.
- Always create a data transfer model that is used only as contract between your infrastructure layer and external REST API.
- Always use IHttpClientFactory
When I need to interact with external API, I like to take following steps:
- Create data transfer objects that will serve as requests and responses
namespace Company.Shorts.Infrastucture.Http.CarsServiceAdapter.Internal.Models
{
using System;
internal sealed class CarDto
{
public Guid Id { get; set; }
public string Name { get; set; } = default!;
public int Consumption { get; set; }
public int NumberOfCylinders { get; set; }
public int HorsePower { get; set; }
public int Weight { get; set; }
public int Acceleration { get; set; }
public DateTimeOffset Year { get; set; }
public string Origin { get; set; } = default!;
}
}
namespace Company.Shorts.Infrastucture.Http.CarsServiceAdapter.Internal.Models
{
using System;
internal sealed class CreateCarDto
{
public string Name { get; set; } = default!;
public int Consumption { get; set; }
public int NumberOfCylinders { get; set; }
public int HorsePower { get; set; }
public int WeightInKilograms { get; set; }
public int AccelerationInKilometersPerHour { get; set; }
public DateTimeOffset Year { get; set; }
public string Origin { get; set; } = default!;
}
}
- Create the Endpoint.cs
- We are going to place routes here
- Not my finest moment – guess why
namespace Company.Shorts.Infrastucture.Http.CarsServiceAdapter.Internal
{
internal static class Endpoints
{
internal static readonly string Post = "/api/v1/cars";
internal static readonly string Get = "/api/v1/cars";
}
}
- Create the HttpClientBase.cs
- Let’s extract some reusable logic
namespace Company.Shorts.Infrastructure.Http.CarsServiceAdapter.Internal.Clients
{
using System.Text.Json;
using System.Threading.Tasks;
public abstract class HttpClientBase
{
protected HttpClientBase(HttpClient httpClient)
{
this.HttpClient = httpClient;
}
protected HttpClient HttpClient { get; }
protected async Task<T?> SendAsync<T>(HttpRequestMessage message, CancellationToken cancellationToken)
where T : class
{
var response = await this.HttpClient.SendAsync(message, cancellationToken);
var json = await response.Content.ReadAsStringAsync(cancellationToken);
return string.IsNullOrEmpty(json)
? null
: JsonSerializer.Deserialize<T>(json, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
protected async Task SendAsync(HttpRequestMessage message, CancellationToken cancellationToken)
{
await this.HttpClient.SendAsync(message, cancellationToken);
}
}
}
- Create the CompanyCarsHttpClient.cs
- Purpose of this class is to create HttpRequestMessage, serialize/deserialize data transfer object and send it via HttpClient
- Notice how we are not worrying about logging and error handling yet since we are planning to handle it somewhere else.
namespace Company.Shorts.Infrastucture.Http.CarsServiceAdapter.Internal.Clients
{
using Company.Shorts.Infrastructure.Http.CarsServiceAdapter.Internal.Clients;
using Company.Shorts.Infrastucture.Http.CarsServiceAdapter.Internal.Models;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
internal sealed class CompanyCarsHttpClient : HttpClientBase, ICompanyCarsHttpClient
{
private const string ApplicationJson = "application/json";
public CompanyCarsHttpClient(HttpClient httpClient) : base(httpClient)
{
}
public async Task CreateAsync(CreateCarDto createCarDto, CancellationToken cancellationToken)
{
string json = JsonSerializer.Serialize(createCarDto);
HttpRequestMessage message = new(HttpMethod.Post, Endpoints.Post);
message.Content = new StringContent(json, Encoding.UTF8, ApplicationJson);
await this.HttpClient.SendAsync(message, cancellationToken);
}
public async Task<IEnumerable<CarDto>> GetAsync(CancellationToken cancellationToken)
{
HttpRequestMessage message = new(HttpMethod.Get, Endpoints.Get);
return await this.SendAsync<List<CarDto>>(message, cancellationToken) ?? new();
}
}
}
- Create the CompanyCarsAdapter.cs
- Purpose of this class is to accept our doiman object and map it to data transfer object.
- Having CompanyCarsAdapter.cs and CompanyCarsHttpClient.cs may be an overkill but this is just my personal preference.
namespace Company.Shorts.Infrastucture.Http.CarsServiceAdapter.Internal
{
using AutoMapper;
using Company.Shorts.Application.Contracts.Http;
using Company.Shorts.Domain;
using Company.Shorts.Infrastucture.Http.CarsServiceAdapter.Internal.Clients;
using Company.Shorts.Infrastucture.Http.CarsServiceAdapter.Internal.Models;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
internal sealed class CompanyCarsAdapter : ICompanyCarsAdapter
{
private readonly IMapper mapper;
private readonly ICompanyCarsHttpClient companyCarsHttpClient;
public CompanyCarsAdapter(IMapper mapper, ICompanyCarsHttpClient companyCarsHttpClient)
{
this.mapper = mapper;
this.companyCarsHttpClient = companyCarsHttpClient;
}
public async Task CreateAsync(Car car, CancellationToken cancellationToken)
{
var dto = this.mapper.Map<CreateCarDto>(car);
await this.companyCarsHttpClient.CreateAsync(dto, cancellationToken);
}
public async Task<List<Car>> GetAsync(CancellationToken cancellationToken)
{
var response = await this.companyCarsHttpClient.GetAsync(cancellationToken);
return this.mapper.Map<List<Car>>(response);
}
}
}
- Create the DefaultHttpDelegatingHandler.cs
- It will take care of logging for us
- It will call response.EnsureSuccessStatusCode()
namespace Company.Shorts.Infrastructure.Http.CarsServiceAdapter.Internal
{
using Microsoft.Extensions.Logging;
using System;
using System.Diagnostics;
using System.Threading.Tasks;
internal class DefaultHttpDelegatingHandler : DelegatingHandler
{
private readonly ILogger<DefaultHttpDelegatingHandler> logger;
public DefaultHttpDelegatingHandler(ILogger<DefaultHttpDelegatingHandler> logger)
{
logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
this.logger.LogInformation(
"Sending {RequestMethod} request towards {Request}",
request.Method,
request.RequestUri?.ToString());
var stopwatch = Stopwatch.StartNew();
HttpResponseMessage response = default!;
try
{
response = await base.SendAsync(request, cancellationToken);
stopwatch.Stop();
this.logger.LogInformation(
"Response took {ElapsedMiliseconds}ms {StatusCode}",
stopwatch.ElapsedMilliseconds,
response.StatusCode);
response.EnsureSuccessStatusCode();
return response;
}
catch (Exception exception)
when (exception is TaskCanceledException or TimeoutException)
{
stopwatch.Stop();
this.logger.LogError(
"Timeout durning {RequestMethod} to {RequestUri} after {ElapsedMiliseconds}ms {StatusCode}",
request.Method,
request.RequestUri?.ToString(),
stopwatch.ElapsedMilliseconds,
response.StatusCode);
throw;
}
catch (Exception exception)
{
stopwatch.Stop();
var message = await response.Content.ReadAsStringAsync(cancellationToken);
if (!string.IsNullOrEmpty(message))
{
message = $"Remote server replied with message: '{message}'";
}
this.logger.LogError(
exception,
"Exception durning {RequestMethod} to {RequestUri} after {ElapsedMiliseconds}ms {StatusCode}.{Message}",
request.Method,
request.RequestUri?.ToString(),
stopwatch.ElapsedMilliseconds,
response.StatusCode,
message);
throw;
}
}
}
}
- Register everything in the DependencyInjection.cs
namespace Company.Shorts.Infrastructure.Http.CarsServiceAdapter
{
using Company.Shorts.Application.Contracts.Http;
using Company.Shorts.Infrastructure.Http.CarsServiceAdapter.Internal;
using Company.Shorts.Infrastucture.Http.CarsServiceAdapter.Internal;
using Company.Shorts.Infrastucture.Http.CarsServiceAdapter.Internal.Clients;
using Microsoft.Extensions.DependencyInjection;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructureHttpCarServiceAdapter(this IServiceCollection services, CarServiceAdapterSettings settings)
{
services
.AddTransient<DefaultHttpDelegatingHandler>();
services
.AddHttpClient<ICompanyCarsHttpClient, CompanyCarsHttpClient>(opt => opt.BaseAddress = new Uri(settings.Url))
.AddHttpMessageHandler<DefaultHttpDelegatingHandler>();
services
.AddScoped<ICompanyCarsAdapter, CompanyCarsAdapter>();
return services;
}
}
public class CarServiceAdapterSettings
{
public const string Key = nameof(CarServiceAdapterSettings);
public string Url { get; set; } = default!;
}
}
- Call extension method from the Startup.cs
- And here is project structure for Company.Shorts.Infrastructure.Http.CarsServiceAdapter
- Company.Shorts
- Github
- branch: shorts/use-http-client-correctly
- checkout README.md file
- Company.Cars
- Github
- branch: main
- check README.md
Benefits?
- Error handling is extracted to a delegating handler, you can always expect a result in your clients/adapters
- If External API changes, you’ll need to change your data transfer models and mapping in Infrastructure Layer. The rest of the application doesn’t need to be changed.