.NET Shorts – Use HttpClientFactory

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:

  1. What the fuck
  2. A lot of hardcoded strings that could be moved to constants
  3. Not propagating cancellation token
  4. There is response.EnsureSuccessStatusCode() that checks if response is successful, otherwise it throws an HttpRequestException.
  5. We can move logging to delegating handlers

There is one thing that is OK with this method

  1. It works

Solution

  • There are two options you can use for serialization and deserialization
    • System.Text.Json
      • relatively new
      • less features than Newtonsoft.Json
      • part of System namespace
  • 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.