Elarion

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.json

That 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.Json options. AddElarionOpenApi() calls AddElarionHttpJson(), which mirrors the canonical IElarionJsonSerialization configuration onto them — the naming knobs and the source-generated TypeInfoResolverChain — so body schemas resolve through the same contexts every other transport uses, with JsonSerializerIsReflectionEnabledByDefault=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 readable operationId (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-Key header parameter, and
  • an x-elarion-idempotent: true vendor extension — the OpenAPI analog of the JSON-RPC schema's idempotent flag, 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:

ConcernJSON-RPC (Elarion.AspNetCore.SchemaGeneration)OpenAPI (Microsoft.Extensions.ApiDescription.Server)
EnableElarionJsonRpcGenerateSchema=trueOpenApiGenerateDocuments=true
Output directoryElarionJsonRpcSchemaOutputDirectory (default obj/)OpenApiDocumentsDirectory (default project dir)
File namerpc-schema.json{ProjectName}.json
Mechanisminert host launch + document cacheinert 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.

ToolEmitsReach for it when
@hey-api/openapi-ts (recommended)A discoverable SDK grouped by module (sdk.clients.getClient(...)), fetch-based, optional ZodYou want the JSON-RPC client's .-drill-down over REST, in the browser or Node
openapi-typescript + openapi-fetchTypes 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
KiotaA 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.json

Install the generator (dev) and the fetch runtime client:

npm install -D @hey-api/openapi-ts
npm install @hey-api/client-fetch

Configure 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-fetch stays a fine choice — createClient<paths>({ baseUrl }).GET("/clients/{id}", { params: { path: { id } } }). It just trades the sdk. 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.

On this page