OpenAPI Code Generation with Corvus: Server Stubs
At endjin, we maintain Corvus.JsonSchema, and in the previous post we generated a strongly-typed HTTP client from an OpenAPI spec.
Now let's flip to the server side.
The validation tax
If you've built an ASP.NET Core API by hand, you know how much code goes into not doing business logic. You parse a query parameter, check it's present, check it's the right type, check it's within range, parse the body, validate required fields, and return a 400 with a useful message if anything is off. You can easily spend more lines on input validation than on the actual operation.
And there's a worse problem: drift. The spec says limit has a maximum of 100. A developer adds a handler that doesn't enforce it. The client and server disagree about the contract, and nobody notices until a customer reports broken pagination.
We wanted a server-side story where your handler only contains business logic. All the parsing, validation, and error response generation come directly from the spec. If the spec changes, the handler's signature changes, and the compiler tells you what to fix.
What the generator produces
corvusjson openapi-server petstore.json \
--rootNamespace Petstore.Server \
--outputPath ./Generated
dotnet add package Corvus.Text.Json.OpenApi
dotnet add package Corvus.Text.Json
You get:
- A handler interface (
IApiPetsHandler) - one async method per operation. This is the only thing you implement. - Endpoint registration (
MapApiEndpoints) - a single extension method that wires all routes with correct HTTP methods and path templates. - Params structs - strongly-typed, already-validated request parameters and bodies.
- Result structs - factory methods for each response status code, so you can't accidentally return the wrong shape.
- Model types - the same zero-allocation models from client generation.
Your handler never validates
This is the core principle. The generated middleware runs the full validation pipeline before your handler is called:
HTTP Request arrives
→ Parse path/query/header/cookie params
→ Validate against JSON Schema
→ Parse and validate request body
→ If anything is invalid: return 400 Problem Details
→ Your Handler runs (everything is guaranteed valid)
→ Validate response body, serialize, write HTTP
If a required parameter is missing, schema validation fails, or the body can't be parsed, the request never reaches your code. The generated middleware returns a properly formatted Problem Details response. Your handler only sees valid, typed data.
Wiring it up
The setup is deliberately minimal:
using Corvus.Text.Json;
using Petstore.Server;
using Petstore.Server.Models;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();
PetsHandler handler = new();
app.MapApiEndpoints(handler);
app.Run();
MapApiEndpoints registers every route from the spec with the correct HTTP method and path template. You don't write app.MapGet(...) by hand. If the spec adds a new operation, the handler interface gains a new method, and the compiler tells you to implement it.
Implementing business logic
Here's what a handler looks like. Notice what's missing. You don't parse parameters, you don't validate schemas, and you don't handle protocol-level errors. You write business logic (including any business-logic errors you need to surface), and the generated infrastructure takes care of the rest:
internal sealed class PetsHandler : IApiPetsHandler
{
public ValueTask<ListPetsResult> HandleListPetsAsync(
ListPetsParams parameters,
JsonWorkspace workspace,
CancellationToken cancellationToken = default)
{
// parameters.Limit is already validated - guaranteed <= 100
ListPetsResult result = ListPetsResult.Ok(
body: new Pets.Source((ref Pets.Builder b) =>
{
b.AddItem(new Pet.Source((ref Pet.Builder pb) =>
{
pb.Create(id: 1, name: "Luna"u8, tag: "cat"u8);
}));
}),
workspace: workspace);
return new(result);
}
}
The JsonWorkspace provides pooled memory for building the response body. The Result factory methods mirror the spec's declared status codes. ListPetsResult.Ok(...) returns a 200, and CreatePetResult.Created(...) returns a 201. You can't accidentally return a 200 body with a 201 status.
Typed results prevent response drift
Each operation gets a result type with factory methods matching the spec:
public ValueTask<CreatePetResult> HandleCreatePetAsync(
CreatePetParams parameters,
JsonWorkspace workspace,
CancellationToken cancellationToken = default)
{
// parameters.Body.Name is guaranteed present - schema says required
string name = (string)parameters.Body.Name;
return new(CreatePetResult.Created(
body: new Pet.Source((ref Pet.Builder pb) =>
{
pb.Create(id: 42, name: name.AsSpan(), tag: "dog"u8);
}),
workspace: workspace));
}
The generated code also validates your response before writing it. If your handler builds a response body that violates the output schema, you get a 500 in development. A silently malformed response does not reach clients.
What's next
In the [ref slug=openapi-code-generation-with-corvus-end-to-end text=next post], we'll wire both sides together - a generated client calling a generated server over real HTTP - and see how the contract guarantee works in practice.