Skip to content
Shiny.NET

Uploads & Downloads

This is the one to use for real uploads. Each part’s body is a stream that ends at the boundary, so a 2 GB file goes straight to disk and nothing large is ever held in memory.

app.MapPost("/upload", async ctx =>
{
await foreach (var part in ctx.Request.ReadMultipartAsync(ctx.RequestAborted))
{
if (!part.IsFile)
continue;
var name = part.SafeFileName() ?? "upload.bin";
await part.SaveToAsync(Path.Combine(uploadDirectory, name), ctx.RequestAborted);
}
return Results.NoContent();
});

A MultipartSection gives you Headers, ContentType, Name, FileName, IsFile, the raw Body stream, and ReadAsStringAsync / CopyToAsync / SaveToAsync.

MultipartReader bounds itself: HeadersLengthLimit (16 KB) and PartCountLimit (256) per body.

For a handful of small fields — a login form, a settings post — buffering is simpler:

app.MapPost("/settings", async ctx =>
{
if (!ctx.Request.HasFormContentType())
return Results.BadRequest();
var form = await ctx.Request.ReadFormAsync();
var name = form.GetFirst("name");
var avatar = form.GetFile("avatar"); // FormFile: Content, Length, OpenReadStream, SaveToAsync
return Results.NoContent();
});

ReadFormAsync handles both application/x-www-form-urlencoded and multipart/form-data, and is bounded on purpose: maxValueLength defaults to 64 KB and maxFileSize to 8 MB per file, and exceeding either is a 413. Buffering an upload of unknown size is how a server runs out of memory.

For a PUT-style upload with no multipart wrapper:

var written = await ctx.Request.SaveBodyToAsync(path, ctx.RequestAborted);

Never buffers. ctx.Request.Body is the stream itself, and ctx.Request.BodyReader is the PipeReader for zero-copy parsing.

Every request body is held to the server-wide Limits.MaxRequestBodySize, 30 MB by default and set in configuration. An upload endpoint doesn’t need the whole server raised: give that route its own limit and keep the default small everywhere else.

builder.Options.Limits.MaxRequestBodySize = 1024 * 1024; // 1 MB for everything...
app.MapPost("/images", Upload).WithRequestSizeLimit(500 * 1024 * 1024); // ...except this
app.MapPut("/backups/{name}", Restore).DisableRequestSizeLimit(); // no limit at all

On a generated endpoint, use the attributes:

[Post("/images")]
[RequestSizeLimit(500 * 1024 * 1024)]
public Task<IActionResult> Upload(HttpContext ctx) => ...;
[Put("/backups/{name}")]
[DisableRequestSizeLimit]
public Task<IActionResult> Restore(string name, HttpContext ctx) => ...;

A route’s limit can be higher or lower than the server’s. Routing applies it before authorization or the handler runs, and the body is checked as it is read. A Content-Length over the limit gets a 413 at the first read, before any of the body is accepted, and a chunked or HTTP/2 body without a length gets a 413 as soon as it goes over. Middleware that runs before routing can also set ctx.Request.MaxBodySize itself. Once the body has started to be read, the limit can’t change: ctx.Request.IsMaxBodySizeReadOnly turns true, and setting it throws.

A client that sends Expect: 100-continue only gets the 100 Continue when the handler starts reading the body. A request that is turned away first (unauthorized, unrouted, or with a declared length over the limit) never has its body sent.

Results.File(path) and Results.Stream(...) cover the simple case. FileDownloadResult is what they and the static file middleware are built on, and it is what you want when the response should support resuming:

app.MapGet("/recordings/{id}", ctx =>
FileDownloadResult.FromFile(PathFor(id), downloadName: "recording.mp4"));
Factory For
FromFile(path, …) A file on disk — length, ETag and modification time filled in
FromBytes(content, …) Bytes already in memory
FromStream(stream, length, …) A seekable stream of known length; disposed after writing
FromOpener(open, length, …) A factory opened only if the response is actually written

FromOpener exists because a resolver that tries several candidates — the static file handler looking for default documents — would otherwise open and discard a stream per miss, and a conditional request ending in a 304 opens nothing at all.

Properties: ContentType, FileDownloadName, Inline, ETag, LastModified. Setting a download name sends Content-Disposition: attachment unless Inline is set.

  • Byte ranges, including suffix ranges (Range: bytes=-500), answered with 206 and Content-Range.
  • 416 for a range past the end, rather than a silently truncated body.
  • ETag / Last-Modified conditional GETs — If-None-Match and If-Modified-Since produce a 304 with no body.
  • If-Range, so a stale resume restarts the download instead of splicing two different files together.
  • A content-type table that falls back to application/octet-stream rather than guessing text/html.

ContentTypes.ForFileName(name) is that table, if you want the same answer somewhere else.