Link Search Menu Expand Document

6. Create an HTTP Client: Solution

Modules 03 to 05 built the server side. This module moves to the other end of the connection and builds a console application that uses the Autobarn API. The client is set up with one URL, the API root. It finds everything else by following hypermedia links, pages through collections by following next links, and caches what it has downloaded.

This module is the worked solution to exercise-06-building-an-api-client.md.

What’s in this module

Autobarn.Client/                    (new project)
├── Autobarn.Client.csproj
├── appsettings.json
├── Program.cs
├── AutobarnApiClient.cs
└── Resources/
    └── Resources.cs

Autobarn.Website is the same as in Module 05. The client doesn’t reference Autobarn.Website or Autobarn.Data. It only talks to the API over HTTP.

Changes from Module 05

File Change
Autobarn.Client/Autobarn.Client.csproj Added. Console app project
Autobarn.Client/appsettings.json Added. The API root URL
Autobarn.Client/Resources/Resources.cs Added. Client-side records that the API’s JSON is deserialised into
Autobarn.Client/AutobarnApiClient.cs Added. A typed wrapper around HttpClient that follows links, pages and caches
Autobarn.Client/Program.cs Added. Configuration, the menu and the keypress loop
Autobarn.slnx Modified. Adds Autobarn.Client to the solution
Autobarn.Data/Autobarn.Data.csproj, AutobarnDbContext.cs Whitespace only. Removes a byte-order mark and adds a blank line

Step 1: Create the console project

File: Autobarn.Client/Autobarn.Client.csproj (new)

<Project Sdk="Microsoft.NET.Sdk">

	<PropertyGroup>
		<OutputType>Exe</OutputType>
	</PropertyGroup>

	<ItemGroup>
		<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
		<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
	</ItemGroup>

	<ItemGroup>
		<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
	</ItemGroup>

</Project>
  • TargetFramework, ImplicitUsings and Nullable all come from Directory.Build.props, and the package versions come from Directory.Packages.props. Both files have had these entries since Module 01, so the new project is very short.
  • The project uses the configuration packages on their own instead of the full Generic Host (Microsoft.Extensions.Hosting), which keeps the console app small.
  • CopyToOutputDirectory="PreserveNewest" copies appsettings.json into bin/, where AppContext.BaseDirectory finds it at runtime.

File: Autobarn.slnx

<Project Path="Autobarn.Client/Autobarn.Client.csproj" />   <!-- new -->
<Project Path="Autobarn.Data/Autobarn.Data.csproj" />
<Project Path="Autobarn.Website/Autobarn.Website.csproj" />

This is what dotnet sln add Autobarn.Client does. It lets dotnet build Autobarn.slnx and the IDE build the client along with the other two projects.

Step 2: Configure the API root URL

File: Autobarn.Client/appsettings.json (new)

{
	"AutobarnApiRootUrl": "https://autobarn.dev/api/"
}

File: Autobarn.Client/Program.cs

var config = new ConfigurationBuilder()
	.SetBasePath(AppContext.BaseDirectory)
	.AddJsonFile("appsettings.json", optional: false)
	.AddEnvironmentVariables()
	.Build();

var rootUrl = config["AutobarnApiRootUrl"]
	?? throw new InvalidOperationException("AutobarnApiRootUrl is not configured");

using var http = new HttpClient();
http.BaseAddress = new(rootUrl.EnsureTrailingSlash());
var client = new AutobarnApiClient(http);

Why:

  • One configuration value. This is the only URL the client knows. Every other address comes from the API’s responses. That’s the practical benefit of the hypermedia work in Module 05.
  • Idiomatic .NET configuration. Sources added later override earlier ones, so an environment variable overrides the JSON file. To point the client at a local copy of the site, run:

    $env:AutobarnApiRootUrl = "http://localhost:5000/api/"
    dotnet run --project Autobarn.Client
    
  • Trailing slash on BaseAddress. URI resolution treats the last path segment as a “file” unless it ends in /. The helper makes sure a URL like https://autobarn.dev/api still works:

    public static class StringExtensions {
    	extension(string url) {
    		public string EnsureTrailingSlash() => url.TrimEnd('/') + '/';
    	}
    }
    

    This uses the C# 14 extension block syntax, the newer way to declare extension members.

Step 3: Define client-side resource types

File: Autobarn.Client/Resources/Resources.cs (new)

public record Hyperlink(string Href);

public abstract record Resource(
	[property: JsonPropertyName("_links")]
	Dictionary<string, Hyperlink> Links) {
	public string? FindLinkHref(string rel) => Links.GetValueOrDefault(rel)?.Href;
}

public record ApiRoot(Dictionary<string, Hyperlink> Links) : Resource(Links);

public record VehicleMake(Dictionary<string, Hyperlink> Links, string Code, string Name) : Resource(Links);

public record VehicleModel(Dictionary<string, Hyperlink> Links, string Code, string Name) : Resource(Links);

public record Page<T>(Dictionary<string, Hyperlink> Links, int Index, int Count, int Total, List<T> Items) : Resource(Links);

Why:

  • No shared assembly with the server. These records are declared again on the client side instead of referencing Autobarn.Website. The client depends on the JSON contract, not on the server’s .NET types. That’s how an independent third-party client would work, and it lets the client and server be deployed separately.
  • Only what the client needs. There’s no Vehicle record yet, because this module never reads vehicles. Module 08 adds one.
  • FindLinkHref(rel) returns null when a link is missing. That’s how the client notices the last page, which has no next link.

File: Autobarn.Client/AutobarnApiClient.cs (new)

The exercise asks for a class that wraps HttpClient and exposes business operations such as “get all makes” rather than raw HTTP calls.

public class AutobarnApiClient(HttpClient http) {

	private static readonly JsonSerializerOptions jsonOptions = new(JsonSerializerDefaults.Web);

	private List<VehicleMake>? makes;
	private List<(VehicleMake Make, List<VehicleModel> Models)>? models;

JsonSerializerDefaults.Web turns on camelCase naming and case-insensitive property matching, which match what ASP.NET Core sends. The options object is static readonly because JsonSerializerOptions caches metadata and is meant to be reused.

Listing makes: start at the root and follow makes

public async Task<List<VehicleMake>> GetMakesAsync(CancellationToken cancellationToken = default) {
	if (makes is not null) return makes;
	var discoveryResource = await GetAsync<ApiRoot>(http.BaseAddress!.ToString(), cancellationToken);
	var href = discoveryResource.FindLinkHref("makes") ?? throw new InvalidOperationException("API root has no 'makes' link");
	return makes = await GetAllPagesAsync<VehicleMake>(href, cancellationToken);
}

These are steps 1 to 4 of the exercise: fetch the discovery endpoint, find the makes link, then page through the results.

public async Task<List<(VehicleMake Make, List<VehicleModel> Models)>> GetModelsAsync(CancellationToken cancellationToken = default) {
	if (models is not null) return models;
	var result = new List<(VehicleMake, List<VehicleModel>)>();
	foreach (var make in await GetMakesAsync(cancellationToken)) {
		var href = make.FindLinkHref("models") ?? throw new InvalidOperationException($"Make '{make.Code}' has no 'models' link");
		result.Add((make, await GetAllPagesAsync<VehicleModel>(href, cancellationToken)));
	}
	return models = result;
}
  • GetModelsAsync builds on GetMakesAsync, so the list of makes is also cached.
  • Results are returned as (Make, Models) tuples, which lets the menu print models grouped by make.
  • The client never builds /api/makes/{code}/models itself. It uses whatever URL the models link holds.

Paging: follow next until there isn’t one

private async Task<List<T>> GetAllPagesAsync<T>(string href, CancellationToken cancellationToken) {
	var items = new List<T>();
	var visited = new HashSet<string>();
	var next = href;
	while (next is not null) {
		if (!visited.Add(next)) throw new InvalidOperationException($"Pagination loop: {next} was already fetched");
		var page = await GetAsync<Page<T>>(next, cancellationToken);
		items.AddRange(page.Items);
		next = page.FindLinkHref("next");
	}
	return items;
}
  • This is the pagination loop the exercise describes, written once and reused for any T.
  • The visited set protects against a buggy server whose next link points back to a page already fetched. Without it, the loop would never end.

The HTTP call

private async Task<T> GetAsync<T>(string href, CancellationToken cancellationToken) {
	// Links may be absolute or relative; HttpClient resolves relative links against BaseAddress.
	Console.WriteLine($"GET {new Uri(http.BaseAddress!, href)}");
	return await http.GetFromJsonAsync<T>(href, jsonOptions, cancellationToken)
		?? throw new InvalidOperationException($"Empty response from {href}");
}
  • The server returns root-relative links such as /api/makes?index=10&count=10. HttpClient resolves them against BaseAddress, so the same code works with relative or absolute links.
  • Every request is written to the console. Attendees can watch how many requests a command makes and see the cache prevent repeat requests.

Clearing the cache

public void ClearCache() {
	makes = null;
	models = null;
}

Step 5: The interactive menu

File: Autobarn.Client/Program.cs (new)

while (true) {
	Console.WriteLine("""
	  ...ASCII-art banner...
	Welcome to the Autobarn API Client!

	Available commands:

	k:  List all vehicle makes
	d:  List all vehicle models
	c:  Clear all cached data
	x:  Exit
	""");

	var key = Console.ReadKey(intercept: true).Key;
	Console.WriteLine();
	var start = Stopwatch.GetTimestamp();
	try {
		switch (key) {
			case ConsoleKey.K:
				var makes = await client.GetMakesAsync();
				foreach (var make in makes) Console.WriteLine($"{make.Code,-20} {make.Name}");
				Console.WriteLine($"{makes.Count} makes ({Stopwatch.GetElapsedTime(start).TotalMilliseconds:0} ms)");
				break;
			case ConsoleKey.D:
				var models = await client.GetModelsAsync();
				foreach (var (make, makeModels) in models) {
					Console.WriteLine($"{make.Name}:");
					foreach (var model in makeModels) Console.WriteLine($"  {model.Code,-30} {model.Name}");
				}
				Console.WriteLine($"{models.Sum(m => m.Models.Count)} models from {models.Count} makes ({Stopwatch.GetElapsedTime(start).TotalMilliseconds:0} ms)");
				break;
			case ConsoleKey.C:
				client.ClearCache();
				Console.WriteLine("Cache cleared.");
				break;
			case ConsoleKey.X:
				return;
		}
	} catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException or InvalidOperationException) {
		Console.WriteLine($"Error: {ex.Message}");
	}
}

How this meets the exercise requirements:

Requirement Implementation
Single keypress, no Enter Console.ReadKey(intercept: true). intercept: true stops the key being echoed
Case-insensitive The code switches on ConsoleKey (the physical key), not the character, so k and K both match ConsoleKey.K
Show elapsed time (extra) Stopwatch.GetTimestamp() and Stopwatch.GetElapsedTime(start) give a high-resolution time without creating a Stopwatch object
Keep running after errors An exception filter (when (ex is ...)) catches network, JSON and missing-link errors, prints them and shows the menu again. Other exceptions still crash the program, so real bugs aren’t hidden
Multi-line menu A C# 11 raw string literal ("""), so the ASCII art needs no escaping

Trying it out

dotnet run --project Autobarn.Client

Press k and the client prints a GET line for the API root and for each page of makes. Press k again: nothing is fetched, and the elapsed time falls to almost zero. Press c, then k, and the requests happen again. Press d and you’ll see one run of paged requests for each make.

By default the client talks to https://autobarn.dev/api/. To use your own copy of the API instead, run dotnet run --project Autobarn.Website in another terminal and set AutobarnApiRootUrl to http://localhost:5000/api/ as shown in Step 2.

What’s next

So far the client only reads data. Module 07 adds a POST endpoint to the API for creating vehicles, and Module 08 teaches the client to call it.