A contact without a name is already rejected in the Kladde, since module 1.6: Contact.Create reports contact.name.required, and since the last lesson, that arrives at the caller as ProblemDetails with a code. So why another check? Because an API has to answer two kinds of questions. One is: Is the input usable at all? Is a required field missing, is the address not text in the form of an address, is a page size 5000? You can check that without knowing anything about the Kladde. The other is: Is this allowed? Does another contact already have this email address, does the appointment overlap with another one? Only the model knows that.

The first kind is checked by the edge, that is, the API itself, before a handler runs. The second stays in the model, in Kladde.Core, where it holds for every interface. In this lesson, you get to know the validation that ASP.NET Core has brought for minimal APIs since .NET 10, and you build a new rule into the core: Two contacts must not have the same email address.

Turning validation on

In k2-practice/2-3, create the file validation.cs:

C#
// validation.cs: checking input at the edge
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false

using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);
builder.Logging.SetMinimumLevel(LogLevel.Warning);
builder.Services.AddProblemDetails();
builder.Services.AddValidation();
var app = builder.Build();

app.MapPost("/api/contacts", (ContactRequest request) => TypedResults.Created("/api/contacts/3", request));
app.MapGet("/api/contacts", ([Range(1, 100)] int size = 20) => $"Pages of {size} contacts");

app.Run();

public record ContactRequest(
    [Required, MaxLength(100)] string? Name,
    [EmailAddress] string? Email,
    [MaxLength(100)] string? Company);

Two things are new:

  • builder.Services.AddValidation() turns validation on. A source generator searches at compile time for all types that handlers have as parameters and writes code that checks their attributes. At runtime, this check runs as an endpoint filter (lesson 2.3.5) before every handler.
  • Attributes from System.ComponentModel.DataAnnotations, which you know from the options in lesson 2.3.4. On a record, they stand in front of the parameters inside the parentheses. A parameter of the handler can carry them as well, like size here.

Send a contact without a name and with a wrong address. Put the JSON into a file bad.json for this, so that the same quotation marks apply in every shell:

JSON
{"name":"","email":"ilse-at-kranich"}
Bash
curl -i -X POST http://localhost:5000/api/contacts -H "Content-Type: application/json" --data-binary "@bad.json"
curl -i "http://localhost:5000/api/contacts?size=500"
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Server: Kestrel
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Name":["The Name field is required."],"Email":["The Email field is not a valid e-mail address."]},"traceId":"00-4094b2367077a6649f7c006ccfbb0346-5b66c50c5720c0a7-00"}
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Server: Kestrel
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"size":["The field size must be between 1 and 100."]},"traceId":"00-c34216209d56359a0cf8283f8459f270-e0e633c24561adbd-00"}

--data-binary "@bad.json" sends the content of the file unchanged. The response is ProblemDetails again, this time in the form that ASP.NET Core uses for validation and calls HttpValidationProblemDetails: The extension errors assigns a list of messages to each name. A form can write each message next to its field that way.

Three details:

  • The handler doesn’t run. The filter answers before it. TypedResults.Created sees only input that has passed the check.
  • The names in errors are those of the properties in C#, Name and Email, not the lowercase ones from the JSON. For parameters like size, it is the name of the parameter.
  • The built-in messages are in English. Every attribute takes a message of its own with ErrorMessage; in it, {0} stands for the name, with [Range], {1} and {2} stand for the limits, and with [MaxLength], {1} stands for the length.

An endpoint that is not to be checked gets .DisableValidation(). And a check that compares two fields, such as “from” before “to” in a search, you write by having the record implement IValidatableObject; the documentation below shows how.