Apricot Framework

Uploads

Reading a multipart request section by section, without buffering it.

StreamFilesAsync reads a multipart body one section at a time. Files are handed to a callback as they arrive and never held, so the memory an upload costs does not grow with its size — a request larger than available memory is fine, provided the callback streams rather than collects.

The shape

using ApricotFramework.AspNetCore.Extensions.ModelBinding;
using ApricotFramework.AspNetCore.Extensions.Uploads;

[HttpPost("upload")]
[DisableFormValueModelBinding]
public async Task<IActionResult> Upload(CancellationToken cancellationToken)
{
    var fields = await this.Request.StreamFilesAsync(
        file => blobStore.WriteAsync(file.FileName, file.OpenReadStream(), cancellationToken),
        cancellationToken);

    return this.Ok(new { Title = fields["title"].ToString() });
}

The callback runs once per file section. The return value is the request's non-file fields, in the order they were declared, as an ordinary IFormCollection.

Warning

[DisableFormValueModelBinding] is not optional on an MVC action. Model binding buffers the whole body to build the form collection, and a body cannot be read twice — so without it there is nothing left to stream. The attribute is described on the model binding page.

How large a file can be

This library sets no ceiling on a file. It leaves MultipartReader.BodyLengthLimit unset, and each file is handed over as a stream, so nothing here has to hold one.

The limit you will actually hit is the server's. Kestrel caps a request body at 30 MB (30000000 bytes) by default, and rejects anything larger with 413 Payload Too Large before your callback runs.

builder.WebHost.ConfigureKestrel(options =>
{
    options.Limits.MaxRequestBodySize = 2048L * 1024 * 1024;   // 2 GB, or null for no limit
});

Warning

Limits is not bound from the Kestrel configuration section, unlike Endpoints and Certificates. Setting Kestrel:Limits:MaxRequestBodySize in appsettings.json or as an environment variable is silently ignored; it has to be set in code.

With the cap raised, 1 GB through a single request completes in about a second and process memory does not move — measured, not assumed. What matters is the callback: streaming it onward costs nothing, while CopyToAsync into a MemoryStream reintroduces the whole file.

FormOptions.MultipartBodyLengthLimit, the 128 MB cap you may have met elsewhere, does not apply. It belongs to ReadFormAsync, which this bypasses entirely.

The other limits worth knowing, none of which is about file size:

LimitValueSet by
Request body30 MBKestrel, adjustable above
Per-action override[RequestSizeLimit] / [DisableRequestSizeLimit] on an MVC action
Field value length4 MBthis library
Field count1024this library
Section header length16 KBthe framework's reader
Boundary length128 charactersthis library

Two rules about the file stream

A file is only readable for the duration of its callback. The next section cannot be reached until this one has been consumed, so a reference kept past the callback points at a stream that has already moved on.

Reading the next section drains whatever the callback left, so ignoring a file, or reading only part of it, is safe — the sections after it are still found correctly.

Length is not available

IFormFile.Length reports MultipartFile.UnknownLength, which is -1.

Note

This is inherent, not a shortcut. A multipart section is read forward only over the request body, so nothing knows its size until it has been consumed — the client does not declare per-part lengths. Asking the underlying stream would throw NotSupportedException, which is what this library did before it was ported. Count the bytes as you copy them if you need a total.

Fields

Non-file sections are collected rather than streamed, so unlike files they are bounded — see the table above for the values.

Note

The 4 MB field cap is stricter than the framework. ReadFormAsync does not apply ValueLengthLimit to multipart field values, so it will happily read an 8 MB field into a string; this rejects it. The size of a field is the client's choice, and reading one to its end unconditionally lets a single request decide how much memory to allocate.

A section header limit of 16 KB is also what bounds field names, since a name arrives inside Content-Disposition. A section declaring no name at all is skipped rather than filed under an empty key.

Character encoding comes from each section's own charset, falling back to UTF-8 when it declares none or declares something unparseable.

Binding the fields to a model

using System.Globalization;

var values = fields.ToValueProvider(CultureInfo.InvariantCulture);

ToValueProvider presents the collected fields as an MVC IValueProvider, which is what TryUpdateModelAsync consumes.

Note

The culture is deliberately required. CultureInfo.CurrentCulture matches what MVC's own form value provider uses; CultureInfo.InvariantCulture is usually what you want for fields that came off the wire rather than from a keyboard, so a decimal or a date is not read differently depending on where the server runs.

Errors

ExceptionMeans
InvalidOperationExceptionNot a multipart request, or the body was already consumed
InvalidDataExceptionMalformed body, or a limit above exceeded
IOExceptionThe body ended early, or the connection was lost

An empty or already-consumed body reports the likely cause by name, because reading it as an empty upload — which this library used to do — hides a wiring mistake behind a successful response.

Warning

Every one of these describes a bad request rather than a server fault, but ASP.NET Core turns none of them into a 400 on its own, no more than it does for its own ReadFormAsync. Map them yourself if a malformed upload should not be reported as a 500.

Behaviour worth knowing

Verified against bodies produced by curl, not by this library:

  • A file name containing quotes and semicolons is preserved verbatim; it cannot break out of the header it arrived in.
  • A body containing what looks like its own boundary is read as data, not as a section break.
  • A zero-byte file is a file, and the callback runs for it.
  • Non-ASCII file names and content survive unchanged.

On this page