OpenAPI Round-Trips
When you emit an OpenAPI spec from your C# contracts and later import it back, certain type information would normally be lost — the OpenAPI spec doesn't natively represent branded types, generic templates, or the original record name for file upload inputs.
Rivet solves this with x-rivet-* vendor extensions. These are standard OpenAPI 3.0 extensions — non-Rivet consumers ignore them, the spec stays valid.
What survives a round-trip
| C# construct | Without extensions | With extensions |
|---|---|---|
Email(string Value) (branded VO) | Collapses to string | Preserved as Email(string Value) |
PagedResult<TaskDto> (generic) | Flat PagedResult_TaskDto record | Reconstructed as PagedResult<T> template |
UploadInput(IFormFile Doc, string Title) | Anonymous UploadRequest record | Named UploadInput record (via $ref) |
DateTimeOffset, uint, short, etc. | Falls back to DateTime/int | Preserved via x-rivet-csharp-type |
| Enums, nullable types, arrays, dicts | Already preserved | Already preserved |
| Property metadata (description, etc.) | Lost | Preserved automatically |
Enum member names (in-progress) | PascalCased (InProgress) | Preserved automatically |
| Form-encoded bodies | Converted to JSON | Preserved via .FormEncoded() |
| Void error responses (404 no body) | Dropped | Preserved via .Returns(statusCode) |
| Request/response content examples | Usually dropped or flattened | Preserved via endpoint example metadata |
How it works
Brands
A branded value object like Email(string Value) emits as a component schema with x-rivet-brand:
{
"Email": {
"type": "string",
"x-rivet-brand": "Email"
}
}Properties that reference it use $ref:
{ "email": { "$ref": "#/components/schemas/Email" } }On import, the extension tells the importer to generate a branded record (Email(string Value)) instead of treating it as a plain string alias.
Generics
PagedResult<TaskDto> emits as a monomorphised schema PagedResult_TaskDto with x-rivet-generic:
{
"PagedResult_TaskDto": {
"type": "object",
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/TaskDto" } },
"totalCount": { "type": "number" }
},
"x-rivet-generic": {
"name": "PagedResult",
"typeParams": ["T"],
"args": { "T": "TaskDto" }
}
}
}On import, the importer:
- Groups all schemas with the same
x-rivet-generic.name - Emits one generic record per template (
PagedResult<T>) - Resolves
$refs to monomorphised schemas as generic type strings (PagedResult<TaskDto>)
Multiple instantiations (PagedResult<TaskDto>, PagedResult<UserDto>) produce one shared template.
File uploads
A multipart endpoint with UploadInput(IFormFile Doc, string Title) emits a $ref to the component schema:
{
"requestBody": {
"content": {
"multipart/form-data": {
"schema": { "$ref": "#/components/schemas/UploadInput" }
}
}
}
}The component schema has x-rivet-file on file properties:
{
"UploadInput": {
"type": "object",
"properties": {
"doc": { "type": "string", "format": "binary", "x-rivet-file": true },
"title": { "type": "string" }
}
}
}- The
$refpreserves the record name directly (the importer reads it from the ref path) x-rivet-filemarks file properties as a fallback signal alongsideformat: binary
Double round-trips
The extensions are idempotent. C# → OpenAPI → C# → OpenAPI → C# produces the same types as a single round-trip. This is tested in OpenApiRoundTripTests.Double_RoundTrip_Is_Stable.
What also survives
These are handled transparently — no user action required:
- Property metadata —
description,default,example,readOnly,writeOnly, and validation constraints are preserved through generated attributes on the C# records (see Metadata Attributes for details) - Type descriptions — schema-level
descriptionis preserved on the generated record - Enum member names — non-PascalCase values (e.g.,
in-progress,my_status) keep their original wire format automatically - Summary / Description — OpenAPI
summaryanddescriptionare preserved as separate.Summary()and.Description()builder calls
These use builder methods you'll see in the generated contracts:
- Form-encoded bodies —
application/x-www-form-urlencodedcontent type is preserved via.FormEncoded() - Void error responses — error responses without a body (e.g., 404 with no content) are preserved via
.Returns(statusCode) - Endpoint/content examples — request and response examples are preserved via
.RequestExampleJson(),.RequestExampleRef(),.ResponseExampleJson(), and.ResponseExampleRef()
Endpoint example round-trips
Rivet treats endpoint/content examples as a separate fidelity category from property-level [RivetExample] metadata.
- Request-body examples round-trip through
TsEndpointDefinition.RequestExamples. - Response-body examples round-trip through
TsResponseType.Examples. - Named examples stay named.
- Ref-backed examples stay ref-backed when Rivet has both the component example id and the resolved JSON payload.
If Rivet has the resolved payload but not enough information to emit a safe component ref, it falls back to inline emission instead of writing a broken $ref. That preserves the example content, but not the original ref shape.
What doesn't survive
- Non-format brands — Only brands emitted by Rivet (which carry
x-rivet-brand) survive. A third-party spec withformat: emailwill still import as a brand, but the name comes from the schema key, not an extension.
All primitive C# types now survive round-trips, including DateTimeOffset, uint, ulong, short, byte, and sbyte. These use x-rivet-csharp-type to preserve the exact type when type+format alone would be ambiguous. Without the extension (e.g. third-party specs), the importer uses format-based defaults: date-time → DateTime, int32 → int, bare integer → long, int64 → long, number → double.
Non-Rivet consumers
The extensions are invisible to tools that don't look for them. Swagger UI, Redocly, API gateways, and other OpenAPI consumers render the spec normally. The x- prefix is the standard mechanism for vendor extensions in OpenAPI 3.0.
Extension reference
See Vendor Extensions for the full specification of each extension.
