Link Search Menu Expand Document

8. Post a Random Vehicle: Solution

Module 07 added POST /api/vehicles to the server. This module teaches the console client to use it. Two new commands create randomly generated vehicles: r creates one, and l creates one every second until you press a key. The client finds the POST URL by following the vehicles link from the API root. It also handles all three kinds of response the API can send: 201 Created, 400 Bad Request and 409 Conflict.

This module is the worked solution to exercise-08-post-a-random-vehicle.md.

What’s in this module

Autobarn.Client/
├── AutobarnApiClient.cs        (changed)
├── Program.cs                  (changed)
├── RandomVehicle.cs            (new)
└── Resources/
    └── Resources.cs            (changed)

Autobarn.Website and Autobarn.Data are the same as in Module 07.

Changes from Module 07

File Change
Autobarn.Client/Resources/Resources.cs Modified. Adds a Vehicle resource for responses and a NewVehicle request body
Autobarn.Client/RandomVehicle.cs Added. Generates a random NewVehicle to the exercise’s rules
Autobarn.Client/AutobarnApiClient.cs Modified. Caches the API root, adds CreateVehicleAsync and a CreateVehicleResult type, and parses error bodies
Autobarn.Client/Program.cs Modified. Adds the r and l commands and sorts the menu alphabetically

Step 1: Add request and response types

File: Autobarn.Client/Resources/Resources.cs

public record Vehicle(Dictionary<string, Hyperlink> Links, string Registration, int Year, string Color) : Resource(Links);

public record NewVehicle(string Registration, string ModelCode, int Year, string Color);

Why there are two types:

  • Vehicle is what the server returns: a resource with _links that matches the server’s VehicleResource from Module 05. The client needs it to read the 201 Created response body.
  • NewVehicle is what the client sends. It has no links, because links belong to resources the server has already created. It includes ModelCode, which the response doesn’t have. Serialised with web defaults (camelCase), it produces exactly the JSON the exercise asks for:

    { "registration": "ABCD1234", "modelCode": "volkswagen-beetle", "year": 1982, "color": "yellow" }
    

Step 2: Generate a random vehicle

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

public static class RandomVehicle {

	private const string REGISTRATION_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
	private const int REGISTRATION_LENGTH = 8;
	private const int MIN_YEAR = 1960;
	private const int MAX_YEAR = 2025;

	// https://developer.mozilla.org/en-US/docs/Web/CSS/named-color
	private static readonly string[] colors = [
		"aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", "bisque", "black",
		// ...all the CSS named colours...
		"violet", "wheat", "white", "whitesmoke", "yellow", "yellowgreen"
	];

	public static NewVehicle Create(IReadOnlyList<string> modelCodes) {
		if (modelCodes.Count == 0) throw new InvalidOperationException("No vehicle models available to choose from");
		return new(
			Registration: Random.Shared.GetString(REGISTRATION_CHARS, REGISTRATION_LENGTH),
			ModelCode: modelCodes[Random.Shared.Next(modelCodes.Count)],
			Year: Random.Shared.Next(MIN_YEAR, MAX_YEAR + 1),
			Color: colors[Random.Shared.Next(colors.Length)]
		);
	}
}

How each exercise rule is met:

Rule Implementation
Registration: 8 random characters from A–Z and 0–9 Random.Shared.GetString(chars, length), a .NET 8+ API that does this in one call
Model code chosen at random from the codes retrieved from the API Create takes the list of model codes as a parameter. It doesn’t call the API, so it stays a pure function that’s easy to test
Year between 1960 and 2025 Random.Shared.Next(MIN_YEAR, MAX_YEAR + 1). The upper bound of Next(min, max) is exclusive, hence the + 1
Colour is a CSS named colour A collection-expression array of every named colour from MDN

Random.Shared is a thread-safe shared instance, which avoids creating a new Random for every vehicle.

The generated values always pass the server’s validation from Module 07: the registration is already upper-case alphanumeric, and 1960–2025 lies inside the allowed range of 1950 to the current year. The API doesn’t restrict colours (the website’s VehicleDto.Colors list only fills the form’s dropdown), so names like "papayawhip" are accepted.

Step 3: Cache the API root

File: Autobarn.Client/AutobarnApiClient.cs

Before (Module 06):

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);
}

After (Module 08):

private ApiRoot? apiRoot;

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

private async Task<ApiRoot> GetApiRootAsync(CancellationToken cancellationToken)
	=> apiRoot ??= await GetAsync<ApiRoot>(http.BaseAddress!.ToString(), cancellationToken);

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

Why: Two operations now start at the API root: GetMakesAsync follows makes, and CreateVehicleAsync follows vehicles. With l creating a vehicle every second, fetching the root before each POST would double the number of requests. ??= fetches it once and reuses it. ClearCache also clears the cached root, so pressing c makes the client discover the API again from scratch.

Step 4: Model the outcome of a create

File: Autobarn.Client/AutobarnApiClient.cs

public abstract record CreateVehicleResult {
	public record Created(Vehicle Vehicle) : CreateVehicleResult;
	public record Rejected(HttpStatusCode StatusCode, string Message) : CreateVehicleResult;
}

Why: A 400 or 409 isn’t an exceptional situation. It’s an expected answer from the API, such as a registration that already exists, and the exercise asks for its message to be shown. Returning a result type instead of throwing:

  • makes both outcomes visible in the method signature
  • lets the caller handle them with a switch pattern match (Step 7)
  • leaves exceptions for problems that really are unexpected, such as network failures or 500 errors

This is a closed hierarchy in the style of a discriminated union: an abstract base record with nested derived records.

Step 5: POST the vehicle

File: Autobarn.Client/AutobarnApiClient.cs

public async Task<CreateVehicleResult> CreateVehicleAsync(NewVehicle vehicle, CancellationToken cancellationToken = default) {
	var href = (await GetApiRootAsync(cancellationToken)).FindLinkHref("vehicles")
		?? throw new InvalidOperationException("API root has no 'vehicles' link");
	Console.WriteLine($"POST {new Uri(http.BaseAddress!, href)}");
	using var response = await http.PostAsJsonAsync(href, vehicle, jsonOptions, cancellationToken);
	switch (response.StatusCode) {
		case HttpStatusCode.Created:
			var created = await response.Content.ReadFromJsonAsync<Vehicle>(jsonOptions, cancellationToken)
				?? throw new InvalidOperationException($"Empty response from POST {href}");
			return new CreateVehicleResult.Created(created);
		case HttpStatusCode.BadRequest:
		case HttpStatusCode.Conflict:
			return new CreateVehicleResult.Rejected(response.StatusCode, await ReadErrorMessageAsync(response, cancellationToken));
		default:
			response.EnsureSuccessStatusCode();
			throw new HttpRequestException($"Unexpected response from POST {href}: {(int) response.StatusCode} {response.ReasonPhrase}");
	}
}
  • Hypermedia, not a hard-coded URL. The POST goes to the API root’s vehicles link. The client still knows only one URL.
  • PostAsJsonAsync serialises NewVehicle using the same JsonSerializerDefaults.Web options, which give camelCase names, and sets Content-Type: application/json.
  • using var response disposes the HttpResponseMessage and its content stream. GetFromJsonAsync did that automatically. Here the response has to be inspected, so the code disposes it itself.
  • The default branch. A non-success code (500, 401 and so on) makes EnsureSuccessStatusCode() throw HttpRequestException, which the menu’s exception filter catches. An unexpected success code such as 200 instead of 201 doesn’t throw there, so the explicit throw after it covers that case.

Step 6: Read error messages in either format

File: Autobarn.Client/AutobarnApiClient.cs

As Module 07 showed, the API returns errors in two shapes:

"Vehicle with registration 'ABC123XY' already exists."                              ← handler: a JSON string
{"title":"One or more validation errors occurred.","errors":{"Year":["..."]}}       ← validation: problem details
// The API returns errors either as a JSON string, e.g. "Model with code 'x' not found.",
// or as a validation problem details object; anything else is displayed as-is.
private static async Task<string> ReadErrorMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken) {
	var body = await response.Content.ReadAsStringAsync(cancellationToken);
	try {
		using var json = JsonDocument.Parse(body);
		var root = json.RootElement;
		if (root.ValueKind == JsonValueKind.String) return root.GetString()!;
		if (root.ValueKind == JsonValueKind.Object) {
			if (root.TryGetProperty("errors", out var errors) && errors.ValueKind == JsonValueKind.Object) {
				return String.Join(" ", errors.EnumerateObject()
					.SelectMany(error => error.Value.EnumerateArray().Select(message => message.GetString())));
			}
			if (root.TryGetProperty("title", out var title)) return title.GetString() ?? body;
		}
	} catch (JsonException) {
		// not JSON; fall through and return the raw body
	}
	return body;
}

Why: The exercise says “display the error message from the response body”. The body can take several forms, so the method tries each in turn:

  1. A JSON string (TypedResults.BadRequest("...") / Conflict("...")): return the string without its quotes.
  2. Validation problem details: join every message in errors into one line.
  3. Another problem-details object: fall back to title.
  4. Not JSON at all, for example an HTML error page from a proxy: return the raw body.

JsonDocument reads the structure without needing a type for each possible shape, and using returns its pooled buffers.

Step 7: Add the r and l commands

File: Autobarn.Client/Program.cs

The menu is now in alphabetical order, with the two new commands added:

c:  Reset (clear) all cached data
d:  List all vehicle models
k:  List all vehicle makes
l:  Loop, creating a random vehicle every second until you press any key
r:  Create a random vehicle
x:  Exit

A local function creates one vehicle and prints the result:

async Task CreateRandomVehicleAsync() {
	var models = await client.GetModelsAsync();
	var modelCodes = models.SelectMany(m => m.Models).Select(m => m.Code).ToList();
	var vehicle = RandomVehicle.Create(modelCodes);
	switch (await client.CreateVehicleAsync(vehicle)) {
		case CreateVehicleResult.Created(var created):
			Console.WriteLine($"Created vehicle {created.Registration}: {created.Year} {created.Color} {vehicle.ModelCode} ({created.FindLinkHref("self")})");
			break;
		case CreateVehicleResult.Rejected(var statusCode, var message):
			Console.WriteLine($"{(int) statusCode} {statusCode}: {message}");
			break;
	}
}
  • Model codes come from the API. GetModelsAsync() is the Module 06 method, and it’s cached. The first r in a session walks every make’s models link, which is many requests. Later vehicles reuse the cached list and cost a single POST each.
  • Positional pattern matching. case CreateVehicleResult.Created(var created) checks the type and deconstructs the record in one step.
  • The success message takes vehicle.ModelCode from the request, because the Vehicle resource in the response has no model code, only a model link.

The new cases in the switch:

case ConsoleKey.R:
	await CreateRandomVehicleAsync();
	break;
case ConsoleKey.L:
	Console.WriteLine("Creating a random vehicle every second. Press any key to stop.");
	while (true) {
		var tick = Stopwatch.GetTimestamp();
		await CreateRandomVehicleAsync();
		while (!Console.KeyAvailable && Stopwatch.GetElapsedTime(tick) < TimeSpan.FromSeconds(1)) await Task.Delay(50);
		if (Console.KeyAvailable) break;
	}
	Console.ReadKey(intercept: true);
	break;

How the loop works:

  • One vehicle per second, however long the POST takes. tick is recorded before the request. The inner loop waits for whatever is left of the second, so a 200 ms request is followed by about 800 ms of waiting, not a full second.
  • Stays responsive. Rather than one Task.Delay(1000), the inner loop checks Console.KeyAvailable every 50 ms, so a keypress stops the loop almost immediately.
  • Eats the keypress. Console.ReadKey(intercept: true) after the loop reads the key that stopped it. Otherwise that key would still be in the buffer and the main menu would run it as a command.
  • Errors end the loop. If a request throws, for example because the server goes down, the exception leaves the loop and the existing exception filter prints it before showing the menu again.

Trying it out

Run the website from this module in one terminal, and point the client at it from another:

dotnet run --project Autobarn.Website
$env:AutobarnApiRootUrl = "http://localhost:5000/api/"
dotnet run --project Autobarn.Client

Press r. You’ll see GET requests for the API root, every page of makes and every page of models, then one POST and a line like:

Created vehicle 7GQ2K0ZD: 1994 papayawhip nissan-note (/api/vehicles/7GQ2K0ZD)

Press l to create a vehicle every second, then press any key to stop. Open http://localhost:5000/Vehicles in a browser: the random vehicles appear on the website, which shares the same in-memory database as the API.