A Simple API Client

Wrap an API in a client class, the way a DAO wraps a table. It's given an HttpClient, has one async method per endpoint, and turns 404 into null or false, so the rest of the program never sees a URL, a status code or JSON.

Journeyman · C# · REST API · Updated

After this lesson you can

  • Wrap an API's endpoints in a client class with one async method each, turning 404 into null or false

Key takeaways

  • A client class wraps an API the way a DAO wraps a table: one async method per endpoint, plain objects in and out.
  • CategoryClient turns a 404 into null or false; for anything else that isn't a success, EnsureSuccessStatusCode() throws for the caller to catch.
  • The client asks for its HttpClient in the constructor, so the program can share one and a test can hand it a fake.
  • Every URL and status code stays inside the client class, so if the API's addresses change, that's the one class to fix.

Calling HttpClient all over a program spreads URLs, status codes and JSON everywhere. You solved the same problem with databases: SQL lives in the DAO, and the rest of the program calls GetById(8). An API deserves the same: a client class with one method per endpoint.

The CategoryClient

using System.Net;
using System.Net.Http.Json;

public class CategoryClient
{
    private readonly HttpClient client;

    public CategoryClient(HttpClient client)
    {
        this.client = client;
    }

    public async Task<List<Category>> GetAllAsync()
    {
        List<Category>? categories = await client.GetFromJsonAsync<List<Category>>("api/categories");
        return categories ?? new List<Category>();
    }

    public async Task<Category?> GetByIdAsync(int categoryId)
    {
        HttpResponseMessage response = await client.GetAsync(quot;api/categories/{categoryId}");
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Category>();
    }

    public async Task<Category> AddAsync(Category category)
    {
        HttpResponseMessage response = await client.PostAsJsonAsync("api/categories", category);
        response.EnsureSuccessStatusCode();
        return (await response.Content.ReadFromJsonAsync<Category>())!;
    }

    public async Task<Category?> UpdateAsync(Category category)
    {
        HttpResponseMessage response = await client.PutAsJsonAsync(quot;api/categories/{category.CategoryId}", category);
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Category>();
    }

    public async Task<bool> DeleteAsync(int categoryId)
    {
        HttpResponseMessage response = await client.DeleteAsync(quot;api/categories/{categoryId}");
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return false;
        }
        response.EnsureSuccessStatusCode();
        return true;
    }
}

Look how much it looks like ICategoryDao from the API: the same five methods, the same null for a missing category, from GetByIdAsync and UpdateAsync, and the same bool for "was there anything to delete". The program using it can't tell whether the categories come from a database or across the network. That's encapsulation: the class hides how it does its job.

How each status is handled is a choice the client class makes once:

The API answers The method
200, 201, 204 Returns the data, or true
404 Returns null or false: an expected answer
Anything else, such as 400 or 500 EnsureSuccessStatusCode() throws an HttpRequestException, for the caller to catch

It asks for its HttpClient

CategoryClient doesn't create its HttpClient; it asks for one in its constructor. That's dependency injection again, and it pays off twice:

  • the program creates one HttpClient, with the base address, and shares it;
  • a test can hand the client an HttpClient that answers from a fake, with no API running. The exercise's tests do exactly that.

Using it

using System.Net;
using System.Net.Http.Json;

using HttpClient http = new HttpClient { BaseAddress = new Uri("http://localhost:5179/") };
CategoryClient categories = new CategoryClient(http);

try
{
    foreach (Category category in await categories.GetAllAsync())
    {
        Console.WriteLine(quot;{category.CategoryId}. {category.CategoryName}");
    }

    Category? missing = await categories.GetByIdAsync(300);
    Console.WriteLine(missing == null ? "No category 300" : missing.CategoryName);

    Category snacks = await categories.AddAsync(new Category { CategoryName = "Snacks" });
    Console.WriteLine(quot;Deleted the new one: {await categories.DeleteAsync(snacks.CategoryId)}");
    Console.WriteLine(quot;Deleted it again: {await categories.DeleteAsync(snacks.CategoryId)}");
}
catch (HttpRequestException ex)
{
    Console.WriteLine(quot;The categories API had a problem: {ex.Message}");
}

public class Category
{
    public int CategoryId { get; set; }
    public string CategoryName { get; set; } = "";
    public string? Description { get; set; }
}

public class CategoryClient
{
    private readonly HttpClient client;

    public CategoryClient(HttpClient client)
    {
        this.client = client;
    }

    public async Task<List<Category>> GetAllAsync()
    {
        List<Category>? categories = await client.GetFromJsonAsync<List<Category>>("api/categories");
        return categories ?? new List<Category>();
    }

    public async Task<Category?> GetByIdAsync(int categoryId)
    {
        HttpResponseMessage response = await client.GetAsync(quot;api/categories/{categoryId}");
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Category>();
    }

    public async Task<Category> AddAsync(Category category)
    {
        HttpResponseMessage response = await client.PostAsJsonAsync("api/categories", category);
        response.EnsureSuccessStatusCode();
        return (await response.Content.ReadFromJsonAsync<Category>())!;
    }

    public async Task<bool> DeleteAsync(int categoryId)
    {
        HttpResponseMessage response = await client.DeleteAsync(quot;api/categories/{categoryId}");
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return false;
        }
        response.EnsureSuccessStatusCode();
        return true;
    }
}
1. Beverages
2. Condiments
3. Confections
4. Dairy Products
5. Grains/Cereals
6. Meat/Poultry
7. Produce
8. Seafood
No category 300
Deleted the new one: True
Deleted it again: False

The program reads like it's using a list of categories in memory. Every URL and status code is in CategoryClient, and if the API's addresses change, that's the one class to fix.

Check yourself

1 question to try on the lesson page.

So what

A client class is a DAO for an API: one method per endpoint, plain objects in and out, and the HTTP details hidden inside. Ask for the HttpClient in the constructor, and both the program and the tests can give it what they need.

Key terms

Cheat sheets