There are lots of ways to work with HttpClient
in .NET. When working with external APIs, I’ve recently discovered a rather useful mechanism that lets me set up a named HttpClient
that I can dependency-inject and define/maintain with ease: Refit. Let’s set up a working example console/worker program with it!
Full Example Source Code
A complete example using the code in this post is available here.
Initial Program and Prerequisite NuGet Packages
To get started, first we’ll create a new worker service. In my case I’ll just use dotnet new worker -o RefitSandbox
to get one up and running. Here’s what Program.cs
looks like out of the box:
using RefitSandbox; // for the "Worker" class
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
var host = builder.Build();
host.Run();
Now let’s get Refit in here. We’ll want a couple NuGet packages this time:
With those installed, we can start defining our Refit client and configuration.
An Example Refit Client
For Refit, we only need to define the interface that we’ll be injecting throughout our program. Refit takes care of generating an implementation that will get used at runtime. For each endpoint, we’ll define a method, what HTTP verb it’ll use, and the relative path. Let’s see how one can look!
public interface IExampleRefitClient
{
[Get("/path/to/endpoint?id={id}")]
public Task<ExampleResponse?> ExampleGetEndpointAsync(int id, CancellationToken cancellationToken);
[Post("/path/to/create")]
public Task<ExampleResponse?> ExamplePostEndpointAsync(ExampleCreateRequestBody request, CancellationToken cancellationToken);
}
public class ExampleCreateRequestBody(string name)
{
public string Name { get; set; } = name;
}
public class ExampleResponse
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
Configuring the Refit Client
With the interface defined, we now have to inform our application that it’s available, and configure it to visit the correct address. We’ll update Program.cs
with the following:
using Refit;
using RefitSandbox;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
builder.Services.AddRefitClient<IExampleRefitClient>() // Returns `IHttpClientBuilder`
.ConfigureHttpClient(httpClient => httpClient.BaseAddress = new Uri("https://example.com/"));
var host = builder.Build();
host.Run();
This defines our Refit client to talk to https://example.com/
. We can configure other aspects of our client like any other HttpClient
, as we’ll show later. Once we’ve added our interface here, we can pass it in through constructors like any other dependency.
using Refit;
namespace RefitSandbox;
public class Worker(ILogger<Worker> logger, IExampleRefitClient exampleRefitClient)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await UseRefitClientAsync(stoppingToken);
}
catch (Exception e)
{
logger.LogError(e, "Exception thrown when using Refit client");
}
}
private async Task UseRefitClientAsync(CancellationToken stoppingToken)
{
// GET: https://example.com/path/to/endpoint?id=42
ExampleResponse? response = await exampleRefitClient.ExampleGetEndpointAsync(42, stoppingToken);
// POST: https://example.com/path/to/create
ExampleCreateRequestBody createRequest = new("John Doe");
ExampleResponse? createResponse = await exampleRefitClient.ExamplePostEndpointAsync(createRequest, stoppingToken);
}
}
Defining Routes
As demonstrated, we can define endpoints with their associated HTTP verbs with an attribute declaration. The route must start with a /
, else an exception will be thrown on startup. You can provide variables for a query string, matching names to the template (e.g. id
in the above example). If you want, you could pass in the whole route! This may be necessary if you would like to configure the routing at runtime, via appsettings.json
or similar.
For POST
requests, you pass the request body in with whatever DTO you have defined. In our example, we use an instance of ExampleCreateRequestBody
to get that job done.
It should also be noted that you technically don’t have to make these methods asynchronous (i.e. using Task<>
, passing in CancellationToken
), but I recommend it. These are asynchronous operations by nature, after all! I also recommend you mark the return values as nullable, as it’s entirely possible no content is returned from the endpoint.
Inspect Raw Response Information Using ApiResponse<>
You may have noticed that our Refit client definition only returns the actual response content. What if we want to validate the response result itself? Our current definition will essentially assume the happy path always occurred. Thankfully, Refit gives you the tool for that in the form of ApiResponse<>
. Let’s change our GET
endpoint:
[Get("/path/to/endpoint?id={id}")]
public Task<ApiResponse<ExampleResponse>> ExampleApiResponseAsync(int id, CancellationToken cancellationToken);
Now that we’ve returned ApiResponse<>
, we not only have our content available, but also the status code and more. When receiving this response we can do the usual assurance that a successful status code was returned, check to see if the returned content is not null, and work with the result. Let’s update Worker.cs
now:
// GET: https://example.com/path/to/endpoint?id=42
ApiResponse<ExampleResponse> result = await exampleRefitClient.ExampleApiResponseAsync(42, stoppingToken);
await result.EnsureSuccessStatusCodeAsync(); // Throws an exception for unsuccessful requests
ExampleResponse? response = result.Content;
if (response is not null)
{
// We got a response!
}
Personally I recommend always using ApiResponse<>
and validating the API request was successful. It gives you more fine-grained control over your exception-handling and information-gathering as to why a request failed, and is more accurate to what your client returns. You can encapsulate this logic into a class that wraps your Refit client, so you only need implement this sort of logic once. This lets you test that sort of logic, and re-use it for all your endpoints, without having to reinvent the wheel.
Defining a Working Refit Client
The example Refit client we defined above won’t do anything. example.com
does not have the random endpoints that we defined and will always 404
on us. Now that we know how to define a Refit client with ths basics, let’s do something that works for real!
Introducing ICatFactsClient
There are many example APIs to play with online. I’ve settled on this Cat Facts API by dint of “it was the first one I found that works.” If you visit /facts you’ll see a simple list of facts in JSON format. We’ll use this endpoint to drive our creation of a working Refit client in our code. Here’s our definition, alongside the changes to Program.cs
and Worker.cs
all at once!
public interface ICatFactsClient
{
[Get("/facts")]
Task<ApiResponse<List<CatFact>>> GetTheFactsAsync(CancellationToken cancellationToken);
}
public class CatFact
{
public string User { get; set; } = "";
public string Text { get; set; } = "";
public bool Used { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
// Program.cs update
builder.Services.AddRefitClient<ICatFactsClient>()
.ConfigureHttpClient(httpClient =>
{
httpClient.BaseAddress = new Uri("https://cat-fact.herokuapp.com/");
// Will throw `TaskCanceledException` if the request goes longer than 3 seconds.
httpClient.Timeout = TimeSpan.FromSeconds(3);
});
// Worker.cs update
public class Worker(ILogger<Worker> logger, ICatFactsClient catFactsClient)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await UseCatFactsClientAsync(stoppingToken);
}
catch (Exception e)
{
logger.LogError(e, "Exception thrown when using Refit client");
}
}
private async Task UseCatFactsClientAsync(CancellationToken stoppingToken)
{
ApiResponse<List<CatFact>> catFactsResponse = await catFactsClient.GetTheFactsAsync(stoppingToken);
await catFactsResponse.EnsureSuccessStatusCodeAsync();
List<CatFact>? catFacts = catFactsResponse.Content;
if (catFacts is null)
{
logger.LogWarning("CatFacts request returned null");
return;
}
logger.LogInformation("Found Cat Facts!");
foreach (CatFact fact in catFacts.Take(3))
{
logger.LogInformation("Fact created at {CreatedAt}: {FactText}", fact.CreatedAt.ToString("yyyy-MM-dd"), fact.Text);
}
}
}
If we run our program now, assuming the endpoint is returning information, we’ll see some cat facts:
info: System.Net.Http.HttpClient.Refit.Implementation.Generated+RefitSandboxICatFactsClient,
RefitSandbox, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null.ClientHandler[101]
Received HTTP response headers after 328.6146ms - 200
info: System.Net.Http.HttpClient.Refit.Implementation.Generated+RefitSandboxICatFactsClient,
RefitSandbox, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null.LogicalHandler[101]
End processing HTTP request after 355.2998ms - 200
info: RefitSandbox.Worker[0]
Found Cat Facts!
info: RefitSandbox.Worker[0]
Fact created at 2018-03-29: Owning a cat can reduce the risk of stroke and heart attack
by a third.
info: RefitSandbox.Worker[0]
Fact created at 2018-03-04: Most cats are lactose intolerant, and milk can cause painful
stomach cramps. It's best to forego the milk and just give your cat the standard: clean,
cool drinking water.
info: RefitSandbox.Worker[0]
Fact created at 2018-01-14: Domestic cats spend about 70 percent of the day sleeping and
15 percent of the day grooming.
In our Worker, you can see we carefully guard against bad returns, and log when we get an error, or if we don’t get any cat facts to display, using ApiResponse<CatFact>
. Since we’re using an interface, too, we can very easily mock out the results when we unit test our application. Easy!
What’s Next?
We’ve implemented a pretty straightforward custom HttpClient
using Refit. Sweet! Refit offers a lot more in terms of configuration that we have not delved into yet. What if we want to see detailed information on the requests and responses for diagnosis of issues? What if the endpoints we are hitting require an OAuth bearer token? We’re blind to the guts of the request and response (short of using external tools to observe HTTP traffic) and are only ever hitting endpoints as anonymous users. In upcoming blog posts, we’ll enhance our Refit client to allow for detailed logging to occur, as well as take advantage of Refit settings to incorporate Bearer request headers. See you then!