Skip to content

feat: Form and multipart - #380

Merged
nielsenko merged 33 commits into
serverpod:mainfrom
namzug16:feat/form_and_multipart
Sep 30, 2026
Merged

nielsenko merged 33 commits into
serverpod:mainfrom
namzug16:feat/form_and_multipart

Conversation

@namzug16

@namzug16 namzug16 commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Description

In this PR I'm adding first class form parsing support to Relic, including URL-encoded forms, multipart form-data streaming, multipart aggregation, upload metadata models, temporary file upload storage (on relic_io), and related tests/examples

This makes it easier for Relic handlers to safely read submitted form fields and uploaded files without manually parsing request bodies, which is pretty useful when developing SSR applications

Related Issues

Pre-Launch Checklist

Please ensure that your PR meets the following requirements before submitting:

  • This update focuses on a single feature or bug fix. (For multiple fixes, please submit separate PRs.)
  • I have read and followed the Dart Style Guide and formatted the code using dart format.
  • I have referenced at least one issue this PR fixes or is related to.
  • I have updated/added relevant documentation (doc comments with ///), ensuring consistency with existing project documentation.
  • I have added new tests to verify the changes.
  • All existing and new tests pass successfully.
  • I have documented any breaking changes below.

Breaking Changes

  • Includes breaking changes.
  • No breaking changes.

Code examples

// Read any supported HTML form:
// - application/x-www-form-urlencoded
// - multipart/form-data
Future<Response> handleForm(Request req) async {
  try {
    //auto detects the form type
    // - application/x-www-form-urlencoded
    // - multipart/form-data
    // if the content-type is neither it throws UnsupportedFormMediaTypeException
    final form = await req.formData();

    // - application/x-www-form-urlencoded
    // final form = await req.urlEncodedForm();

    // - multipart/form-data
    // form = await req.multipartForm(
    //   uploadStorage: TempUploadStorage(directory: uploadDir),
    // );
    // You should call form.dispose() when done so temp files can be cleaned up

    // returns first value or null
    final name = form.fields.get('name');
    // if no email is provided throws a StateError, otherwise it returns the first value
    final email = form.fields.getRequired('email');
    // returns list of values or an empty list
    final tags = form.fields.getAll('tag');

    // example of a file
    // final avatar = form.files.get('avatar');

    return Response.ok(
      Body.fromString('name=$name email=$email tags=$tags'),
    );
  } on FormException catch (error) {
    return Response(
      error.statusCode,
      body: Body.fromString(error.message),
    );
  } finally {
    // await form?.dispose();
  }
}


// Stream multipart parts without aggregating the whole form.
Future<Response> handleStreamingUpload(Request req) async {
  final lines = <String>[];

  await for (final part in req.multipart()) {
    if (part.isField) {
      lines.add('field ${part.name}: ${await part.readAsString()}');
      continue;
    }

    if (part.isFile) {
      var bytes = 0;
      await for (final chunk in part.body.read()) {
        bytes += chunk.length;
      }

      lines.add(
        'file ${part.name}: filename=${part.filename}, bytes=$bytes',
      );
      continue;
    }

    await part.discard();
  }

  return Response.ok(Body.fromString(lines.join('\n')));
}

// Apply custom limits.
final form = await req.multipartForm(
  limits: const FormLimits(
    maxBodySize: 32 * 1024,
    maxFieldCount: 8,
    maxFileCount: 1,
    maxPartCount: 8,
    maxFieldSize: 64,
    maxFileSize: 1024,
    maxTotalFileSize: 1024,
    maxPartHeaderSize: 8 * 1024,
    maxBoundarySize: 200,
  ),
);

@coderabbitai

coderabbitai Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 14c0cacf-5d65-4f9e-a136-352e454d37e7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@namzug16

Copy link
Copy Markdown
Contributor Author

Hey @nielsenko, could you please review this PR? it is connected to the issue #379

Thank you!

PS. I haven't added any docs into the site because I wasn't really if I should do it, or if you guys have any sort of guidelines for new docs in the site

@nielsenko

Copy link
Copy Markdown
Collaborator

@namzug16 Thank you for your contribution - I'll get to it Wednesday next week.

@codecov

codecov Bot commented Sep 11, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.38143% with 58 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.50%. Comparing base (c89a31a) to head (5a7a2f2).

Files with missing lines Patch % Lines
...src/headers/typed/headers/content_type_header.dart 52.77% 17 Missing ⚠️
...elic_io/lib/src/io/upload/temp_upload_storage.dart 83.05% 10 Missing ⚠️
packages/relic_core/lib/src/form/form_data.dart 93.80% 7 Missing ⚠️
...re/lib/src/headers/typed/primitives/ext_value.dart 89.85% 7 Missing ⚠️
...lic_core/lib/src/form/request_form_extensions.dart 96.87% 5 Missing ⚠️
...kages/relic_core/lib/src/body/types/body_type.dart 87.50% 3 Missing ⚠️
...ckages/relic_core/lib/src/form/multipart_part.dart 95.71% 3 Missing ⚠️
...ders/typed/headers/content_disposition_header.dart 92.00% 2 Missing ⚠️
...b/src/headers/typed/primitives/header_scanner.dart 75.00% 2 Missing ⚠️
...e/lib/src/headers/standard_headers_extensions.dart 50.00% 1 Missing ⚠️
... and 1 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #380      +/-   ##
==========================================
- Coverage   92.74%   92.50%   -0.24%     
==========================================
  Files         110      117       +7     
  Lines        4702     5260     +558     
  Branches     2380     2634     +254     
==========================================
+ Hits         4361     4866     +505     
- Misses        341      394      +53     
Flag Coverage Δ
relic_core 92.57% <91.14%> (-0.13%) ⬇️
relic_io 91.96% <84.72%> (-1.16%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@nielsenko

Copy link
Copy Markdown
Collaborator

@namzug16 Looking at this now. Sorry for the delay.

@nielsenko nielsenko changed the title feat: form and multipart feat: Form and multipart Sep 25, 2026
@nielsenko

Copy link
Copy Markdown
Collaborator

@namzug16 Thank you for taking the time to look into this.

I have taken the liberty to suggest some changes.

Typed API

Form fields now support a typed api similar to path and query parameters.

const nameField = StringFormField('name');
const ageField = IntFormField('age');
const avatarFile = FormFile('avatar');

router.post('/profile', (req) async {
  final form = await req.multipartForm(uploadStorage: TempUploadStorage());
  try {
    final name = form.fields.get(nameField); // 400 if missing
    final age = form.fields(ageField); // null if missing, 400 if not an int
    final avatar = form.files.get(avatarFile);
    return Response.ok();
  } finally {
    await form.dispose();
  }
});

Content-Type

Content-Type stays on Body, as in the rest of relic.

  • BodyType.parameters carries parameters such as boundary, and the dart:io adapter reads and writes them.
  • Forms read the media type, charset and boundary from req.body.bodyType. The defaultEncoding argument is gone.
  • headers.contentType is a read-only getter.

This also fixes a bug on main. StaticHandler sent multipart/byteranges responses without their boundary.

Multipart

  • MultipartPart is sealed, with field, file and other parts.
  • A part with filename="", as browsers send for an empty file input, is a file part.
  • Forms ignore filename*, which RFC 7578 forbids.
  • Part headers are read as UTF-8.

Hardening

  • A body that fails or ends inside a part, or a part header that does not parse, is an error. Both used to hang.
  • Bytes that do not decode in the declared charset are a 400.
  • Upload filenames are reduced to a basename, without control, bidi or zero-width characters.
  • Form errors thrown while the body may be unread close the connection.
  • TempUploadStorage applies backpressure and writes each upload to its own private directory.

Breaking changes

  • Content-Disposition extended parameters such as filename* must be UTF-8 RFC 8187 ext-values. Anything else is a FormatException.
  • ContentDispositionParameter.encoding is removed.
  • BodyType has no const constructor.

@nielsenko

nielsenko commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Opened a PR towards your branch here: namzug16#1 with my suggestions

@namzug16

Copy link
Copy Markdown
Contributor Author

Hey @nielsenko, thank you for the suggestions and for taking the time for this PR 🚀🚀

I've merged your PR

namzug16 and others added 18 commits September 30, 2026 10:42
- Add ExtValue for the ext-value grammar, with UTF-8 as the only charset
- Percent-encode every byte outside attr-char in ExtValue.encode
- Type ContentDispositionParameter.language as LanguageTag

BREAKING CHANGE: ContentDispositionParameter has no encoding, and
extended parameters always encode as UTF-8. Pass a LanguageTag as
language. Parsing throws FormatException for a charset other than UTF-8
and for any malformed ext-value.
- Decode form text through decodeFormText in urlEncodedForm,
  MultipartPart.readAsString and multipart form fields
- Write each chunk with RandomAccessFile.writeFrom in TempUploadStorage
- Close the temp file before deleting it when the content fails
- Default every FormLimits constructor parameter to its
  FormLimits.defaults value
- Add FormLimits.copyWith
- Rename UploadedFile.openRead to read
- Remove fieldName from UploadedFile, MemoryUploadedFile and
  TempUploadedFile, which FileFieldEntry.name carries
- Add MultipartFieldPart, MultipartFilePart and MultipartOtherPart
- Move name to MultipartFieldPart and MultipartFilePart
- Make MultipartFilePart.filename non-nullable
- Remove MultipartPart.isField, isFile, name and filename
- Remove C0 and C1 controls, line and paragraph separators and
  bidirectional formatting characters from MultipartFilePart.filename
- Make MultipartFilePart.filename nullable
- Set it to null for ".", ".." and names that end in a separator
- Classify a named form-data part with filename="" as a
  MultipartFilePart with a null filename
- Add MultipartFilePart.hasEmptyFilename
- Leave file parts with an empty filename out of multipartForm
- Add BodyType.parameters, BodyType.parameter and BodyType.validate
- Lowercase parameter names and reject charset in the BodyType
  constructor
- Validate parameter names in BodyType.toHeaderValue
- Add a parameters argument to Body.fromData and Body.fromDataStream
- Carry Content-Type parameters through the dart:io adapter and drop
  the request parameters it cannot write back
- Pass the multipart/byteranges boundary through Body in StaticHandler

BREAKING CHANGE: BodyType has no const constructor. Drop const from
BodyType(...) calls.
- Read the form media type, charset and boundary from Body.bodyType
- Remove defaultEncoding from urlEncodedForm, formData and
  multipartForm
- Remove Headers.contentType and the MutableHeaders.contentType setter
- Rename TempUploadStorage.directoryPrefix to prefix
- Make the TempUploadedFile constructor private
- Replace the per-type form catches in RelicServer with one on
  FormException
- Respond with FormException.statusCode, its message and
  Connection: close
- Add FormField, StringFormField, NumFormField, IntFormField,
  DoubleFormField and FormFile
- Make FormFields and UploadedFiles AccessorState subclasses with
  get, call, tryGet and getAll
- Add MissingFormFieldException and InvalidFormFieldException, whose
  responses keep the connection open
- Remove getRequired and contains from FormFields and UploadedFiles
- Add FormLimit
- Make FormLimitExceededException.limit a FormLimit
- Throw FormLimitExceededException for FormLimits.maxBodySize from
  urlEncodedForm and multipart
- Give each MultipartPart body the part Content-Type as its bodyType
- Take a content stream in the MultipartPart factory
- Remove MultipartPart.contentType
- Replace UploadedFile.contentType and the contentType argument of
  UploadStorage.store with bodyType
- Listen to MimeMultipartTransformer in a guarded zone in _parts
- Map FormatException to MalformedFormDataException
- Add HeaderScannerInternal.utf8, which reads every non-ASCII code
  unit as obs-text
- Parse multipart Content-Disposition with
  ContentDispositionHeaderInternal.parseFormData
- Read MultipartFilePart.filename from the filename parameter only
- Classify a part with only filename* as a MultipartFilePart with a
  null filename
- Keep parameters with a * undecoded, with the * in their name, in
  ContentDispositionHeaderInternal.parseFormData
- Add a copy argument to MemoryUploadedFile, true by default
- Pass copy: false from MemoryUploadStorage
Set each upload directory to mode 0700 with chmod through dart:ffi on
every platform except Windows.
@nielsenko
nielsenko force-pushed the feat/form_and_multipart branch from 4e56831 to 7b57d05 Compare September 30, 2026 09:14
@nielsenko

nielsenko commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

@nielsenko
nielsenko force-pushed the feat/form_and_multipart branch from 7b57d05 to 0d59901 Compare September 30, 2026 09:27

@nielsenko nielsenko left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@nielsenko
nielsenko merged commit ab9d5f4 into serverpod:main Sep 30, 2026
30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Finish moving Content-Type onto Body

2 participants