File Uploads & Downloads
Uploads (multipart/form-data)
On controller endpoints, an IFormFile parameter — or a collection of them (List<IFormFile>, IFormFile[], IReadOnlyList<IFormFile>, ...) — makes the operation a multipart/form-data request body; a single file property is emitted as { "type": "string", "format": "binary" } and a collection as an array of binary parts. IFormFile itself is the retained semantic convention; it does not pull neighbouring parameters into the form body. Every other form field must declare its source explicitly: [FromForm] string caption becomes a form field beside the file, [FromRoute]/[FromQuery]/[FromHeader] parameters keep their declared locations, and any unattributed neighbour refuses extraction (RIV1100). A complex type declared [FromForm] beside a file refuses too (RIV1104) — lowering it faithfully would mean emulating MVC's recursive form binder, and a [FromBody] body cannot ride a multipart request at all.
On contracts, a TInput that is IFormFile itself, or a record with IFormFile (or collection-of-IFormFile) properties, is detected as multipart automatically. .AcceptsFile() marks a definition explicitly:
public static readonly RouteDefinition<UploadRequest, UploadResponse> Upload =
Define.Post<UploadRequest, UploadResponse>("/api/files")
.AcceptsFile();Raw binary request bodies
.AcceptsBinary(contentType = "application/octet-stream") declares that the request body is the raw bytes — no multipart envelope, no JSON schema. The canonical case is a chunked upload, where each chunk is PUT as an opaque byte stream:
[RivetType]
public sealed record UploadChunkInput(string Id, int ChunkIndex);
[RivetContract]
public static class ThingsContract
{
public static readonly RouteDefinition<UploadChunkInput, ChunkReceipt> UploadChunk =
Define.Put<UploadChunkInput, ChunkReceipt>("/api/things/{id}/chunks/{chunkIndex}")
.AcceptsBinary();
}What Rivet enforces — and what it deliberately does not:
- Spec-only. Rivet emits the
requestBodyas{ "type": "string", "format": "binary" }under the declared content type. Reading the request stream (HttpContext.Request.Body) is host code — Rivet never binds or buffers the bytes at runtime. TInputlowers to route/query params. Because the body is the raw bytes, the input record's properties never become a JSON body: route-placeholder-matched properties bind to the path (id,chunkIndexabove), the rest become query parameters — the same lowering GET/DELETE inputs get.- Mutually exclusive with other body shapes. Combining
.AcceptsBinary()with.AcceptsFile()or.FormEncoded()throws — at runtime from the builder, and at generation time from the walker.
On import (--from-openapi), any non-multipart request-body content entry whose schema is { "type": "string", "format": "binary" } — whatever the content type (application/octet-stream, audio/mpeg, ...) — scaffolds back as .AcceptsBinary("<content-type>").
TypeScript consumers: openapi-typescript types binary request bodies as string. Pass the actual Blob/ArrayBuffer with a pass-through bodySerializer so the client does not JSON-stringify it:
await client.PUT("/api/things/{id}/chunks/{chunkIndex}", {
params: { path: { id, chunkIndex } },
body: chunk as unknown as string, // Blob — typed as string by openapi-typescript
bodySerializer: (body) => body as unknown as BodyInit, // pass-through
headers: { "Content-Type": "application/octet-stream" },
});Downloads (binary responses)
Define.File declares an endpoint that returns binary content instead of JSON (GET, application/octet-stream unless overridden):
public static readonly FileRouteDefinition Avatar =
Define.File("/api/members/{id}/avatar")
.ProducesFile("image/jpeg")
.QueryAuth(); // auth token as ?token=... for media playersThis emits a 200 response with the given content type and { "type": "string", "format": "binary" }, plus x-rivet-query-auth when .QueryAuth() is used. On non-file definitions, .ProducesFile(contentType) does the same.
.QueryAuth(parameterName = "token") adds a required query parameter for the auth token — for clients (ExoPlayer, HLS.js) that cannot set headers on media segment requests.
Returning files
Run the application operation normally, then construct the response with .File(...). Rivet takes the content type from the contract and provides overloads for byte[], Stream, and an absolute physical path. Input-bearing file definitions first use .Bind(input). Errors use .Error(...) and must be declared via .Returns(...):
[HttpGet("{id}/avatar")]
public async Task<IActionResult> Avatar(Guid id)
{
var content = await store.Load(id);
return MembersContract.Avatar.File(content, "avatar.jpg").ToActionResult();
}.ToActionResult() and .ToResult() are the first-party MVC and minimal-API bridges. Range processing can produce 206; stream contents are never inspected, and handlers that bypass the contract terminals are unchecked. See Runtime Validation.
Choosing a binary response MIME type
When an endpoint can return different file formats, declare each representation:
public static readonly FileRouteDefinition Image = Define.File("/images/{id}")
.ProducesFile("image/png")
.ResponseBinaryContent(200, "image/jpeg");Select the actual representation per response:
return Image.File(bytes, contentType: "image/jpeg").ToActionResult();The selector works with byte arrays, streams, physical paths, and bound file routes. It must match a declared binary success representation (case-insensitive, ignoring parameters such as charset). It does not negotiate Accept or inspect file bytes. Omit it only when exactly one concrete representation is declared; ambiguous and undeclared selections throw RivetContractViolationException.
Files whose type is only known at runtime
Stored uploads can be any allowed type. Declare a media range and pass the concrete type each response serves:
public static readonly FileRouteDefinition<FileInput> Download = Define
.File<FileInput>("/files/{id}")
.ProducesFile("*/*");
return Download.Bind(input).File(stream, file.Name, contentType: file.ContentType).ToActionResult();A declared range (*/*, image/*) admits any concrete type inside it, and that type is what reaches the wire. An exact declaration wins over a range. A range rejects a type outside it, a wildcard or malformed runtime type, and an omitted contentType.
Inline files and sniffing
Every file response carries X-Content-Type-Options: nosniff: the contract states the type, so the browser must not guess another. A download name normally makes the file an attachment. Pass inline: true to show it in the browser and keep the name:
return Download.Bind(input)
.File(stream, file.Name, contentType: file.ContentType, inline: true)
.ToActionResult();Choosing which types are safe to show inline (for example, not image/svg+xml, which runs script) is the application's decision.
