# 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 November 3, 2026

## 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

```csharp
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($"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($"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($"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

```csharp
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($"{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($"Deleted the new one: {await categories.DeleteAsync(snacks.CategoryId)}");
    Console.WriteLine($"Deleted it again: {await categories.DeleteAsync(snacks.CategoryId)}");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"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($"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($"api/categories/{categoryId}");
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return false;
        }
        response.EnsureSuccessStatusCode();
        return true;
    }
}
```

```text
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

- [API Client](https://www.ask-codey.com/glossary/api-client)
- [Encapsulation](https://www.ask-codey.com/glossary/encapsulation-c-sharp)
- [Dependency Injection](https://www.ask-codey.com/glossary/dependency-injection)

## Cheat sheets

- [A Simple API Client](https://www.ask-codey.com/cheat-sheets/calling-apis-with-httpclient-using-a-simple-http-client-cheat-sheet)

Source: https://www.ask-codey.com/lessons/calling-apis-with-httpclient-using-a-simple-http-client
