@olivervea/typespec-csharp
v0.63.0
Published
TypeSpec emitter: an HTTP contract → its C# minimal-API surface (records, response unions, handler interfaces, route mapping, validators, JSON source-gen context) on the Olve.TypeSpec runtime.
Readme
@olivervea/typespec-csharp
A TypeSpec emitter: an HTTP contract → its C# minimal-API surface, on the Olve.TypeSpec runtime. The service compiles the generated files and hand-writes one handler class per operation; the contract is the only place the API is defined.
tsp compile writes three files, named after the service namespace (namespace Widgets; below; the prefix option overrides it):
| File | Contents |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| WidgetsModels.g.cs | Records (request shapes split by @visibility), enums, @discriminated unions, @events unions (IServerSentEvent), and an IValidator<T> per request body with constraints (@minLength, @maxValue, …). |
| WidgetsApi.g.cs | Per operation: a request record, a response union with one variant per declared status, and I…Handler : IOperationHandler<…>. Plus WidgetsOperations (declared statuses), WidgetsApi.HandlerTypes, AddWidgetsApi(), UseWidgetsApi(), MapWidgetsApi(). |
| WidgetsJsonContext.g.cs | A System.Text.Json source-generation context for every DTO (Native AOT ready). |
Install
npm install --save-dev @olivervea/typespec-csharp @typespec/compiler @typespec/http @typespec/openapi3
dotnet add package Olve.TypeSpecConfigure
In tspconfig.yaml:
emit:
- "@typespec/openapi3"
- "@olivervea/typespec-csharp"
options:
"@olivervea/typespec-csharp":
emitter-output-dir: "{project-root}/artifacts/generated"
namespace: "MyService.Api" # default: Generated.Api
prefix: "MyService" # default: the service namespace's name → MyServiceApi, AddMyServiceApi, …
external-types: # spec models the app already has a C# type for
ErrorEnvelope: "MyService.Api.ErrorEnvelope"Generated files belong in a gitignored folder the service's project compiles; Olve.TypeSpec's MSBuild targets run tsp compile before the build (<TypeSpecContract Include="spec/main.tsp" GeneratedDir="…" />, see its README).
What the contract gives you
- Response unions.
op update(...): Widget | NotFound | ConflictbecomesWidgetsUpdateResponsewithOk(Widget),NotFound(ErrorEnvelope)andConflict(ErrorEnvelope)variants. A handler returns one; an undeclared status can't compile. A success body no other variant carries converts implicitly (return widget;). - Several success statuses.
(CreatedResponse & { @body body: Widget }) | (AcceptedResponse & …)isCreatedandAccepted, each writing its own status. - Visibility.
@visibility(Lifecycle.Read)properties leave the request shapes (WidgetCreate,WidgetWritable); the response keeps them. - Discriminated unions (
@discriminated(#{ envelope: "none", discriminatorPropertyName: "kind" })) become an abstract record with[JsonPolymorphic]/[JsonDerivedType]; the discriminator may appear anywhere in a request object. - Parameters bind from path, query and header; string enums by wire value (
?color=red);@query(#{ explode: false })string lists from one comma-separated value. - SSE.
SSEStream<Events>of an@eventsunion streamsServerSentEvent<Events>items: the event name is the variant'stypeliteral, and the emitter refuses a variant whosetypedisagrees with its name. - Files and text. A
bytesbody with a non-JSON content type is aStream, copied to the response as it is read (never buffered); atext/*stringis written as is. A literal content type is a constant; forbytes,@header contentType: stringmakes it aContentTypemember the handler sets. - Response headers. Each
@headerof a response is a member of its variant (optional ones last, null = not sent); string literal headers are constants.@headoperations answer headers only. - Catch-all paths.
@route("{+path}")as the last segment maps to{**path}. OpenAPI can't express reserved expansion (suppress@typespec/openapi3/path-reserved-expansion), so generated clients senda%2Fb; the binding accepts it asa/band refuses a.or..segment (a binding failure). - Raw request bodies. A
bytesbody with a non-JSON content type (@header contentType: "*/*"for any) is the request stream, unread, so a webhook signature over the exact bytes verifies. The handler reads it with its own size limit. - Typed ids (opt-in). A uuid is
Guidunless tagged.@id(Model)on a uuid-format string scalar makes every property and path/query parameter of that scalar Olve.Utilities'Id<T>(nullable and arrays included);Tis the model's generated record, or the C# type given as the second argument. See Typed ids. - Validation. Constraints on a request body become a validator that runs before the handler; failures and binding failures are answered by the app's
IApiErrorWriter, in the contract's terms (the matched operation's declared statuses are at hand).
Unsupported shapes are reported as @olivervea/typespec-csharp/unsupported-type or unsupported-operation warnings and emitted as JsonElement or skipped, never silently mistyped: untagged unions, enveloped discriminated unions, cookie parameters, multipart bodies, status code ranges, array parameters other than explode: false string lists in the query, non-JSON request bodies other than bytes, non-JSON responses other than bytes and text/* string, response headers other than strings, integers and string literals. test/fixtures/unsupported.tsp holds one operation per shape and the snapshot test asserts each diagnostic; none of them is blocked by the design, they are simply not needed yet. Validation covers the top-level request body only.
Typed ids
The emitter's decorators live in its own TypeSpec library; other emitters ignore them. Tag a uuid-format string scalar once:
import "@olivervea/typespec-csharp";
using Olve.CSharp;
@format("uuid")
scalar Uuid extends string;
@id(Character) scalar CharacterId extends Uuid; // Id<Character>
@id(Place, "MyApp.Persistence.PlaceRecord") scalar PlaceId extends Uuid; // Id<MyApp.Persistence.PlaceRecord>- Every property and path/query parameter of
CharacterIdemits asOlve.Utilities.Ids.Id<Character>,?and arrays included. The app references Olve.Utilities. @idcan also tag one property or parameter. To keep the main spec free of C# hints, apply it from a separate file:@@id(CharacterId, Character);.- The wire is unchanged: a uuid string.
@iddoesn't imply the format, so keep@format("uuid")(on a base scalar, as above) for OpenAPI and other clients. A value that isn't a uuid is a binding failure. @idon anything but a uuid-format string is an error (id-not-uuid):Id<T>is backed by aGuid.explode: falsequery lists of ids are reported as unsupported.
Conformance
The emitter is proven once, in this repository: test/emitter.test.js snapshots the output of test/fixtures/widgets.tsp, and tests/Olve.TypeSpec.Conformance (.NET) compiles that output with the runtime, hosts it with trivial handlers and checks every operation and response over HTTP against the fixture's OpenAPI document; tests/Olve.TypeSpec.AotSmoke publishes the same app with Native AOT and checks a few requests against the binary. Services built on it test only their own behaviour.
npm test # snapshots; UPDATE_SNAPSHOTS=1 npm test to accept new output
dotnet test --project tests/Olve.TypeSpec.Conformance # from the repo root
dotnet publish tests/Olve.TypeSpec.AotSmoke -c Release -r linux-x64 -o artifacts/aot-smoke/publish && artifacts/aot-smoke/publish/Olve.TypeSpec.AotSmoke