OpenAPI Code Generation with Corvus: Typed HTTP Clients
At endjin, we maintain Corvus.JsonSchema, and in the previous posts we introduced V5's core JSON Schema engine - zero-allocation models, pooled memory, and full schema validation.
Now let's apply that engine to a higher-level problem: generating HTTP clients from OpenAPI specifications.
The silent bug factory
If you've consumed a REST API from .NET, you've probably written the same kind of glue code dozens of times: constructing a URI, serializing a body, sending the request, checking the status code, then deserializing the response and hoping you got the property names right. Each of those steps is a chance for a silent bug. A misspelt query parameter won't fail at compile time. A missing required header won't surface until the server rejects it. A status code you forgot to handle won't manifest until production.
The frustrating thing is that OpenAPI specifications already contain all the information you need to get this right. The spec knows which parameters go where, what shape the body should be, and which status codes the server can return. But most code generators produce loose wrapper classes that don't enforce any of that at compile time. You still end up writing validation logic and defensive checks by hand.
We wanted a generated client that actually uses the spec as a contract. One where the compiler refuses to build if you haven't handled all the declared response codes, where parameters are validated against their JSON Schema before the request ever hits the wire, and where the generated models use the same zero-allocation pooled memory from the rest of V5.
That's what corvusjson openapi-client gives you.
Why not Kiota?
The obvious question. Microsoft Kiota is the most widely-used OpenAPI client generator for .NET, and if you need multi-language support (Java, Go, TypeScript, Python) it's the right choice.
But Kiota makes different trade-offs. It generates class-based POCO models that allocate per response. It has no schema validation. Your client happily sends a malformed request and waits for the server to reject it. It doesn't generate server stubs, so you can't use one spec to keep both sides in sync. It doesn't surface per-operation OAuth2 scopes, so you end up hardcoding scope strings. And it uses exception-based error handling for non-2xx responses, which means error paths are invisible until they throw at runtime.
Corvus takes a different position: struct-based models backed by pooled memory (12–17× less allocation than Kiota in benchmarks), built-in schema validation that's still 2–4× faster than Kiota without validation, exhaustive MatchResult for response codes enforced by the compiler, and generated server stubs from the same spec. The cost is that it's .NET only.
If you're building a .NET service where latency and allocation matter, where you want both client and server from one contract, and where the compiler should catch missing error paths, that's the Corvus sweet spot.
What comes out
The full reference documentation is on the Corvus.JsonSchema OpenAPI guide, and there's an interactive OpenAPI playground where you can paste a spec and see the generated code immediately. This post focuses on the design intent and the key patterns.
Point the generator at any OpenAPI 3.x spec (3.0, 3.1, or 3.2):
dotnet tool install --global Corvus.Json.Cli
dotnet add package Corvus.Text.Json.OpenApi.HttpTransport
dotnet add package Corvus.Text.Json
corvusjson openapi-client petstore.json \
--rootNamespace Petstore.Client \
--outputPath ./Generated
You get four things:
- A client class that orchestrates the full request lifecycle - parameter serialization, schema validation, HTTP transport, response parsing
- Request structs that serialize path, query, header, and cookie parameters into the correct wire format
- Response structs with exhaustive
MatchResult- one handler per declared status code, enforced by the compiler - Model types in a
.Modelssub-namespace - the same zero-allocation JSON Schema types used throughout V5
The key insight is that all the information needed to build a correct HTTP request is already in the spec. The generator just makes it impossible to get wrong.
Transport as an abstraction
The generated client doesn't know about HttpClient. It talks to an IApiTransport - an abstraction over how HTTP requests are sent:
using Corvus.Text.Json.OpenApi;
using Corvus.Text.Json.OpenApi.HttpTransport;
using Petstore.Client;
using Petstore.Client.Models;
using HttpClient httpClient = new() { BaseAddress = new Uri("https://petstore.example.com/v1") };
await using HttpClientTransport transport = new(httpClient);
ApiPetsClient client = new(transport);
This matters for testing. Substitute an in-memory transport that returns canned responses, without needing a running server or mocking HttpClient internals. The generated code doesn't care.
Exhaustive response handling
This is the design choice we're most opinionated about.
When an API declares multiple response codes, you should handle all of them. Not just the happy path. Most HTTP client libraries make the error path easy to ignore. A 404 slips through, an unexpected 503 crashes at runtime, and nobody notices until production.
The generated response types enforce this with MatchResult:
await using ListPetsResponse listResponse = await client.ListPetsAsync(limit: 10);
listResponse.MatchResult(
matchOk: pets =>
{
foreach (Pet pet in pets.EnumerateArray())
{
Console.WriteLine($"[{pet.Id}] {pet.Name} (tag: {pet.Tag})");
}
return 0;
},
matchDefault: error =>
{
Console.WriteLine($"Error {error.Code}: {error.Message}");
return 0;
});
If the spec declares a 404 response and you don't provide a matchNotFound handler, the code won't compile. Same principle as exhaustive switch expressions on discriminated unions. The compiler is your safety net.
Building requests without allocations
Request bodies use the same Source and Builder pattern from the rest of V5 (if you're not familiar with this, the Corvus.JsonSchema documentation covers the mutable document model in detail). A ref-struct builder constructs the JSON object in pooled memory, so there are no intermediate objects and no heap traffic:
await using CreatePetResponse createResponse = await client.CreatePetAsync(
body: new NewPet.Source(static (ref NewPet.Builder b) =>
{
b.Create(name: "Fido"u8, tag: "dog"u8);
}));
createResponse.MatchResult(
matchCreated: createdPet =>
{
Console.WriteLine($"Created: [{createdPet.Id}] {createdPet.Name}");
return 0;
},
matchDefault: error =>
{
Console.WriteLine($"Error {error.Code}: {error.Message}");
return 0;
});
Required properties are mandatory parameters on Create(). Optional ones have defaults. Forget a required property? It won't compile. The schema contract is enforced at the construction site, not at serialization time.
Path parameters and typed headers
Path parameters are type-safe. The generated code URI-encodes the value and substitutes it into the path template. You never build a URL string by hand:
await using ShowPetByIdResponse showResponse = await client.ShowPetByIdAsync(petId: "pet-123"u8);
string message = showResponse.MatchResult<string>(
matchOk: static pet => $"Found: {pet.Name}",
matchDefault: static error => $"Not found: {error.Message}");
Response headers declared in the spec are surfaced as typed properties:
JsonString nextPage = listResponse.XNextHeader;
No more digging through response.Headers with magic strings.
Validation before the wire
Here's a subtle but important choice. The generated client validates all parameters and request bodies before sending the HTTP request.
Why? Because a cryptic 400 from the server tells you almost nothing. A client-side ArgumentException with the exact schema violation tells you everything.
try
{
// limit: 200 exceeds the schema's "maximum: 100" constraint
await using ListPetsResponse _ = await client.ListPetsAsync(
limit: 200,
validationMode: ValidationMode.Detailed);
}
catch (ArgumentException ex)
{
Console.WriteLine($"Validation caught: {ex.Message}");
}
You control the level per-call:
| Mode | Behaviour | Use for |
|---|---|---|
ValidationMode.Basic |
Validates; throws with a brief message | Production default |
ValidationMode.Detailed |
Full JSON Schema evaluation output | Development and debugging |
ValidationMode.None |
Skips validation entirely | Trusted inputs in hot paths |
Trust but verify. Catch bugs early in development, disable checks in hot paths where you know the data is valid.
What's next
In the [ref slug=openapi-code-generation-with-corvus-server-stubs text=next post], we'll flip to the server side - generating handler interfaces and ASP.NET Core endpoint registration from the same spec, so both sides of the API contract stay in sync automatically.