Skip to content
Matthew Adams By Matthew Adams Co-Founder · 4 min read
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 .Models sub-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.

FAQs

What OpenAPI versions does Corvus support for client generation? Corvus supports OpenAPI 3.0, 3.1, and 3.2 specifications. The generator reads paths, components, and security schemes from any of these versions.
How does the generated client handle response status codes? The generated client provides a MatchResult method with one handler per declared status code - like a discriminated union. The compiler ensures exhaustive handling so you never miss an error case.
What is the difference between Corvus OpenAPI clients and Kiota? Corvus generates zero-allocation models with JSON Schema validation built in, uses the Source/Builder pattern for request construction, and emits per-operation OAuth2 scope constants. Kiota uses a middleware pipeline with authentication providers.

Matthew Adams

Co-Founder

Matthew Adams

Matthew was CTO of a venture-backed technology start-up in the UK & US for 10 years, and is now the co-founder of endjin, which provides technology strategy, experience and development services to its customers who are seeking to take advantage of Microsoft Azure and the Cloud.