OpenAPI
Bring the [HttpEndpoint] REST transport to schema/contract parity with JSON-RPC — an OpenAPI document, module tags, clean operation ids, ProblemDetails, and the Idempotency-Key contract, from Microsoft.AspNetCore.OpenApi.
The JSON-RPC transport ships a build-time schema and a typed
TypeScript client. Elarion.AspNetCore.OpenApi gives the HTTP transport
the same story with the tools the REST world already expects: a standard OpenAPI document you can feed to
Swagger UI, Scalar, Kiota, or openapi-typescript. It is a thin, opt-in sibling of Elarion.AspNetCore over
Microsoft.AspNetCore.OpenApi —
Elarion adds only the wiring Microsoft can't (ADR-0026).
Enable it
<ItemGroup>
<PackageReference Include="Elarion.AspNetCore.OpenApi" Version="0.2.3" />
</ItemGroup>builder.Services.AddElarion(builder.Configuration); // contributes the module JSON contexts
builder.Services.AddElarionOpenApi(); // wires OpenAPI over the canonical JSON
var app = builder.Build();
app.MapElarionEndpoints(app.Configuration); // the generated [HttpEndpoint] routes
app.MapOpenApi(); // GET /openapi/v1.jsonThat is the whole runtime setup. AddElarionOpenApi() registers AddOpenApi() plus the Elarion transformers;
MapOpenApi() (from Microsoft.AspNetCore.OpenApi) serves the document.
Expose MapOpenApi() and any UI (Swagger UI, Scalar) in development only — the document describes your whole
API surface. Gate it with if (app.Environment.IsDevelopment()) in production hosts.
What Elarion adds
Because the generator already annotates every [HttpEndpoint] with .Produces<T>(...), the ProblemDetails
.ProducesProblem(...) set, and a name and description, most of the document is correct out of the box. On top
of that AddElarionOpenApi() contributes the parts that make it Elarion's contract:
- Canonical JSON, reflection-free. OpenAPI schema generation (and minimal-API request-body binding) read
ASP.NET's
Microsoft.AspNetCore.Http.Jsonoptions.AddElarionOpenApi()callsAddElarionHttpJson(), which mirrors the canonicalIElarionJsonSerializationconfiguration onto them — the naming knobs and the source-generatedTypeInfoResolverChain— so body schemas resolve through the same contexts every other transport uses, withJsonSerializerIsReflectionEnabledByDefault=false. A DTO missing from every source-gen context throws (surfacing a missing[JsonSerializable]) instead of silently reflecting, and the emitted schema matches the JSON-RPC/MCP wire shape for the same type. - Validation constraints, for free. The DataAnnotations attributes on your request DTOs (see
Validation) appear in the document natively — Microsoft's schema generation maps
[Range],[MinLength]/[MaxLength]/[Length]/[StringLength],[RegularExpression],[Url], and[Base64String]onto the JSON Schema keywords, verified to work reflection-off. Elarion adds a small schema transformer for[EmailAddress]→format: "email"parity, so the OpenAPI document and the JSON-RPC schema never diverge on the same DTO. - Module tags. Each operation is tagged with its owning
[AppModule], so tools group your API by module — the REST analog of JSON-RPC's module grouping. - Clean operation ids. The handler's fully-qualified type name (
Billing.Invoicing.GetInvoice) is normalized to a readableoperationId(GetInvoice), so generated clients get sensible method names. Colliding names keep their original id, so ids stay unique. - The idempotency contract (below).
Idempotency parity
A handler marked [Idempotent] is safe to retry: the same Idempotency-Key
returns the original outcome instead of running again. Over HTTP the server already honors the
Idempotency-Key header (app.UseElarionIdempotencyKey() feeds it to the pipeline). The OpenAPI package makes
that contract discoverable: for every idempotent operation it adds
- an optional
Idempotency-Keyheader parameter, and - an
x-elarion-idempotent: truevendor extension — the OpenAPI analog of the JSON-RPC schema'sidempotentflag, which lets a generated client auto-attach the key (see below).
Non-idempotent operations carry neither, so the document tells a client exactly which calls are safe to retry.
Export the document at build time
For a committed contract (the REST analog of rpc-schema.json), reuse Microsoft's build-time exporter —
Elarion does not ship its own for OpenAPI:
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.9" />
</ItemGroup>
<PropertyGroup>
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)</OpenApiDocumentsDirectory>
</PropertyGroup>dotnet build then emits {ProjectName}.json next to the project. Like Elarion's JSON-RPC schema tool, this
boots the host with an inert server to read runtime metadata.
The build-time launch runs your program up to app.Run(). If the host does side-effecting startup work —
e.g. db.Database.MigrateAsync() — guard it (skip when there's no server request pipeline) or export at
runtime from a running instance via MapOpenApi() instead.
The two build-time exporters are intentionally parallel — both launch the host and dump a contract file. They differ only in surface:
| Concern | JSON-RPC (Elarion.AspNetCore.SchemaGeneration) | OpenAPI (Microsoft.Extensions.ApiDescription.Server) |
|---|---|---|
| Enable | ElarionJsonRpcGenerateSchema=true | OpenApiGenerateDocuments=true |
| Output directory | ElarionJsonRpcSchemaOutputDirectory (default obj/) | OpenApiDocumentsDirectory (default project dir) |
| File name | rpc-schema.json | {ProjectName}.json |
| Mechanism | inert host launch + document cache | inert host launch + document cache |
Generate a typed client
An OpenAPI document works with the whole client-generation ecosystem, so Elarion ships no bespoke HTTP client generator — pick the off-the-shelf tool that fits your stack.
Recommended: @hey-api/openapi-ts. It generates a discoverable SDK grouped by
module — you type sdk. and IntelliSense walks you to sdk.clients.getClient(...), the same ergonomics as the
JSON-RPC client. Elarion emits the module as an OpenAPI tag and a clean operationId, so the grouping lands
on module → operation with no extra work. It is built on fetch (so it runs in the browser and Node, and works
with OpenTelemetry), takes request interceptors for cross-cutting headers, and has an optional Zod plugin —
mirroring the JSON-RPC client's typed + validated feel.
| Tool | Emits | Reach for it when |
|---|---|---|
@hey-api/openapi-ts (recommended) | A discoverable SDK grouped by module (sdk.clients.getClient(...)), fetch-based, optional Zod | You want the JSON-RPC client's .-drill-down over REST, in the browser or Node |
openapi-typescript + openapi-fetch | Types only + a ~6 kB typed fetch wrapper (api.GET("/clients/{id}", …)) | You want the tiniest runtime and no generated method code — the operation is the path string |
| Kiota | A full SDK in C#, TypeScript, Python, Go, Java, … with a single fluent root (client.clients.byClientId(id).get()) | You need a non-TypeScript client, or a first-party Microsoft-supported generator |
Generate
Get the document. Either export it at build time (the section above —
{ProjectName}.json), or fetch it from a running host:
curl http://localhost:5000/openapi/v1.json -o openapi.jsonInstall the generator (dev) and the fetch runtime client:
npm install -D @hey-api/openapi-ts
npm install @hey-api/client-fetchConfigure and generate. Group operations by their tag so the SDK nests by module. Re-run gen:http whenever
the API changes (wire it into CI next to the JSON-RPC gen:rpc step):
// openapi-ts.config.ts
import { defineConfig } from "@hey-api/openapi-ts";
export default defineConfig({
input: "./openapi.json",
output: "./src/generated",
plugins: [
"@hey-api/client-fetch",
"zod", // optional runtime validation, like the JSON-RPC client's Zod schemas
{
name: "@hey-api/sdk",
// Nest by the module tag Elarion emits → sdk.<module>.<operation>.
operations: {
strategy: "byTags",
nesting: (operation) => [operation.tags?.[0] ?? "default", operation.id],
},
},
],
});// package.json
{ "scripts": { "gen:http": "openapi-ts" } }Hey API's operations grouping options move occasionally — treat the config above as a starting point and check
the SDK plugin docs for the current option names. The exact generated
export/nesting names come from your operationIds and tags.
Use
Configure the client's base URL once, then drill in: sdk. lists the modules, each module lists its operations,
and params, body, and response are all typed. Errors come back in the result ({ data, error }) instead of
throwing:
import { client } from "./generated/client.gen";
import { sdk } from "./generated/sdk.gen";
client.setConfig({ baseUrl: "/" });
// sdk.clients.getClient — discovered by typing `sdk.clients.`
const { data, error } = await sdk.clients.getClient({ path: { id } });
if (error) {
// `error` is the typed RFC 7807 ProblemDetails body from ProducesElarionErrors().
console.error(error.title, error.status);
} else {
console.log(data.name); // `data` is the response DTO, fully typed.
}
// A command — the body is typed to the handler's Command.
const created = await sdk.clients.createClient({ body: { name: "Acme", email: "billing@acme.test" } });The client is built on fetch, so the same generated SDK runs in the browser and in Node (18+, on the
global fetch; pass a custom fetch via client.setConfig({ fetch }) for other runtimes). Because the SDK is
generated from the same document your server produces, a renamed field or a changed route is a compile error in
the client — the end-to-end typing the JSON-RPC client gives you, over REST.
Prefer the tiniest possible runtime and don't mind addressing operations by path string? openapi-typescript
openapi-fetchstays a fine choice —createClient<paths>({ baseUrl }).GET("/clients/{id}", { params: { path: { id } } }). It just trades thesdk.drill-down for a ~6 kB library and no generated method code.
Automatic idempotency keys
The JSON-RPC client auto-attaches an idempotency key for [Idempotent] methods. Reproduce that over HTTP with a
request interceptor that keys off the x-elarion-idempotent extension. First emit the idempotent operations
from the document (a ~15-line build step — read each operation, keep the {method, path-template} of those whose
x-elarion-idempotent is true, turning each template into a RegExp), then register the interceptor once:
// idempotency.ts
import { client } from "./generated/client.gen";
// [{ method, pattern }] where the OpenAPI operation carries x-elarion-idempotent: true.
import { idempotentOperations } from "./generated/idempotent-operations";
const newKey = () => crypto.randomUUID();
client.interceptors.request.use((request) => {
const path = new URL(request.url).pathname;
const idempotent = idempotentOperations.some(
(op) => op.method === request.method && op.pattern.test(path),
);
if (idempotent && !request.headers.has("Idempotency-Key")) {
request.headers.set("Idempotency-Key", newKey());
}
return request;
});That gives an idempotent HTTP call the same exactly-once-on-retry behavior as the JSON-RPC client — a key attached
by default, or pass your own via a per-call header to reuse it across your retries — with the Idempotency-Key
header that is idiomatic for HTTP rather than JSON-RPC's params._meta.
Trace client requests with OpenTelemetry
A generated client call should appear in the same distributed trace as the server work it triggers. The server
already does its part: an ASP.NET Core host running OpenTelemetry with AddAspNetCoreInstrumentation() extracts
the W3C traceparent header from the incoming request, so the REST endpoint and the handler-pipeline spans nest
under whatever client span sent it (see Telemetry & observability). The
client's job is to create that span and inject the header.
Because the hey-api client is built on fetch, the simplest path is to instrument fetch itself — every request
gets a client span and a traceparent, with no per-call code. In the browser use
@opentelemetry/instrumentation-fetch; in
Node (18+) the global fetch is undici-based, so use
@opentelemetry/instrumentation-undici
instead. The browser setup:
import { WebTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-web";
import { ZoneContextManager } from "@opentelemetry/context-zone";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
const provider = new WebTracerProvider({
spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: "/v1/traces" }))],
});
provider.register({ contextManager: new ZoneContextManager() });
registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
// Send the trace header to your API origin. Cross-origin also needs the server's CORS to allow it.
propagateTraceHeaderCorsUrls: [/./],
}),
],
});Prefer to own propagation (or skip the fetch auto-instrumentation)? Inject the active context yourself with a
request interceptor — it composes with the idempotency interceptor above and works the same in the browser and
Node:
import { context, propagation } from "@opentelemetry/api";
import { client } from "./generated/client.gen";
client.interceptors.request.use((request) => {
propagation.inject(context.active(), request.headers, { set: (h, k, v) => h.set(k, v) });
return request;
});The JSON-RPC client carries a dedicated RpcInstrumentation hook instead of relying on fetch
instrumentation — see
TypeScript client › Tracing with OpenTelemetry.
HTTP or JSON-RPC?
Both remain first-class — the choice is per-handler, and a handler can expose both. Reach for OpenAPI when you serve third-party or public consumers who expect REST + Swagger tooling; reach for the JSON-RPC client when one team owns both ends and wants an operation-shaped, end-to-end-typed contract. With this package, neither transport is the "typed" one and the other the "loose" one — both carry a machine-readable contract.
HTTP endpoints
Mark a handler with [HttpEndpoint] and Elarion generates the minimal-API MapGet/MapPost mapping — unwrapping the Query/Command and mapping AppError to RFC 7807 status codes.
JSON-RPC
JSON-RPC is a first-class optional transport — mark a handler with [Handler] and Elarion takes it from dispatcher to typed TypeScript client.