Link Search Menu Expand Document

7. HTTP POST

So far the Autobarn API has been read-only. This module adds its first write operation: POST /api/vehicles, which creates a new vehicle. It reuses the validation rules from the website’s Advertise form (Module 01) and returns the appropriate status codes: 201 Created, 400 Bad Request or 409 Conflict.

What’s in this module

Autobarn.Website/
└── Api/
    ├── AutobarnApiEndpointRouteBuilderExtensions.cs   (changed)
    └── Endpoints.cs                                    (changed)

Autobarn.Client is the same as in Module 06.

Changes from Module 06

File Change
Autobarn.Website/Api/Endpoints.cs Modified. Adds POST_VEHICLE and reorders the constants by resource
Autobarn.Website/Api/AutobarnApiEndpointRouteBuilderExtensions.cs Modified. Adds the POST /vehicles endpoint

Step 1: Add an endpoint name for the POST

File: Autobarn.Website/Api/Endpoints.cs

/// <summary>Names of the Autobarn API endpoints, used to generate hypermedia links.</summary>
public static class Endpoints {

	public const string GET_API_ROOT = nameof(GET_API_ROOT);

	public const string GET_MAKE = nameof(GET_MAKE);
	public const string GET_MAKES = nameof(GET_MAKES);

	public const string GET_MODEL = nameof(GET_MODEL);
	public const string GET_MODELS_BY_MAKE = nameof(GET_MODELS_BY_MAKE);

	public const string GET_VEHICLE = nameof(GET_VEHICLE);
	public const string GET_VEHICLES = nameof(GET_VEHICLES);
	public const string GET_VEHICLES_BY_MODEL = nameof(GET_VEHICLES_BY_MODEL);

	public const string POST_VEHICLE = nameof(POST_VEHICLE);
}

Why: The new endpoint needs a name for .WithName(...), which also becomes its OpenAPI operationId. The constants are now grouped by resource so the list stays readable as it grows.

Step 2: Add POST /api/vehicles

File: Autobarn.Website/Api/AutobarnApiEndpointRouteBuilderExtensions.cs

Two new using directives:

using Autobarn.Data.Entities;      // for the Vehicle entity
using Autobarn.Website.Models;     // for VehicleDto

The new endpoint sits between GET /vehicles/{registration} and GET /makes:

api.MapPost("/vehicles",
		async Task<Results<Created<VehicleResource>, Conflict<string>, BadRequest<string>>> (AutobarnDbContext db, LinkGenerator links, HttpContext http, VehicleDto dto)
	=> {
		var model = await db.Models.FirstOrDefaultAsync(m => m.Code == dto.ModelCode, http.RequestAborted);
		if (model is null) {
			return TypedResults.BadRequest($"Model with code '{dto.ModelCode}' not found.");
		}

		if (String.IsNullOrEmpty(dto.Registration)) {
			return TypedResults.BadRequest("Registration is required.");
		}

		if (await db.Vehicles.AnyAsync(v => v.Registration == dto.Registration, http.RequestAborted)) {
			return TypedResults.Conflict($"Vehicle with registration '{dto.Registration}' already exists.");
		}
		var vehicle = new Vehicle {
			Model = model,
			Registration = dto.Registration,
			Color = dto.Color,
			Year = dto.Year!.Value
		};
		await db.Vehicles.AddAsync(vehicle, http.RequestAborted);
		await db.SaveChangesAsync(http.RequestAborted);
		var resource = vehicle.ToResource(links, http);
		return TypedResults.Created(resource.Links["self"].Href, resource);
	})
	.WithName(Endpoints.POST_VEHICLE)
	.WithSummary("Create a new vehicle")
	.WithDescription("Creates a new vehicle in the Autobarn catalogue.")
	.ProducesValidationProblem();

The design decisions, one at a time:

Reuse VehicleDto as the request body

The endpoint binds the JSON body to the same VehicleDto the MVC Advertise form uses. It therefore gets the Module 01 rules for free:

  • [Required] on Registration, Year and Color
  • [RegistrationYear]: the year must be between 1950 and the current year
  • The Registration setter strips everything except A-Z0-9 and converts to upper case, so "abc-123 xy" is stored as ABC123XY

builder.Services.AddValidation() has been in Program.cs since Module 01. It makes minimal APIs check these DataAnnotations before the handler runs. An invalid body gets an automatic 400 with a validation problem-details response, and .ProducesValidationProblem() documents that response in OpenAPI.

Business-rule checks in the handler

Attributes can’t check anything that needs the database, so the handler does:

Check Response Reason
The model code doesn’t exist 400 Bad Request The request refers to something invalid, so the client needs to fix it
Registration is null or empty 400 Bad Request A fallback check that also satisfies the compiler’s nullable analysis. In practice it’s never reached: the setter runs during JSON binding, so "---" becomes "" before validation and [Required] rejects it first
The registration already exists 409 Conflict The request is well-formed but conflicts with the current state of the server

The handler returns Results<Created<VehicleResource>, Conflict<string>, BadRequest<string>>. As in Module 04, this union type lets the OpenAPI document list all three outcomes.

201 Created with a Location header and the new resource

var resource = vehicle.ToResource(links, http);
return TypedResults.Created(resource.Links["self"].Href, resource);
  • Created(uri, value) sets the Location header to the new vehicle’s URL, as HTTP specifies for 201. The URL comes from the resource’s own self link, so it’s generated the same way as every other link.
  • The body is a full VehicleResource with its _links, so the client can go straight to the new vehicle’s model or make.
  • vehicle.ToResource() needs vehicle.Model. It’s already set because the entity was created with Model = model, so no extra query is needed.
  • dto.Year!.Value is safe because [Required] has already rejected a missing year.

Same URL as the list

POST goes to /api/vehicles, the same URL as GET /api/vehicles. That’s the usual REST convention: you create an item by posting to its collection. A hypermedia client can therefore find where to POST by following the vehicles link from the API root, which is exactly what Module 08 does.

Trying it out

dotnet run --project Autobarn.Website

These responses come from running this module locally.

Success:

POST /api/vehicles
Content-Type: application/json

{"registration":"abc-123 xy","modelCode":"volkswagen-beetle","year":1982,"color":"yellow"}
HTTP/1.1 201 Created
Location: /api/vehicles/ABC123XY

{"_links":{"self":{"href":"/api/vehicles/ABC123XY"},"model":{"href":"/api/makes/volkswagen/models/beetle"},"make":{"href":"/api/makes/volkswagen"}},"registration":"ABC123XY","year":1982,"color":"yellow"}

Duplicate registration (sending the same body again):

HTTP/1.1 409 Conflict

"Vehicle with registration 'ABC123XY' already exists."

Unknown model ("modelCode":"nope"):

HTTP/1.1 400 Bad Request

"Model with code 'nope' not found."

Fails attribute validation ("year":1900, no color):

HTTP/1.1 400 Bad Request

{"title":"One or more validation errors occurred.","errors":{"Year":["Year must be between 1950 and 2026."],"Color":["The Color field is required."]}}

The API returns errors in two different shapes: a plain JSON string from the handler, or a problem-details object from the validation step. A client has to handle both, and Module 08 does.

You can also send these requests from the Scalar UI at /scalar, which now lists the POST operation.

What’s next

Module 08 extends the console client to create random vehicles with this endpoint.