OpenAPI Code Generation with Corvus: End-to-End
At endjin, we maintain Corvus.JsonSchema, and in the previous two posts we generated a typed HTTP client and server stubs from the same OpenAPI spec. This time we wire them together. A generated client calls a generated server over real HTTP, and we look at authentication with generated scope constants.
Why both sides from one spec?
The whole point of a contract-first approach is that the client and server agree. If you generate only the client, you can still drift on the server side. If you generate only the server, clients can send malformed requests that pass your hand-written validation but violate the spec.
When both sides are generated from the same specification, the schema contract is enforced at both boundaries. If the client builds a request that satisfies its generated validation, the server will accept it. If either side drifts from the spec, regeneration breaks the build. That's the feedback loop we want.
Setting up the round-trip
Generate both sides with separate namespaces:
corvusjson openapi-client petstore.json \
--rootNamespace Petstore.Client \
--outputPath ./Generated/Client
corvusjson openapi-server petstore.json \
--rootNamespace Petstore.Server \
--outputPath ./Generated/Server
Start the server with ASP.NET Core minimal APIs:
using Corvus.Text.Json.OpenApi;
using Corvus.Text.Json.OpenApi.HttpTransport;
using Petstore.Server;
using Petstore.Client;
WebApplicationBuilder builder = WebApplication.CreateBuilder();
WebApplication app = builder.Build();
PetsHandler handler = new();
app.MapApiEndpoints(handler);
await app.StartAsync();
string serverUrl = app.Urls.First();
Connect the client via HttpClientTransport:
using HttpClient httpClient = new() { BaseAddress = new Uri(serverUrl) };
await using HttpClientTransport transport = new(httpClient);
await using ApiPetsClient client = new(transport);
Both sides validate against the same JSON Schema. If the client builds a valid request, the server accepts it. If either side drifts from the spec, validation fails at the boundary.
Full round-trip
Create a resource through the client, receive it from the server:
await using CreatePetResponse createResponse = await client.CreatePetAsync(
session_token: "admin-token"u8,
body: new NewPet.Source((ref NewPet.Builder b) =>
{
b.Create(
name: "Luna"u8,
status: "available"u8,
tags: new NewPet.JsonStringArray.Source(
(ref NewPet.JsonStringArray.Builder ab) =>
{
ab.AddItem("friendly"u8);
ab.AddItem("vaccinated"u8);
}));
}));
createResponse.MatchResult(
matchCreated: pet =>
{
Console.WriteLine($"Created: [{pet.Id}] {pet.Name}");
return 0;
},
matchDefault: error =>
{
Console.WriteLine($"Error: {error.Message}");
return 0;
});
The generated server handler receives pre-validated parameters and returns typed results:
public ValueTask<CreatePetResult> HandleCreatePetAsync(
CreatePetParams parameters,
JsonWorkspace workspace,
CancellationToken cancellationToken = default)
{
return new(CreatePetResult.Created(
body: new Pet.Source((ref Pet.Builder pb) =>
{
pb.Create(
id: 42,
name: parameters.Body.Name,
status: parameters.Body.Status);
}),
workspace: workspace));
}
What's next
So far we've focused on the request/response basics. In the [ref slug=openapi-code-generation-with-corvus-streaming text=next post], we'll look at streaming - SSE and NDJSON responses where the server pushes a sequence of typed items and the client consumes them as IAsyncEnumerable.