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:
Vehicleis what the server returns: a resource with_linksthat matches the server’sVehicleResourcefrom Module 05. The client needs it to read the201 Createdresponse body.-
NewVehicleis what the client sends. It has no links, because links belong to resources the server has already created. It includesModelCode, 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
switchpattern match (Step 7) - leaves exceptions for problems that really are unexpected, such as network failures or
500errors
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
vehicleslink. The client still knows only one URL. PostAsJsonAsyncserialisesNewVehicleusing the sameJsonSerializerDefaults.Weboptions, which give camelCase names, and setsContent-Type: application/json.using var responsedisposes theHttpResponseMessageand its content stream.GetFromJsonAsyncdid that automatically. Here the response has to be inspected, so the code disposes it itself.- The
defaultbranch. A non-success code (500,401and so on) makesEnsureSuccessStatusCode()throwHttpRequestException, which the menu’s exception filter catches. An unexpected success code such as200instead of201doesn’t throw there, so the explicitthrowafter 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:
- A JSON string (
TypedResults.BadRequest("...")/Conflict("...")): return the string without its quotes. - Validation problem details: join every message in
errorsinto one line. - Another problem-details object: fall back to
title. - 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 firstrin a session walks every make’smodelslink, 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.ModelCodefrom the request, because theVehicleresource in the response has no model code, only amodellink.
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.
tickis 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 checksConsole.KeyAvailableevery 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.