OpenAPI Code Generation

August 26, 2026 · View on GitHub

Try the OpenAPI Playground — generate strongly-typed OpenAPI client code, inspect request/response types, and run samples in your browser.

Overview

Corvus.Text.Json includes a code generator that produces strongly-typed HTTP clients and ASP.NET Core server stubs from OpenAPI specifications (versions 2.0, 3.0, 3.1, and 3.2).

Both sides are generated from the same spec. The generated code handles all the HTTP plumbing — parameter serialization, schema validation, request/response parsing, streaming, and error handling — so you focus purely on business logic.

The generator leverages the Corvus.JsonSchema V5 engine for model generation, producing zero-allocation, pooled-memory types with full JSON Schema validation built in.

Installation

# Install the CLI tool globally
dotnet tool install --global Corvus.Json.Cli

# Or as a local tool
dotnet tool install Corvus.Json.Cli

For client projects, add the transport package:

dotnet add package Corvus.Text.Json.OpenApi.HttpTransport

For server projects, add the OpenAPI runtime:

dotnet add package Corvus.Text.Json.OpenApi

Both also need the core library:

dotnet add package Corvus.Text.Json

Quick Start — Client

Generate a typed client from any OpenAPI spec:

corvusjson openapi-client petstore.json \
    --rootNamespace Petstore.Client \
    --outputPath ./Generated

Use it with just a few lines:

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);
await using ApiPetsClient client = new(transport);

// List pets — the generated code validates limit <= 100 before sending
await using ListPetsResponse response = await client.ListPetsAsync(limit: 10);

response.MatchResult(
    matchOk: pets =>
    {
        foreach (Pet pet in pets.EnumerateArray())
        {
            Console.WriteLine($"[{pet.Id}] {pet.Name}");
        }

        return 0;
    },
    matchDefault: error =>
    {
        Console.WriteLine($"Error {error.Code}: {error.Message}");
        return 0;
    });

Quick Start — Server

Generate handler interfaces and endpoint registration:

corvusjson openapi-server petstore.json \
    --rootNamespace Petstore.Server \
    --outputPath ./Generated

Wire up with ASP.NET Core minimal APIs:

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();

Implement your business logic:

internal sealed class PetsHandler : IApiPetsHandler
{
    public ValueTask<ListPetsResult> HandleListPetsAsync(
        ListPetsParams parameters,
        JsonWorkspace workspace,
        CancellationToken cancellationToken = default)
    {
        // Parameters are already validated — limit <= 100 is guaranteed
        // Build response using typed Result factory + array builder
        ListPetsResult result = ListPetsResult.Ok(
            body: new Pets.Source((ref Pets.Builder b) =>
            {
                b.AddItem(Pet.Build(id: 1, name: "Luna"u8, tag: "cat"u8));
            }),
            workspace: workspace);

        return new(result);
    }
}

What the Generator Produces

Client Generation (openapi-client)

Generated artifactWhat it handles
Client class (e.g., ApiPetsClient)Orchestrates request lifecycle: builds parameters, validates against JSON Schema, sends via transport, parses response
Request structsSerializes path/query/header/cookie parameters into correct wire format
Response structsParses response body into typed models; MatchResult for exhaustive status code handling
Model types (in .Models sub-namespace)Strongly-typed JSON Schema models with validation, zero-allocation access, and builder patterns
Client interfaceContract for dependency injection and testing

Server Generation (openapi-server)

Generated artifactWhat it handles
Handler interface (e.g., IApiPetsHandler)One async method per operation — you implement business logic here
Endpoint registration (ApiEndpointRegistration)Maps all routes with correct HTTP methods and path templates
Params structsStrongly-typed, schema-validated request parameters and bodies
Result structsFactory methods for each response status code with typed body builders
Model types (in .Models sub-namespace)Same models as client generation

Namespace Layout

The generator places types into two namespaces to avoid name collisions:

NamespaceContainsExample
{rootNamespace}Client/server classes, request, response, params, result typesPetstore.Client.ApiPetsClient, Petstore.Client.ListPetsResponse
{rootNamespace}.ModelsJSON Schema model types (from inline schemas and #/components/schemas)Petstore.Client.Models.Pet, Petstore.Client.Models.Error

Model types are generated into a Models/ subdirectory of the output path and use the .Models sub-namespace. This prevents collisions when a schema name (e.g., CreateUserRequest) matches a generated request type name.

Generated Code Architecture

Client Flow

Your Code → Client.MethodAsync(params...)
  → Generated Request: serialize params (path/query/header/cookie)
  → Generated Validation: validate against JSON Schema
  → IApiTransport: send HTTP request
  → Generated Response: parse status + body + headers
  → MatchResult: dispatch to your handler

Server Flow

HTTP Request arrives
  → Generated Middleware: parse path/query/header/cookie params
  → Generated Validation: validate params and body against schemas
  → If invalid: return 400 Problem Details (your handler never runs)
  → Your Handler: receives typed params, returns typed Result
  → Generated Middleware: validate response body, serialize, write HTTP

Parameter Styles

The generator supports all OpenAPI 3.x parameter serialization styles:

StyleLocationExampleWire Format
Simplepath{petId}/pets/123
Simple arraypath{ids}/pets/batch/1,2,3
Formquerylimit=10?limit=10
Form array (explode)querytags?tags=dog&tags=cat
Deep objectqueryfilter?filter[status]=available&filter[breed]=lab
Simpleheaderx-request-idx-request-id: abc-123
Formcookiesession_tokenCookie: session_token=tok123

Deep-Object Filters (Client)

await using ListPetsResponse response = await petsClient.ListPetsAsync(
    xRequestId: "req-abc-123"u8,
    filter: GetPetsFilter.Build(status: "available"u8, breed: "labrador"u8),
    tags: new GetPetsTags.Source((ref GetPetsTags.Builder b) =>
    {
        b.AddItem("dog"u8);
        b.AddItem("friendly"u8);
    }));
// Produces: GET /pets?filter[status]=available&filter[breed]=labrador&tags=dog&tags=friendly

Deep-Object Filters (Server)

public ValueTask<ListPetsResult> HandleListPetsAsync(
    ListPetsParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
    // The generated code already parsed ?filter[status]=available into a typed object
    if (!parameters.Filter.IsUndefined())
    {
        string status = (string)parameters.Filter.GetProperty("status"u8);
        // Use for filtering...
    }

    // Array params are typed too
    foreach (JsonString tag in parameters.Tags.EnumerateArray())
    {
        // Filter by each tag...
    }
}

Response Handling

MatchResult (Exhaustive Pattern Matching)

Every response type provides MatchResult() with one handler per declared status code:

string message = showResponse.MatchResult<string>(
    matchOk: static pet => $"Found: {pet.Name}",
    matchNotFound: static error => $"Not found: {error.Message}",
    matchDefault: static error => $"Unexpected: {error.Message}");

This is the preferred approach — it ensures you handle every possible response the API can return.

Response Headers

Declared response headers are exposed as typed properties:

await using ListPetsResponse response = await client.ListPetsAsync(limit: 10);
JsonInteger totalCount = response.XTotalCountHeader;
JsonString nextPage = response.XNextHeader;

Text Responses

A text/plain response body is not read from the network until you consume it, and first consumption wins. GetOkTextAsync() buffers the body asynchronously on first call and returns the cached string afterwards; it is the accessor to prefer. OkTextStream exposes the live response stream for callers that want the raw bytes as they arrive (for example a text stream carrying its own protocol); reading it directly consumes the body, and it returns null once another accessor has buffered. The synchronous OkText and OkUtf8Bytes properties remain and buffer with a blocking read on first access, so existing code keeps working unchanged.

Request Bodies

Building a Body Without Closures

Where a body is an object or an array, the client also emits a generic overload taking that type's Source<TContext>. It is the client counterpart of the server's Ok<TContext>(...) factory: the body is assembled lazily with the caller's context threaded through, and materialised in one pass, so putting a collection in a request body costs no per-item closure.

// Without: the lambda captures `pets`, allocating a closure per call.
await client.CreatePetsAsync(body: Pets.Build(b => { foreach (Pet p in pets) b.AddItem(p); }));

// With: `pets` is threaded through, and the lambda stays static.
await client.CreatePetsAsync(
    body: Pets.Build(in pets, static (in IReadOnlyList<Pet> source, ref Pets.Builder b) =>
    {
        foreach (Pet p in source)
        {
            b.AddItem(p);
        }
    }));

The overload is emitted only where it can be: a body whose type is a scalar has no Source<TContext>, so nothing changes there.

Optional Bodies

A request body that is not marked required is optional in the generated signature. A client omits it when it is not supplied, rather than sending an empty one, and a server sees it as absent rather than as an empty value.

JSON Bodies (Client)

Object bodies use the generated Builder pattern. Required properties are mandatory parameters; optional ones have defaults:

await using CreatePetResponse response = await client.CreatePetAsync(
    session_token: "admin-token"u8,
    body: NewPet.Build(
        name: "Fido"u8,
        status: "available"u8,
        tags: new NewPet.JsonStringArray.Source(
            (ref NewPet.JsonStringArray.Builder ab) =>
            {
                ab.AddItem("friendly"u8);
                ab.AddItem("vaccinated"u8);
            })));

JSON Bodies (Server)

On the server, the body arrives pre-parsed and validated in the params struct:

public ValueTask<CreatePetResult> HandleCreatePetAsync(
    CreatePetParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
    // Body already deserialized and validated — read typed properties directly
    string name = (string)parameters.Body.Name;
    string status = (string)parameters.Body.Status;

    // Build a typed response
    return new(CreatePetResult.Created(
        body: Pet.Build(id: nextId++, name: name.AsSpan(), status: status.AsSpan()),
        workspace: workspace));
}

Form-Encoded Bodies

URL-encoded forms use the same builder pattern — the generated code handles encoding:

await using SubmitAdoptionApplicationResponse response =
    await adoptionClient.SubmitAdoptionApplicationAsync(
        body: PostAdoptionApplyBody.Build(
            applicantName: "Jane Smith"u8,
            email: "jane@example.com"u8,
            housingType: "house"u8,
            petId: "pet-42"u8));
// Wire format: applicantName=Jane+Smith&email=jane%40example.com&housingType=house&petId=pet-42

On the server, the generated middleware deserializes form data back into the same typed struct:

public ValueTask<SubmitAdoptionApplicationResult> HandleSubmitAdoptionApplicationAsync(
    SubmitAdoptionApplicationParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
    // parameters.Body is typed — same properties regardless of encoding
    string applicant = (string)parameters.Body.ApplicantName;
    string email = (string)parameters.Body.Email;

    return new(SubmitAdoptionApplicationResult.Accepted(
        body: PostAdoptionApplyAccepted.Build(applicationId: "app-42"u8, status: "received"u8),
        workspace: workspace));
}

Streaming

Server-Sent Events (SSE)

For server text/event-stream responses with an OpenAPI itemSchema, the generated result factory accepts a writer callback. Append typed items to the generated stream; the endpoint registration serializes each item as compact JSON and applies the SSE frame (data: {...}\n\n):

public ValueTask<StartVetChatResult> HandleStartVetChatAsync(
    StartVetChatParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
    return new(StartVetChatResult.Ok(static async (stream, cancellationToken) =>
    {
        ChatChunk greeting = ChatChunk.ParseValue(
            """{"delta":"Hello! ","done":false}"""u8);
        await stream.AppendChatChunk(greeting, cancellationToken);

        ChatChunk answer = ChatChunk.ParseValue(
            """{"delta":"How can I help?","done":true}"""u8);
        await stream.AppendChatChunk(answer, cancellationToken);
    }));
}

There is no generated End* method. For SSE, stream completion is the HTTP response completing: when the callback returns, the endpoint flushes the response and closes the response body. If the client disconnects, the callback receives cancellation through the provided token.

On the client, streaming responses expose IAsyncEnumerable. Use EnumerateOkItems() when you only need JSON payloads, or EnumerateOkSseItems() when you also need SSE metadata such as id or event:

await foreach (ParsedJsonDocument<ChatChunk> chunk in chatResponse.EnumerateOkItems())
{
    using (chunk)
    {
        Console.Write(chunk.RootElement.Delta);
    }
}

NDJSON (Newline-Delimited JSON)

For server application/x-ndjson responses, the same generated writer callback is used. Each appended item is written as one JSON line ({...}\n):

public ValueTask<StreamPetActivityResult> HandleStreamPetActivityAsync(
    StreamPetActivityParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
    return new(StreamPetActivityResult.Ok(static async (stream, cancellationToken) =>
    {
        ActivityEvent checkIn = ActivityEvent.ParseValue(
            """{"eventId":"evt-1","timestamp":"2026-05-30T18:00:00Z","type":"check-in","description":"Bella checked in"}"""u8);
        await stream.AppendActivityEvent(checkIn, cancellationToken);
    }));
}

The client reads each JSON line as a pooled typed document:

await foreach (ParsedJsonDocument<ActivityEvent> doc in activityResponse.EnumerateOkItems())
{
    using (doc)
    {
        Console.WriteLine($"[{doc.RootElement.Timestamp}] {doc.RootElement.Type}: {doc.RootElement.Description}");
    }
}

Binary Transfers

Binary Responses (Server)

A binary response carries its body through the result factory, either as bytes or as a writer, and chooses its content type:

// Bytes you already hold.
return DownloadPhotoResult.Ok(photo.Data, photo.ContentType);

// Or stream it, for something you do not want to buffer.
return DownloadPhotoResult.Ok(
    (stream, ct) => blob.DownloadToAsync(stream, ct),
    "image/png");

Before 5.3.0 this factory took no arguments, so a handler could not supply the body at all and the content type came from whichever one the specification happened to list first. Handlers returning a binary response must now pass a body, which is a compile break with the missing capability inside it.

File Upload (Multipart)

await using UploadPetPhotoResponse uploadResponse = await photosClient.UploadPetPhotoAsync(
    petId: "pet-1"u8,
    metadata: PhotoMetadata.Build(petId: "pet-1"u8, description: "At the park"u8),
    file: new BinaryPartData(
        WriteContentAsync: (stream, ct) => { stream.Write(photoBytes); return default; },
        ContentType: "image/png",
        FileName: "bella-park.png"));

For async sources (e.g. downloading from cloud storage), the callback can be fully async:

file: new BinaryPartData(
    WriteContentAsync: (stream, ct) => blob.DownloadToAsync(stream, ct),
    ContentType: "application/octet-stream",
    FileName: "report.pdf")

The generated client always writes binary parts after all non-binary parts, which RFC 7578 permits. This keeps client output compatible with servers generated in streaming mode (see below).

Raw application/octet-stream request bodies get the same push model: alongside the Stream overload, the client offers a write-callback overload, so a source that can only write into a stream needs no intermediate pipe:

await using UploadFileResponse response = await filesClient.UploadFileAsync(
    (stream, ct) => blob.DownloadToAsync(stream, ct));

On the server, each format: binary part (or Swagger 2.0 type: file parameter) arrives on the Params struct as a ReadOnlyMemory<byte> property; multipart/mixed binary parts arrive as positional Part{n} properties and repeating binary items as an IReadOnlyList<ReadOnlyMemory<byte>>. These are slices over pooled memory with the same lifetime as Body: they are valid for the duration of the handler call and must not be retained after it returns. Copy the bytes (or forward them) before returning if they need to outlive the request.

Streaming File Uploads (Server)

By default the generated server buffers the whole multipart body before the handler runs, subject to the size limit described under Request Body Size Limits. For OpenAPI 3.2 specs you can opt in to streaming instead:

corvusjson openapi-server petstore-3.2.json --rootNamespace PetStore.Server --outputPath Generated --serverBinaryParts stream

In streaming mode a multipart/form-data endpoint never buffers its binary parts. The non-binary parts are still read into the typed Body first (they are form fields, capped by ApiServerOptions.MaxNonBinaryPartsLength, 1 MiB by default), and each format: binary property becomes a BinaryPartHandle on the Params struct. Open the handle to read that part's bytes directly from the request:

public async ValueTask<UploadPetPhotoResult> HandleUploadPetPhotoAsync(
    UploadPetPhotoParams parameters, JsonWorkspace workspace, CancellationToken cancellationToken = default)
{
    Stream? photo = await parameters.File.OpenStreamAsync(cancellationToken);
    if (photo is not null)
    {
        // Forward the upload without ever holding it in memory.
        await blobClient.UploadAsync(photo, cancellationToken);
    }

    return UploadPetPhotoResult.Created(receipt, workspace);
}

The contract is small. Each handle opens at most once. Consume the stream before the handler returns; it is not valid afterwards. Anything the handler does not open is drained and cleaned up by the generated endpoint. Opening a required part that is absent from the body produces a 400 for the request; an absent optional part opens as null.

Part Ordering Policies

Because the typed Body must be complete before the handler runs, the placement of binary parts on the wire matters. The policy is chosen where you map the endpoints, not at generation time, so one generated server can serve different clients with different policies:

  • MultipartBinaryOrdering.RequireBinaryLast (the default) is true streaming. Binary parts must arrive after all non-binary parts; a request that interleaves them is rejected with 400. Nothing touches disk or memory beyond the form fields. Clients generated by Corvus already send binary parts last.
  • MultipartBinaryOrdering.SpoolOutOfOrder accepts standard browser part order. A binary part arriving before the last form field is spooled, in pooled memory at or below SpoolMemoryThresholdBytes (64 KiB by default) and in a temporary file above it, and parsing continues to the end of the body. Handles then read from the spool in any order. The framework deletes spool files when the request completes, including on failures; handler code never cleans anything up. The trade is that out-of-order files hit disk rather than streaming straight through to their destination.
app.MapApiEndpoints(
    petsHandler,
    serverOptions: new ApiServerOptions
    {
        MultipartBinaryOrdering = MultipartBinaryOrdering.SpoolOutOfOrder,
        SpoolMemoryThresholdBytes = 256 * 1024,
        SpoolDirectory = "/var/tmp/uploads",
    });

Only the binary parts declared by the operation's schema are ever bound or spooled; undeclared and duplicate parts are drained without allocation. MaxSpooledBodyLength caps the total bytes spooled per request (unbounded by default, on the basis that the web server's request size limit governs; a request over the cap is rejected with 413).

Streaming multipart/mixed

multipart/mixed bodies are positional, so the wire order is the schema order and the ordering policy does not apply; they always stream live. Streaming applies when the schema's binary parts all come after its non-binary parts (a JSON envelope followed by a payload, for example); a schema that interleaves them keeps the buffered path. Binary prefix parts arrive as BinaryPartHandle Part{n} properties, and repeating binary items arrive as a BinaryPartSequence:

while (await parameters.Items.MoveNextAsync(cancellationToken) is { } item)
{
    await ProcessAsync(item, cancellationToken);
}

Browser Forms with RequireBinaryLast

Browsers submit form fields in DOM order, so a form with its file input above a text field sends the file first. Under RequireBinaryLast either put file inputs last in the form, or build the FormData yourself with files appended last:

const form = document.getElementById("upload");
form.addEventListener("submit", async (event) => {
  event.preventDefault();

  // Append text fields first, then files, so binary parts go last on the wire.
  const data = new FormData();
  for (const element of form.elements) {
    if (element.name && element.type !== "file") {
      data.append(element.name, element.value);
    }
  }
  for (const element of form.elements) {
    if (element.type === "file") {
      for (const file of element.files) {
        data.append(element.name, file);
      }
    }
  }

  await fetch("/upload", { method: "POST", body: data });
});

Alternatively, register the endpoints with SpoolOutOfOrder and submit the form unchanged.

Multipart Responses (Server)

An operation whose 2xx (or default) response declares multipart/form-data content with format: binary properties gets a result factory taking the typed body plus a BinaryPartData per binary part, mirroring what the client sends for the same shape:

return GetDocReportResult.Ok(
    meta,
    workspace,
    file: new BinaryPartData(async (s, ct) => await report.CopyToAsync(s, ct), FileName: "report.pdf"),
    thumb: new BinaryPartData(async (s, ct) => await thumb.CopyToAsync(s, ct), ContentType: "image/png"));

The generated endpoint puts a fresh boundary on the Content-Type header and streams the body: the typed body's non-binary fields first, then the binary parts, so the output is directly consumable by a streaming receiver. A part's ContentType left unset falls back to the spec-declared encoding.contentType, then to application/octet-stream. Write callbacks run against the live response stream, which disallows synchronous IO; use WriteAsync/CopyToAsync, never synchronous writes. The typed body is not schema-validated for multipart results, since the binary properties it declares are carried outside it.

Multipart Responses (Client)

Receiving the same response on the client is streaming only. The response holds the live body until you read it, once, through the generated accessor, which parses the typed body from the non-binary parts and hands each binary part back as a handle over the wire:

await using GetDocReportResponse response = await client.GetDocReportAsync();

GetDocReportOkMultipart report = await response.GetOkMultipartAsync();
Console.WriteLine((string)report.Body.Meta.Title);

Stream? file = await report.File.OpenStreamAsync();
if (file is not null)
{
    await file.CopyToAsync(destination);
}

The same handle contract as streaming server uploads applies: open each handle at most once, in wire order, and consume its stream before opening the next; a required part that is absent throws when opened, an absent optional part opens as null. Handles are valid until the response is disposed. The typed body's non-binary parts are capped by the accessor's maxNonBinaryPartsLength parameter (1 MiB by default). Corvus-generated servers emit binary parts last, so their responses always stream cleanly through this surface.

File Download

Use MatchResult<ValueTask> to handle stream responses asynchronously:

await downloadResponse.MatchResult<ValueTask>(
    matchOkStream: async stream =>
    {
        // Read binary data asynchronously from the raw stream
        await stream.CopyToAsync(fileStream);
    },
    matchNotFound: error =>
    {
        Console.WriteLine($"Photo not found: {error.Message}");
        return default;
    },
    matchDefault: statusCode =>
    {
        Console.WriteLine($"Unexpected: {statusCode}");
        return default;
    });

Validation

Client-Side Validation

By default, the generated client validates all parameters and request bodies before sending:

try
{
    // limit: 200 exceeds the schema's "maximum: 100" constraint
    await using ListPetsResponse _ = await client.ListPetsAsync(
        limit: 200,
        validationMode: ValidationMode.Detailed);
}
catch (InvalidOperationException ex)
{
    // Detailed mode includes full JSON Schema evaluation output
    Console.WriteLine($"Validation failed: {ex.Message}");
}
ModeBehaviour
ValidationMode.BasicValidates parameters and bodies; throws on failure with a brief message
ValidationMode.DetailedSame, but includes full JSON Schema evaluation output in the exception
ValidationMode.NoneSkips validation — use for trusted inputs in performance-critical paths

Server-Side Validation

The generated middleware validates all inputs before your handler runs:

Invalid inputGenerated behaviour
Required parameter missing400 Problem Details — handler never called
Parameter fails schema validation400 Problem Details
Request body fails JSON parse400 Problem Details
Request body fails schema validation400 Problem Details
Buffered request body over the size limit413 Problem Details
Response body fails validation500 Internal Server Error

You never write validation code. Your handler only receives valid, typed data.

Request Body Size Limits

The body paths that buffer the whole request before the handler runs (multipart/form-data, multipart/mixed and application/x-www-form-urlencoded) enforce a size limit, 128 MiB by default. Configure it at registration time with ApiServerOptions:

app.MapApiEndpoints(
    petsHandler,
    configureEndpoint: null,
    serverOptions: new ApiServerOptions { MaxBufferedRequestBodyLength = 32 * 1024 * 1024 });

A request whose declared Content-Length exceeds the limit is rejected with 413 Payload Too Large before any of the body is read; a chunked request is rejected as soon as the buffered bytes pass the limit. A cancelled request is no longer reported as a 400 parse failure — cancellation propagates to the host as usual.

Raw stream bodies and JSON bodies are not buffered by this layer and are not affected by the limit; cap those with the web server's request size limit (for example Kestrel's MaxRequestBodySize).

Servers generated with --serverBinaryParts stream do not buffer multipart binary parts at all, so this limit does not apply to their multipart endpoints. Their non-binary parts are capped by MaxNonBinaryPartsLength, and spooled parts by MaxSpooledBodyLength; see Streaming File Uploads (Server).

Customizing Generated Endpoints

By default MapApiEndpoints registers each operation as a bare minimal-API route. When you need per-endpoint conventions — authorization, route names, tags, Produces metadata, output caching, rate limiting — use the configureEndpoint overload. It is invoked once per generated endpoint, after the route is mapped (so your conventions win), with a descriptor identifying the operation and the route's IEndpointConventionBuilder:

app.MapApiEndpoints(petsHandler, static (in EndpointDescriptor endpoint, IEndpointConventionBuilder builder) =>
{
    builder.WithName(endpoint.OperationId ?? endpoint.MethodName);
    builder.WithTags([.. endpoint.Tags]);

    // Webhook/callback operations (from openapi-callback-server) are flagged separately.
    if (endpoint.IsCallback)
    {
        builder.WithMetadata(new EndpointGroupNameAttribute("webhooks"));
    }
});

The bare MapApiEndpoints(petsHandler) overload is preserved unchanged; the callback variant is an additional overload, so adopting the hook is both source- and binary-compatible. No new package dependency is added — the callback receives IEndpointConventionBuilder from Microsoft.AspNetCore.Routing, which the generated server already references.

EndpointDescriptor

EndpointDescriptor, EndpointSecurityRequirementSet, EndpointSecurityRequirement, and the ConfigureEndpoint delegate are read-only types generated alongside ApiEndpointRegistration in your server's root namespace.

MemberTypeDescription
OperationIdstring?OpenAPI operationId, or null if the operation declares none
MethodNamestringGenerated handler method name (the {MethodName} in Handle{MethodName}Async)
HttpMethodstringHTTP method (GET, POST, …)
RouteTemplatestringASP.NET route template as registered
TagsIReadOnlyList<string>OpenAPI tags for the operation
IsCallbackbooltrue for webhook/callback operations, false for main paths
SecurityRequirementsIReadOnlyList<EndpointSecurityRequirementSet>The operation's declared security as a list of alternatives (see below)

SecurityRequirements is populated for all supported spec versions (OpenAPI 2.0, 3.0, 3.1, and 3.2; in 2.0 the schemes come from securityDefinitions). Operation-level security takes precedence over the document-level default; operations that declare neither surface an empty list.

The OR/AND structure

OpenAPI security mirrors the spec's security array exactly: it is a list of alternatives, and the operation is satisfied if any one alternative is met (OR). Each alternative — an EndpointSecurityRequirementSet — is a group of scheme requirements that must all be met together (AND). For example [{bearerAuth: []}, {apiKeyAuth: []}] means bearerAuth OR apiKeyAuth, whereas [{bearerAuth: [], apiKeyAuth: []}] means bearerAuth AND apiKeyAuth.

EndpointSecurityRequirementSet (one alternative):

MemberTypeDescription
RequirementsIReadOnlyList<EndpointSecurityRequirement>The scheme requirements that must all be satisfied together (AND)
IsOptionalbooltrue when this alternative is the empty OpenAPI requirement ({}), which permits anonymous access
PolicyNamestringThe canonical policy name for the alternative: the single requirement's PolicyName, or the requirement policy names joined with && (empty for an anonymous alternative)

EndpointSecurityRequirement (one scheme within an alternative):

MemberTypeDescription
SchemeNamestringThe security scheme name, as declared in components.securitySchemes
ScopesIReadOnlyList<string>The scopes required by this requirement (empty for non-scoped schemes)
SchemeTypestring?The scheme's OpenAPI type (oauth2, apiKey, http, openIdConnect), or null if the scheme is not declared in components.securitySchemes. Lets the callback branch on scheme type without cross-referencing the scheme table
PolicyNamestringThe canonical policy name: the scheme name alone when no scopes are required, otherwise {schemeName}:{scope+scope...}. Use the same value when registering policies so endpoint mapping and policy registration stay in sync

Applying OpenAPI Security as Authorization

The generator surfaces components.securitySchemes and per-operation security on the descriptor, but it deliberately does not decide how a scheme maps to an authentication handler or how scopes are enforced — those are application concerns. It does, however, generate a small helper that applies the declared security using a sensible default convention, so the common case needs only one line.

The generated RequireDeclaredAuthorization helper

Alongside the registration types, the generator emits an EndpointSecurityConventions.RequireDeclaredAuthorization extension. Its behaviour follows the alternatives:

  • No declared security, or any anonymous ({}) alternative → AllowAnonymous.
  • A single alternative → RequireAuthorization for each scheme in it (AND), using each requirement's PolicyName.
  • Multiple alternatives (OR) → a single RequireAuthorization with a combined name (the alternatives' PolicyNames joined by ||), because ASP.NET endpoint conventions cannot OR policies. You register that one policy with your own OR logic.

You register the referenced policies and wire the helper into the hook:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(/* your scheme(s) */);
builder.Services.AddAuthorization(options =>
{
    // Single-alternative operations require one policy per requirement PolicyName.
    options.AddPolicy("oauth2:read:pets", p => p.RequireClaim("scope", "read:pets"));

    // An OR operation ([{bearerAuth}, {apiKeyAuth}]) requires the combined policy; you supply the OR logic.
    options.AddPolicy("bearerAuth || apiKeyAuth", p => p.RequireAssertion(ctx =>
        ctx.User.HasClaim(c => c.Type == "bearer") || ctx.User.HasClaim(c => c.Type == "apikey")));
});

WebApplication app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

PetsHandler petsHandler = new();
app.MapApiEndpoints(petsHandler, static (in EndpointDescriptor endpoint, IEndpointConventionBuilder builder) =>
    builder.RequireDeclaredAuthorization(endpoint));

app.Run();

Writing the mapping yourself

RequireDeclaredAuthorization is just a convenience over SecurityRequirements. When you need different policy names or to branch on SchemeType, walk the alternatives yourself:

app.MapApiEndpoints(petsHandler, static (in EndpointDescriptor endpoint, IEndpointConventionBuilder builder) =>
{
    if (endpoint.SecurityRequirements.Count == 0)
    {
        builder.AllowAnonymous();
        return;
    }

    foreach (EndpointSecurityRequirementSet alternative in endpoint.SecurityRequirements)
    {
        foreach (EndpointSecurityRequirement requirement in alternative.Requirements)
        {
            // requirement.SchemeType is "oauth2", "apiKey", "http", or "openIdConnect".
            _ = requirement.PolicyName;
        }
    }

    // ...translate the OR-of-ANDs however your app enforces it.
});

Because the callback just hands you an IEndpointConventionBuilder, every standard ASP.NET endpoint extension is available the same way — RequireRateLimiting, CacheOutput, RequireCors, DisableAntiforgery, and so on. See the 034-OpenApiCallbackServer recipe for the hook applied to a callback/webhook server.

Authentication

Corvus generates typed parameters for OpenAPI security schemes declared in the spec — but authentication itself is implemented using standard .NET HttpClient patterns. The generated transport (HttpClientTransport) wraps a regular HttpClient, so you configure auth the same way you would for any HTTP client.

Kiota provides built-in IAuthenticationProvider implementations (BaseBearerTokenAuthenticationProvider, ApiKeyAuthenticationProvider, AnonymousAuthenticationProvider, and an Azure Identity provider for Microsoft Entra). Corvus achieves the same result using IHttpClientFactory and standard DelegatingHandler middleware — the same patterns used across the .NET ecosystem.

Key advantage: Corvus extracts OAuth2 scopes from the specification and emits them as generated constants — per-operation (ListPetsOauth2Scopes) and unioned (AllOauth2Scopes). This eliminates hardcoded scope strings and ensures your token requests stay in sync with the API contract. Kiota does not surface per-operation scopes in generated code.

Microsoft Entra ID (Azure AD / MSAL)

Kiota provides a dedicated Azure Identity authentication provider that wraps MSAL. The Corvus equivalent uses Azure.Identity (which wraps MSAL internally) with a standard DelegatingHandler.

The code generator emits per-operation OAuth2 scope constants directly from the specification's security requirements. This means you never need to hardcode scopes — they come from the spec:

// Generated constants from the specification
IApiPetsClient.SecuritySchemes.Oauth2TokenUrl       // "https://auth.example.com/token"
IApiPetsClient.SecuritySchemes.Oauth2AuthorizationUrl // "https://auth.example.com/authorize"
IApiPetsClient.SecuritySchemes.Oauth2AvailableScopes // ["read:pets", "write:pets"]

// Per-operation scopes
IApiPetsClient.SecurityRequirements.ListPetsOauth2Scopes   // ["read:pets"]
IApiPetsClient.SecurityRequirements.CreatePetOauth2Scopes  // ["read:pets", "write:pets"]
IApiPetsClient.SecurityRequirements.AllOauth2Scopes        // ["read:pets", "write:pets"]

Use these with Azure.Identity and TokenRequestContext:

// Register Azure.Identity credential + handler in DI
services.AddSingleton<TokenCredential>(
    new ClientSecretCredential(tenantId, clientId, clientSecret));

services.AddTransient<EntraTokenHandler>();
services.AddHttpClient("petstore")
    .AddHttpMessageHandler<EntraTokenHandler>();

// Handler acquires tokens using generated scope constants
public class EntraTokenHandler(TokenCredential credential) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        AccessToken token = await credential.GetTokenAsync(
            new TokenRequestContext(IApiPetsClient.SecurityRequirements.AllOauth2Scopes),
            cancellationToken);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
        return await base.SendAsync(request, cancellationToken);
    }
}

For per-operation scopes (e.g., requesting a minimal token for read-only operations), use HttpRequestMessage.Options to pass the required scopes to your handler:

// A scope-aware handler that reads per-request scopes from Options
public class ScopeAwareEntraHandler(TokenCredential credential) : DelegatingHandler
{
    private static readonly HttpRequestOptionsKey<string[]> ScopesKey = new("OAuth2Scopes");

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        // Read scopes from the request options (set by calling code)
        if (!request.Options.TryGetValue(ScopesKey, out string[]? scopes))
        {
            // Fall back to union of all scopes if none specified
            scopes = IApiPetsClient.SecurityRequirements.AllOauth2Scopes;
        }

        AccessToken token = await credential.GetTokenAsync(
            new TokenRequestContext(scopes), cancellationToken);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
        return await base.SendAsync(request, cancellationToken);
    }

    /// <summary>
    /// Sets the OAuth2 scopes for a specific request.
    /// </summary>
    public static void SetScopes(HttpRequestMessage request, string[] scopes)
    {
        request.Options.Set(ScopesKey, scopes);
    }
}

Then at the call site, set operation-specific scopes when you need fine-grained token permissions:

// Read-only operation — request minimal scopes
var listRequest = new HttpRequestMessage(HttpMethod.Get, "/pets");
ScopeAwareEntraHandler.SetScopes(
    listRequest, IApiPetsClient.SecurityRequirements.ListPetsOauth2Scopes);

// Write operation — request elevated scopes
var createRequest = new HttpRequestMessage(HttpMethod.Post, "/pets");
ScopeAwareEntraHandler.SetScopes(
    createRequest, IApiPetsClient.SecurityRequirements.CreatePetOauth2Scopes);

Interactive and Device-Code Flows

For user-facing applications, substitute the credential type:

// Interactive browser login (desktop/native apps)
services.AddSingleton<TokenCredential>(
    new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions
    {
        ClientId = clientId,
        TenantId = tenantId,
        RedirectUri = new Uri("http://localhost:1234"),
    }));

// Device code flow (CLI tools, headless terminals)
services.AddSingleton<TokenCredential>(
    new DeviceCodeCredential(new DeviceCodeCredentialOptions
    {
        ClientId = clientId,
        TenantId = tenantId,
        DeviceCodeCallback = (info, cancel) =>
        {
            Console.WriteLine(info.Message); // "To sign in, use a web browser..."
            return Task.CompletedTask;
        },
    }));

// Managed identity (Azure-hosted services — no secrets needed)
services.AddSingleton<TokenCredential>(new DefaultAzureCredential());

All credential types work with the same ScopeAwareEntraHandler — the handler acquires tokens using whichever TokenCredential is registered. The generated scope constants ensure the correct permissions are requested regardless of flow.

Bearer Token (Generic OAuth2 / JWT)

For non-Entra APIs that use OAuth2 bearer tokens, implement a token service:

services.AddTransient<BearerTokenHandler>();
services.AddHttpClient("petstore")
    .AddHttpMessageHandler<BearerTokenHandler>();

public class BearerTokenHandler(ITokenService tokenService) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        string token = await tokenService.GetTokenAsync(cancellationToken);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        return await base.SendAsync(request, cancellationToken);
    }
}

// Create the Corvus client from the named HttpClient
HttpClient httpClient = httpClientFactory.CreateClient("petstore");
await using HttpClientTransport transport = new(httpClient, baseUri);
PetstorePetsClient client = new(transport);

API Key (Header)

Kiota provides ApiKeyAuthenticationProvider with KeyLocation.Header. The Corvus equivalent uses default request headers:

services.AddHttpClient("petstore", client =>
{
    client.DefaultRequestHeaders.Add("X-Api-Key", configuration["ApiKey"]);
});

API Key (Query Parameter)

Kiota provides ApiKeyAuthenticationProvider with KeyLocation.QueryParameter. The Corvus equivalent uses a DelegatingHandler:

public class ApiKeyQueryHandler(string paramName, string apiKey) : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var uriBuilder = new UriBuilder(request.RequestUri!);
        string separator = string.IsNullOrEmpty(uriBuilder.Query) ? "" : "&";
        uriBuilder.Query = uriBuilder.Query.TrimStart('?') + separator
            + Uri.EscapeDataString(paramName) + "=" + Uri.EscapeDataString(apiKey);
        request.RequestUri = uriBuilder.Uri;
        return base.SendAsync(request, cancellationToken);
    }
}

services.AddTransient(sp => new ApiKeyQueryHandler("api_key", configuration["ApiKey"]!));
services.AddHttpClient("petstore")
    .AddHttpMessageHandler<ApiKeyQueryHandler>();

Basic Authentication

services.AddHttpClient("petstore", client =>
{
    byte[] credentials = Encoding.UTF8.GetBytes($"{username}:{password}");
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Basic", Convert.ToBase64String(credentials));
});

Anonymous (No Authentication)

Kiota provides AnonymousAuthenticationProvider. With Corvus, this is the default — an HttpClient with no auth handler:

services.AddHttpClient("petstore");

// No auth handler — requests go out without credentials
HttpClient httpClient = httpClientFactory.CreateClient("petstore");
await using HttpClientTransport transport = new(httpClient, baseUri);

Combining Authentication with Resilience

Use Microsoft.Extensions.Http.Resilience (Polly v8) to compose authentication with retry policies:

services.AddHttpClient("petstore")
    .AddHttpMessageHandler<EntraTokenHandler>()
    .AddStandardResilienceHandler(options =>
    {
        options.Retry.MaxRetryAttempts = 3;
        options.Retry.BackoffType = DelayBackoffType.Exponential;
    });

This gives you exponential backoff with jitter, circuit breaking, and request hedging — all composable with your auth handler via the standard IHttpClientFactory pipeline.

Per-Request Authentication Override

Because HttpClientTransport wraps a standard HttpClient, you can also set auth per-request using HttpRequestMessage.Options with a custom handler that reads per-request tokens from the options dictionary — the same pattern used for differentiated auth in multi-tenant scenarios.

Cookie parameters appear as regular method parameters — the generated code sets the Cookie header:

// Client: cookie becomes a method parameter
await using CreatePetResponse response = await petsClient.CreatePetAsync(
    session_token: "sess_k7j2m9x4"u8,
    body: ...);
// Wire: Cookie: session_token=sess_k7j2m9x4

On the server, cookie presence is validated automatically:

// Server: cookie is extracted and validated before your handler runs
public ValueTask<CreatePetResult> HandleCreatePetAsync(
    CreatePetParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
    // parameters.SessionToken is guaranteed non-undefined (required cookie)
    string token = (string)parameters.SessionToken;
    // Your auth logic here...
}

If the cookie is declared required: true and missing, the generated middleware returns 400 Problem Details automatically.

In practice, cookie-based APIs typically use ASP.NET Core's cookie authentication middleware. The cookie is issued by a login endpoint and validated on subsequent requests by the framework — your handler receives an already-authenticated ClaimsPrincipal.

When the OpenAPI spec declares a security scheme with type: apiKey and in: cookie, the generator emits a SecuritySchemes.{Name}KeyName constant containing the cookie name from the spec. On the server side, this lives on the ApiEndpointRegistration class. Use this constant to configure the ASP.NET Core cookie name — keeping your server in sync with the API contract:

Server setup — configure cookie auth in the ASP.NET Core pipeline:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        // Use the generated constant — derived from the OpenAPI securitySchemes definition
        options.Cookie.Name = ApiEndpointRegistration.SecuritySchemes.SessionAuthKeyName;
        options.Cookie.HttpOnly = true;
        options.Cookie.SameSite = SameSiteMode.Strict;
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
        options.ExpireTimeSpan = TimeSpan.FromHours(2);
        options.SlidingExpiration = true;
        options.Events.OnRedirectToLogin = context =>
        {
            // API servers return 401, not a redirect
            context.Response.StatusCode = 401;
            return Task.CompletedTask;
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

// Register the generated endpoints (validation runs before auth in the pipeline)
app.MapPetstoreEndpoints(new PetstoreHandlers());

Login endpoint — issue the cookie via ASP.NET's SignInAsync:

app.MapPost("/login", async (LoginRequest login, HttpContext context) =>
{
    // Validate credentials (e.g., against a database)
    User? user = await userService.ValidateAsync(login.Username, login.Password);
    if (user is null)
    {
        return Results.Unauthorized();
    }

    // Build claims and sign in — ASP.NET Core sets the cookie automatically
    List<Claim> claims =
    [
        new(ClaimTypes.NameIdentifier, user.Id.ToString()),
        new(ClaimTypes.Name, user.Username),
        new(ClaimTypes.Role, user.Role),
    ];

    ClaimsIdentity identity = new(claims, CookieAuthenticationDefaults.AuthenticationScheme);
    await context.SignInAsync(
        CookieAuthenticationDefaults.AuthenticationScheme,
        new ClaimsPrincipal(identity),
        new AuthenticationProperties { IsPersistent = true });

    return Results.Ok();
});

Handler — in your generated handler, the cookie has already been validated by the middleware. Access the authenticated user via HttpContext:

public ValueTask<CreatePetResult> HandleCreatePetAsync(
    CreatePetParams parameters, JsonWorkspace workspace, CancellationToken ct)
{
    // The cookie was validated by ASP.NET Core's CookieAuthenticationHandler
    // before this handler is reached. Access claims via HttpContext:
    // var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);

    // The generated cookie parameter still gives you the raw value if needed
    string rawToken = (string)parameters.SessionToken;

    // Your business logic...
}

How client and server cookie names stay in sync

Both the client and server generators emit the cookie name as a constant derived from the same OpenAPI spec. The spec is the single source of truth:

SideConstantValue
ServerApiEndpointRegistration.SecuritySchemes.SessionAuthKeyName"session_token"
ClientIApiPetsClient.SecuritySchemes.SessionAuthKeyName"session_token"

In the normal login flow, you don't need to reference the client constant explicitly — the server's Set-Cookie: session_token=... response header tells CookieContainer the name, and it matches automatically. The constant is useful when you need to manually inject a cookie (see below).

Client — integration tests with WebApplicationFactory

ASP.NET Core integration tests use WebApplicationFactory<T> to create an in-memory test server. Its CreateClient() method returns an HttpClient with cookies enabled by default (HttpClientHandler.UseCookies = true). Call the login endpoint, and subsequent requests include the cookie automatically:

// Arrange: create the test server and a cookie-aware HttpClient
await using var factory = new WebApplicationFactory<Program>();
using var httpClient = factory.CreateClient();

// Act: log in — ASP.NET Core's SignInAsync sets the Set-Cookie header,
// and HttpClient's CookieContainer captures it automatically
using var loginResponse = await httpClient.PostAsJsonAsync("/login",
    new { username = "admin", password = "secret" });
Assert.AreEqual(HttpStatusCode.OK, loginResponse.StatusCode);

// All subsequent requests from this HttpClient include the cookie automatically
var petsClient = new ApiPetsClient(new HttpClientTransport(httpClient));
await using var response = await petsClient.ListPetsAsync();
// The generated middleware validates the cookie and your handler runs normally

The CookieContainer on the underlying HttpClientHandler stores cookies from Set-Cookie response headers and attaches them to subsequent requests for the same domain — exactly the same way a browser behaves. No manual header extraction is needed.

Client — production service-to-service with IHttpClientFactory

For production, register a named client with IHttpClientFactory. The default SocketsHttpHandler has cookies enabled, so the login → capture → replay flow works the same way:

// In Startup / Program.cs — register the named client
builder.Services.AddHttpClient("PetstoreApi", client =>
{
    client.BaseAddress = new Uri("https://api.example.com");
});

// At runtime — resolve and use the client
var httpClient = httpClientFactory.CreateClient("PetstoreApi");

// Login — the server's Set-Cookie is captured by the handler's CookieContainer
await httpClient.PostAsJsonAsync("/login", new { username = "svc", password = "secret" });

// Subsequent calls include the cookie automatically
var petsClient = new ApiPetsClient(new HttpClientTransport(httpClient));
await using var response = await petsClient.CreatePetAsync(body: ...);

Note: IHttpClientFactory pools handlers (default lifetime: 2 minutes). Cookies persist within the handler's lifetime. For long-lived sessions, increase the handler lifetime via SetHandlerLifetime() or manage cookies externally.

Client — manual cookie injection

If you already have a session token (e.g., from a shared auth service or a config store) and need to inject it without calling a login endpoint, use the generated client constant to ensure the cookie name matches the server's expectation:

var cookieContainer = new CookieContainer();
cookieContainer.Add(
    new Uri("https://api.example.com"),
    new Cookie(IApiPetsClient.SecuritySchemes.SessionAuthKeyName, existingToken));

var handler = new HttpClientHandler { CookieContainer = cookieContainer };
using var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com") };

var petsClient = new ApiPetsClient(new HttpClientTransport(httpClient));
await using var response = await petsClient.ListPetsAsync();

Resilience (Polly Integration)

The Corvus.Text.Json.OpenApi.Polly package wraps any IApiTransport in a Polly resilience pipeline, so retry and circuit-breaking are configured once for the client rather than at every call site. It is the OpenAPI counterpart of Corvus.Text.Json.AsyncApi.Polly.

dotnet add package Corvus.Text.Json.OpenApi.Polly
using Corvus.Text.Json.OpenApi.Polly;
using Polly;
using Polly.Retry;

ResiliencePipeline pipeline = new ResiliencePipelineBuilder()
    .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3 })
    .AddTimeout(TimeSpan.FromSeconds(10))
    .Build();

await using HttpClientTransport inner = new(httpClient);
await using ResilientApiTransport transport = new(inner, pipeline);

ApiPetsClient client = new(transport);

Every operation passes through the pipeline and is otherwise unchanged, so the generated client neither knows nor needs to know that it is there. Disposing the resilient transport disposes the one it wraps.

Choose the strategies with the API in mind: retrying a non-idempotent operation can duplicate work, and the pipeline cannot tell which is which.

Retries and Request Bodies

A retried attempt has to re-send the request body, and the transport makes that safe for every body kind:

  • JSON bodies re-serialize from the typed element on every attempt.
  • Write callbacks (form, multipart, and the raw-stream write-callback overload, including BinaryPartData.WriteContentAsync) are invoked once per attempt. They must be re-invocable: each invocation must write the same content.
  • A seekable Stream body is rewound to the position it had when the call was made before each attempt, so a failed attempt's partial read never truncates the retry.
  • A non-seekable Stream body cannot be replayed. It retries only while still unconsumed (for example when the connection was refused before anything was sent); once an attempt has read from it, a retry fails with a descriptive InvalidOperationException rather than silently sending a truncated body. Prefer a seekable stream or the write-callback overload when the operation should retry.

Webhooks and Callbacks

OpenAPI specifications can define webhooks (top-level, spec-wide notifications) and callbacks (per-operation, triggered by runtime expressions). The Corvus code generator supports both with dedicated commands that produce the same output structure as regular client/server generation.

Understanding the Symmetry

Consider a spec where an API server sends webhook notifications to its clients:

┌────────────────┐  POST /subscribe    ┌────────────────┐
│   Client App   │ ──────────────────▶ │   API Server   │
│                │                      │                │
│                │ ◀────────────────── │                │
│   (webhook    │  POST {callbackUrl}  │   (sends       │
│    receiver)  │  "pet was adopted"   │    webhooks)   │
└────────────────┘                      └────────────────┘

This creates two new generation scenarios:

You are building…You need…Command
The client appA server to receive the webhooksopenapi-callback-server
The API serverA client to send the webhooksopenapi-callback-client

Callback Server — Receiving Webhooks

When your application subscribes to a service's events, you need a server endpoint to receive the callbacks. Generate the server stubs:

corvusjson openapi-callback-server petstore.json \
    --rootNamespace MyApp.WebhookReceiver \
    --outputPath ./Generated/WebhookReceiver

This produces:

  • Handler interfaces — implement these to process incoming notifications
  • Endpoint registration — wire up ASP.NET minimal API routes
  • Params/Result types — schema-validated request bodies and typed responses

Wire it up in your client application:

using Corvus.Text.Json;
using MyApp.WebhookReceiver;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
WebApplication app = builder.Build();

WebhookHandler handler = new();
app.MapApiEndpoints(handler);

app.Run();

Callback Client — Sending Webhooks

When your server needs to notify subscribed clients at their registered callback URLs, generate a typed client:

corvusjson openapi-callback-client petstore.json \
    --rootNamespace MyApp.WebhookSender \
    --outputPath ./Generated/WebhookSender

This produces:

  • Client class — type-safe methods for each webhook/callback operation
  • Request/Response types — serialize the notification payload, parse the client's response

Use it in your server's business logic:

using Corvus.Text.Json.OpenApi.HttpTransport;
using MyApp.WebhookSender;

// When a pet is adopted, notify the subscriber
using HttpClient httpClient = new() { BaseAddress = new Uri(subscriberCallbackUrl) };
await using HttpClientTransport transport = new(httpClient);
await using ApiWebhooksClient client = new(transport);

await using PetAdoptedWebhookResponse response = await client.PetAdoptedWebhookAsync(
    body: PetAdoptedEvent.Build(petId: "pet-123"u8, adopterId: "user-456"u8));

Runtime Expressions and Context

OpenAPI callback keys use runtime expressions like {$request.body#/callbackUrl} to specify where values come from. The code generator resolves these automatically — the generated client extracts values from the originating request/response context using typed property navigation.

ExpressionWhat it resolves toGenerated code
$request.body#/callbackUrlA JSON Pointer into the request bodysourceRequest.CallbackUrl (typed accessor)
$request.query.eventTypeA query parameter from the original requestsourceRequest.EventType
$request.header.X-Correlation-IdA header from the original requestsourceRequest.XCorrelationId
$response.body#/idA JSON Pointer into the response bodyresponse.CreatedBody.Id
$response.header.LocationA response headerresponse.LocationHeader

The generated Response struct captures all the context needed to follow links and invoke callbacks. The generated code automatically fills in parameters bound by runtime expressions — you don't write this code yourself; it is emitted by the generator:

// This is GENERATED code — the generator creates this method and its
// runtime expression bindings from your OpenAPI spec automatically.
// After creating a subscription, the response carries context for callbacks.
await using CreateSubscriptionResponse response = await client.CreateSubscriptionAsync(
    body: Subscription.Build(callbackUrl: "https://my-app.example.com/hooks"u8, events: ...));

// response.Links gives typed access to linked/callback operations.
// Runtime expressions like $request.body#/callbackUrl are resolved automatically
// from the captured request/response context — no manual wiring needed.

This is the same runtime expression infrastructure used by OpenAPI Links — both callbacks and links share the context-capture mechanism.

OpenAPI Links define follow-on operations that can be invoked from a response, with parameters automatically populated from runtime expressions. The code generator supports links on response objects.

When a response declares links, the generated Response struct provides typed navigation methods:

// Create a pet — the 201 response links to showPetById
await using CreatePetResponse response = await client.CreatePetAsync(
    body: NewPet.Build(name: "Luna"u8, tag: "cat"u8));

// Follow the link — petId is automatically populated from $response.body#/id
response.MatchResult(
    matchCreated: async (pet) =>
    {
        // The generated link method fills parameters from runtime expressions
        await using ShowPetByIdResponse petResponse = await response.CreatedLinks.ShowPetByIdAsync();
        // petId was automatically extracted from the create response body
    },
    matchDefault: (error) => { });

The generator captures request and response state in the response struct so that linked operations can resolve their parameter bindings without manual wiring. Parameters bound by $request.body#/..., $request.header.X-..., $response.body#/..., and $response.header.X-... expressions are all resolved automatically.

Example Spec with Webhooks and Callbacks

{
  "openapi": "3.2.0",
  "info": { "title": "Event API", "version": "1.0.0" },
  "paths": {
    "/subscriptions": {
      "post": {
        "operationId": "createSubscription",
        "callbacks": {
          "onEvent": {
            "{$request.body#/callbackUrl}": {
              "post": {
                "operationId": "onEventCallback",
                "requestBody": {
                  "content": {
                    "application/json": {
                      "schema": { "$ref": "#/components/schemas/Event" }
                    }
                  }
                },
                "responses": { "200": { "description": "Received" } }
              }
            }
          }
        }
      }
    }
  },
  "webhooks": {
    "statusChange": {
      "post": {
        "operationId": "statusChangeWebhook",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/StatusChange" }
            }
          }
        },
        "responses": { "200": { "description": "Acknowledged" } }
      }
    }
  }
}

CLI Reference

Client Generation

corvusjson openapi-client <spec-path> [options]
OptionDescriptionDefault
--rootNamespaceRoot namespace for generated typesDerived from spec title
--outputPathOutput directory./
--clientNamePrefix for generated client type namesDerived from spec title
--forceRegenerate even if lock file is currentfalse
--spec-urlOriginal URL of the spec (recorded in lock file for update-style re-fetch)
--include-pathGlob patterns for paths to include (repeatable or comma-separated)All paths
--exclude-pathGlob patterns for paths to exclude (repeatable or comma-separated)None
--specVersionOverride auto-detected OpenAPI version (2.0, 3.0, 3.1, or 3.2)Auto-detected
--ignoreEmptyFormUrlEncodedBodyTreat form-urlencoded bodies with no schema properties as absentfalse

Server Generation

corvusjson openapi-server <spec-path> [options]

Same options as client generation, plus --serverBinaryParts buffer|stream. The default, buffer, binds each multipart binary part's bytes onto the Params struct; stream (OpenAPI 3.2 specs) hands the handler streaming part handles instead, as described under Streaming File Uploads (Server). The output includes handler interfaces, endpoint registration, and shared model types.

Callback Server Generation

corvusjson openapi-callback-server <spec-path> [options]

Generates server stubs for webhooks and callbacks defined in the spec. Use this when you are building a client application that needs to receive webhook/callback notifications from a service.

Same options as openapi-server. Only webhook and per-operation callback path-items are processed — the main paths object is ignored.

Callback Client Generation

corvusjson openapi-callback-client <spec-path> [options]

Generates a typed HTTP client for invoking webhooks and callbacks defined in the spec. Use this when you are building the server side of an API and need to call back into client-provided endpoints.

Same options as openapi-client. Only webhook and per-operation callback path-items are processed.

Inspecting Operations

corvusjson openapi-show <spec-path> [options]

Displays the operation tree of an OpenAPI specification. Useful for previewing which operations a filter will select before running generation.

OptionDescriptionDefault
--include-pathGlob patterns for paths to includeAll paths
--exclude-pathGlob patterns for paths to excludeNone
--group-byGroup operations by path or tagpath
--specVersionOverride auto-detected OpenAPI versionAuto-detected

Path Filtering

For large specs, use --include-path and --exclude-path to generate code for a subset of operations. Filters apply to both client and server generation and to openapi-show.

Pattern syntax:

PatternMatches
/petsExactly /pets
/pets/*One segment after /pets/ (e.g., /pets/{petId})
/pets/**Any depth under /pets/ (e.g., /pets/{petId}/toys/{toyId})
{param}Any path parameter segment (e.g., /pets/{petId} matches /pets/*)

Patterns are case-insensitive. Include patterns are additive (union). Exclude patterns subtract from the include set. If no include patterns are specified, all paths are included.

Examples:

# Generate only the /pets endpoints (including nested paths like /pets/{petId})
corvusjson openapi-client petstore.json --include-path "/pets/**"

# Generate everything except admin endpoints
corvusjson openapi-client petstore.json --exclude-path "/admin/**"

# Combine: include /pets and /store, but exclude store admin
corvusjson openapi-client petstore.json \
    --include-path "/pets/**" --include-path "/store/**" \
    --exclude-path "/store/admin/**"

# Multiple patterns via comma-separated values
corvusjson openapi-client petstore.json --include-path "/pets/**,/users/**"

# Preview what will be generated before running
corvusjson openapi-show petstore.json --include-path "/pets/**"

Workflow: Use openapi-show with filters to preview, then apply the same filter to openapi-client or openapi-server:

# 1. See what's in the spec
corvusjson openapi-show petstore.json --group-by tag

# 2. Preview filtered subset
corvusjson openapi-show petstore.json --include-path "/pets/**"

# 3. Generate only that subset
corvusjson openapi-client petstore.json --include-path "/pets/**" \
    --rootNamespace MyApp.Pets --outputPath ./Generated/Pets

Only operations matching the filter are generated. Model types referenced by filtered operations are still included — filtering is at the operation level, not the schema level.

Real-world example: Stripe Payments subset

Stripe publishes their OpenAPI spec at openapi/spec3.json — it contains 300+ endpoints. You can generate a focused client for just the payment processing operations:

# Download the Stripe spec (or use --spec-url for automatic re-fetch)
corvusjson openapi-client spec3.json \
    --spec-url "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json" \
    --include-path "/v1/payment_intents/**,/v1/charges/**,/v1/refunds/**" \
    --rootNamespace MyApp.Stripe.Payments \
    --outputPath ./Generated/StripePayments \
    --ignoreEmptyFormUrlEncodedBody

# Or preview what that includes first
corvusjson openapi-show spec3.json \
    --include-path "/v1/payment_intents/**,/v1/charges/**,/v1/refunds/**"

The --ignoreEmptyFormUrlEncodedBody flag is recommended for Stripe because their spec declares application/x-www-form-urlencoded request bodies on every operation — even those with no body properties (e.g. GET /v1/charges). Without this flag, the generated client methods include a redundant body parameter.

You can split a large spec into multiple domain-specific clients:

# Payments domain
corvusjson openapi-client spec3.json \
    --include-path "/v1/payment_intents/**,/v1/charges/**,/v1/refunds/**" \
    --rootNamespace MyApp.Stripe.Payments --outputPath ./Generated/Payments

# Customer domain
corvusjson openapi-client spec3.json \
    --include-path "/v1/customers/**,/v1/subscriptions/**" \
    --rootNamespace MyApp.Stripe.Customers --outputPath ./Generated/Customers

# Billing domain (everything except one-off payments)
corvusjson openapi-client spec3.json \
    --include-path "/v1/invoices/**,/v1/plans/**,/v1/prices/**" \
    --rootNamespace MyApp.Stripe.Billing --outputPath ./Generated/Billing

Each produces an independent client with only the operations and models relevant to that domain.

Lock File and Regeneration

The generator writes a corvusjson-openapi.lock file alongside the output. On subsequent runs, if the spec hasn't changed, generation is skipped. Use --force to regenerate unconditionally.

Version Support

OpenAPI VersionDetected FromNotes
3.2"openapi": "3.2.0"Full support including itemSchema for streaming
3.1"openapi": "3.1.x"Full support; JSON Schema 2020-12 for models
3.0"openapi": "3.0.x"Full support; JSON Schema Draft 4 subset for models
2.0 (Swagger)"swagger": "2.0"Full support; see the OpenAPI 2.0 notes below

The generator auto-detects the version from the openapi field (or the swagger field for 2.0 documents). All versions produce the same client/server API surface — only the internal model schemas and document shapes differ.

OpenAPI 2.0 (Swagger) notes

Swagger 2.0 documents differ structurally from 3.x, and the generator lowers those differences natively:

  • A body parameter becomes the request body, with media types drawn from the effective consumes list (operation-level replaces document-level; application/json when absent).
  • formData parameters aggregate into a synthesized form body serialized as application/x-www-form-urlencoded (or multipart/form-data when a file parameter or the consumes list requires it); type: file fields become hoisted binary-part parameters.
  • collectionFormat maps to wire formats directly: csv joins with commas, ssv with spaces, tsv with tabs, pipes with pipes, and multi repeats the parameter. All five are supported on query and formData parameters, on both the client and server sides. ssv/tsv/pipes on path or header parameters have no wire mapping and degrade to csv semantics on both sides; the generated request struct carries a #warning naming the parameter so the degradation is visible at build time.
  • Responses carry a direct schema (no media-type map); media types come from the effective produces list, and type: file responses use the raw-stream path.
  • host + basePath + schemes produce the server URL (https preferred when several schemes are listed; operation-level schemes override).
  • Non-body parameters carry their constraint keywords directly, and the models are generated under a dedicated OpenAPI 2.0 schema dialect: JSON Schema Draft 4 keywords (including the boolean exclusiveMinimum/exclusiveMaximum forms and tuple-form items), the 2.0 fixed fields (discriminator, readOnly, xml, example, externalDocs), the widely-used x-nullable vendor convention, and oneOf/anyOf/not/nullable accepted as practical extras found in real-world Swagger documents. patternProperties and dependencies are not part of the dialect.
  • OpenAPI 2.0 defines no callbacks or webhooks; the openapi-callback-client and openapi-callback-server commands fail with a clear message for 2.0 documents.

Best Practices

  1. Use u8 literals for scalar parameterspetId: "abc"u8, name: "Fido"u8. Implicit conversions handle encoding and avoid UTF-16→UTF-8 transcoding.
  2. Use Builder.Create() for request/response bodies — mirrors schema properties with typed parameters.
  3. Always await using responses and transports — they hold pooled memory that must be returned.
  4. Prefer MatchResult for response handling — ensures exhaustive coverage of all status codes.
  5. Leave validationMode at Basic for development; consider None for production hot paths.
  6. Pass the workspace through on the server — it enables efficient pooled-memory JSON building.
  7. Let the middleware validate — don't re-validate inputs the generated code already checked.

Comparison with Kiota

Microsoft Kiota is the most widely-used OpenAPI client generator for .NET. This section compares the two generators across architecture, features, and performance.

Architecture

AspectCorvusKiota
Generation modelCLI tool (corvusjson openapi-client)CLI tool (kiota generate)
Runtime dependencyCorvus.Text.Json.OpenApi (thin transport abstraction)Microsoft.Kiota.Bundle (abstractions + HTTP + serialization + auth)
SerializationZero-copy over pooled JSON document (no POCO hydration)POCO model classes with IParsable self-deserialization
Memory modelStruct-based types backed by pooled byte[] — GC-free hot pathClass-based models allocated per response
Transport abstractionIApiTransport (4 overloads: no-body, typed, stream, writer)IRequestAdapter wrapping HttpClient with middleware pipeline
Schema validationBuilt-in, configurable per-request (None/Basic/Detailed)None — no schema validation support

Feature Support

FeatureCorvusKiota
OpenAPI 2.0 (Swagger)Via conversion
OpenAPI 3.0
OpenAPI 3.1
OpenAPI 3.2Partial
JSON request/response bodies
Form-urlencoded bodies
Multipart/mixed bodiesLimited
Binary upload (octet-stream)
Server stub generation
Webhooks (top-level, OA 3.1+)❌ (confirmed by team)
Per-operation callbacks❌ (no tracking issue)
OpenAPI Links (typed follow-on ops)❌ (no tracking issue)
Runtime expression resolution❌ (not applicable — no callbacks/links)
Typed response headers
Cookie parameters
Schema validation✅ (None/Basic/Detailed)
Per-operation OAuth2 scopes✅ (generated constants)❌ (must hardcode)
Error response discrimination✅ (typed MatchResult)✅ (exception-based)
Middleware pipelinePlatform-native (IHttpClientFactory + Polly)Built-in DelegatingHandler chain
Multiple languagesC# onlyC#, Java, Go, TypeScript, Python, PHP, Ruby, Swift, CLI
IDE autocompletion✅ (pre-generated files)✅ (pre-generated files)

Response Handling

AspectCorvusKiota
Return typeValue-type response struct with IAsyncDisposableNullable POCO or Task<T?>
Status discriminationMatchResult() with typed handlers per status codeException-based (ApiException subclasses)
Memory lifetimeResponse owns pooled document; disposed via await usingGC-managed; no explicit lifetime
Response headersGenerated typed properties (lazy-parsed)Not generated — must use NativeResponseHandler

Middleware and Resilience

Kiota ships its own DelegatingHandler pipeline with default handlers for retry, redirect, user-agent injection, and header inspection.

Corvus uses platform-native .NET patterns instead:

ConcernCorvusKiota
RetryMicrosoft.Extensions.Http.Resilience (Polly v8)Built-in RetryHandler (exponential backoff)
RedirectSocketsHttpHandler.AllowAutoRedirect (platform)Built-in RedirectHandler
ObservabilityOpenTelemetry HttpClient instrumentationCustom Activity tracing in handlers
CompositionIHttpClientFactory.AddHttpMessageHandler()KiotaClientFactory.CreateDefaultHandlers()

This means Corvus doesn't reimplement retry/redirect logic that the platform already provides, and users can mix any community middleware (Polly, OpenTelemetry, logging) without learning a framework-specific handler API.

The trade-off is that these middleware patterns are tied to HttpClient — if you swap HttpClientTransport for a non-HTTP transport (e.g., in-process or message queue), the DelegatingHandler pipeline no longer applies. Corvus has no transport-independent middleware abstraction; Kiota's built-in handler chain works regardless of the underlying transport adapter.

Performance

All benchmarks use mock transports (no real I/O) measuring the full client pipeline: construct request → serialize → transport → parse response → typed access. "Corvus + Validation" uses ValidationMode.Basic — boolean pass/fail schema validation of both request and response bodies (no error-location collection).

OperationCorvusCorvus + Basic ValidationKiotaSpeed (×Kiota)Alloc (×Kiota)
GET /pets (10-item array)1,342 ns3,059 ns6,088 ns4.5× / 2.0×17×
GET /pets/{petId}409 ns610 ns2,290 ns5.6× / 3.8×14×
POST /pets (JSON body)434 ns701 ns2,910 ns6.7× / 4.2×15×
PUT /pets/{petId} (form body)536 ns833 ns2,957 ns5.5× / 3.5×12×
Response header (x-next)472 nsN/A

Speed column: first number = Corvus (no validation) vs Kiota; second = Corvus + Basic validation vs Kiota. Alloc column: Kiota allocation ÷ Corvus allocation.

Corvus with full schema validation is 2–4× faster than Kiota without any validation, and allocates 12–17× less memory.

Validation mode overhead (GET /pets, 3-item array):

ModeMeanvs NoneAlloc
None616 ns1.0×1,044 B
Basic (boolean pass/fail)1,272 ns2.1×1,044 B
Detailed (error locations)2,039 ns3.3×1,044 B

All validation modes produce zero additional allocation.

BenchmarkDotNet v0.15.8, .NET 10.0.8, 13th Gen Intel Core i7-13800H, Windows 11. OutlierMode=RemoveAll, RunStrategy=Throughput.

When to Choose Corvus

  • Performance-critical services where latency and allocation matter
  • APIs requiring request/response validation without external tooling
  • .NET-only projects wanting zero-copy JSON and compile-time safety
  • APIs using form-urlencoded, multipart/mixed, or cookie parameters
  • Scenarios requiring typed response header access
  • Projects needing both client and server from the same spec
  • APIs with webhooks or callbacks that need both receiver stubs and sender clients
  • OAuth2-secured APIs where you want spec-driven scope management (Corvus generates per-operation scope constants from security requirements; Kiota requires manual hardcoding)
  • Specs using OpenAPI Links for typed operation chaining (Corvus generates follow-on methods with automatic runtime expression resolution)

When to Choose Kiota

  • Multi-language projects needing consistent client generation across platforms
  • Teams wanting a self-contained middleware pipeline without configuring IHttpClientFactory
  • Projects where POCO-style models are preferred over struct-based types
  • APIs where performance is not the primary concern

Example Recipes

RecipeWhat it shows
029-OpenApiClientBasic client: list, create, show with MatchResult
030-OpenApiServerBasic server: handler interface + endpoint registration
031-OpenApiAdvancedClientAdvanced: streaming, binary, forms, cookies, deep-object queries
032-OpenApiAdvancedServerAdvanced server: all parameter styles, streaming, uploads
033-OpenApiEndToEndFull round-trip: generated client calls generated server over real HTTP
034-OpenApiCallbackServerWebhook receiver: callback server stubs for client-side webhook handling
035-OpenApiCallbackClientWebhook sender: callback client for server-side webhook delivery