Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0a1ad02
feat(headers): add typed content-type header
namzug16 Sep 10, 2026
dfe3d7e
feat(forms): add form data model types
namzug16 Sep 10, 2026
8e2c8c6
feat(forms): parse urlencoded request bodies
namzug16 Sep 10, 2026
e8b7d45
feat(forms): stream multipart request parts
namzug16 Sep 10, 2026
121f14c
feat(forms): aggregate multipart form data
namzug16 Sep 10, 2026
6561990
feat(forms): add temp upload storage
namzug16 Sep 10, 2026
db52986
feat(forms): map form parsing errors
namzug16 Sep 10, 2026
1715349
doc(forms): add forms example
namzug16 Sep 11, 2026
923b420
fix(forms): sanitize multipart filenames
namzug16 Sep 11, 2026
2ccd371
fix(headers)!: Parse extended parameters as UTF-8 RFC 8187 ext-values
nielsenko Sep 25, 2026
90893d8
fix(forms): Map charset decode failures to MalformedFormDataException
nielsenko Sep 25, 2026
a64e595
fix(io): Apply backpressure when writing temp uploads
nielsenko Sep 25, 2026
a84d3d8
feat(forms): Give FormLimits parameter defaults and copyWith
nielsenko Sep 25, 2026
dda0dd6
refactor(forms): Trim the UploadedFile API
nielsenko Sep 25, 2026
5fb93af
refactor(forms): Model multipart parts as a sealed MultipartPart
nielsenko Sep 25, 2026
b3202d7
fix(forms): Drop dot segments and control chars from upload filenames
nielsenko Sep 25, 2026
8e4104b
fix(forms): Treat parts with an empty filename as file parts
nielsenko Sep 25, 2026
7e097cf
feat(body)!: Carry Content-Type parameters in BodyType
nielsenko Sep 25, 2026
94f9f2b
refactor: Make Body the only source of Content-Type
nielsenko Sep 25, 2026
2268b48
fix(io): Write each temp upload to its own private directory
nielsenko Sep 25, 2026
274f098
fix(forms): Close the connection after a form error response
nielsenko Sep 25, 2026
c2827fb
feat(forms): Read form fields and files through accessors
nielsenko Sep 25, 2026
16a45cf
refactor(forms): Name the exceeded form limit with a FormLimit enum
nielsenko Sep 25, 2026
62fa638
refactor(forms): Describe part and upload content types with BodyType
nielsenko Sep 25, 2026
9e88392
fix(forms): Fail the open multipart part when the body ends early
nielsenko Sep 28, 2026
ed48787
fix(forms): Fail multipart parsing when a part header does not parse
nielsenko Sep 28, 2026
0d1f0f9
fix(forms): Accept field and file names beyond Latin-1
nielsenko Sep 28, 2026
6ae1f24
fix(forms): Ignore filename* in multipart parts
nielsenko Sep 28, 2026
da4a005
perf(forms): Store in-memory uploads without copying them
nielsenko Sep 29, 2026
c1b9c46
fix(io): Restrict each temp upload directory to its owner
nielsenko Sep 30, 2026
e61b2d9
fix: Bump min sdk to ^3.10.0 as Dart 3.9.0 compiler crashes in the FF…
nielsenko Sep 30, 2026
0d59901
chore: Tighten deps after bump
nielsenko Sep 30, 2026
5a7a2f2
ci: Make codecov project and patch statuses informational
nielsenko Sep 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:

env:
PUB_CACHE_PATH: ~/.pub-cache
LOWEST_DART_SDK: "3.9.0"
LOWEST_DART_SDK: "3.10.0"

jobs:
analyze:
Expand All @@ -22,7 +22,7 @@ jobs:
dart_sdk:
# ${{ env.LOWEST_DART_SDK }} won't work at job level as env context not available for strategy ¯\_(ツ)_/¯
# (see https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#context-availability)
- "3.9.0"
- "3.10.0"
- stable
steps:
- name: Checkout Code
Expand Down Expand Up @@ -109,12 +109,12 @@ jobs:
unit_tests:
name: Run Unit Tests
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.dart_sdk != '3.9.0' }} # env context not available for continue-on-error
continue-on-error: ${{ matrix.dart_sdk != '3.10.0' }} # env context not available for continue-on-error
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
dart_sdk: ["3.9.0", stable, beta] # env context not available for strategy
dart_sdk: ["3.10.0", stable, beta] # env context not available for strategy
deps: [downgrade, upgrade]
steps:
- name: Checkout Code
Expand Down Expand Up @@ -149,30 +149,30 @@ jobs:
run: melos run test --no-select -- --reporter=failures-only

- name: Run Tests With Coverage
if: ${{ matrix.os == 'ubuntu-latest' && matrix.dart_sdk == '3.9.0' && matrix.deps == 'upgrade' }}
if: ${{ matrix.os == 'ubuntu-latest' && matrix.dart_sdk == '3.10.0' && matrix.deps == 'upgrade' }}
run: |
dart pub global activate coverage
melos run coverage --no-select

- name: Upload Coverage (relic_core)
uses: codecov/codecov-action@v7
if: ${{ matrix.os == 'ubuntu-latest' && matrix.dart_sdk == '3.9.0' && matrix.deps == 'upgrade' && !cancelled() }}
if: ${{ matrix.os == 'ubuntu-latest' && matrix.dart_sdk == '3.10.0' && matrix.deps == 'upgrade' && !cancelled() }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/relic_core/coverage/lcov.info
flags: relic_core

- name: Upload Coverage (relic_io)
uses: codecov/codecov-action@v7
if: ${{ matrix.os == 'ubuntu-latest' && matrix.dart_sdk == '3.9.0' && matrix.deps == 'upgrade' && !cancelled() }}
if: ${{ matrix.os == 'ubuntu-latest' && matrix.dart_sdk == '3.10.0' && matrix.deps == 'upgrade' && !cancelled() }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/relic_io/coverage/lcov.info
flags: relic_io

- name: Upload Coverage (relic)
uses: codecov/codecov-action@v7
if: ${{ matrix.os == 'ubuntu-latest' && matrix.dart_sdk == '3.9.0' && matrix.deps == 'upgrade' && !cancelled() }}
if: ${{ matrix.os == 'ubuntu-latest' && matrix.dart_sdk == '3.10.0' && matrix.deps == 'upgrade' && !cancelled() }}
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: packages/relic/coverage/lcov.info
Expand Down
2 changes: 2 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ coverage:
project:
default:
target: auto
informational: true
paths:
- "packages/relic_core/**"
- "packages/relic_io/**"
- "packages/relic/**"
patch:
default:
target: auto
informational: true
paths:
- "packages/relic_core/**"
- "packages/relic_io/**"
Expand Down
6 changes: 3 additions & 3 deletions packages/benchmark/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ description: Benchmarks for Relic router.
publish_to: none

environment:
sdk: ^3.9.0
sdk: ^3.10.0

resolution: workspace

dependencies:
benchmark_harness: ^2.3.1
cli_tools: ">=0.11.0 <0.14.0"
config: ">=0.8.3 <0.10.0"
config: ">=0.8.4 <0.10.0"
git: ^2.2.1
path: ^1.8.4
path: ^1.9.0
relic: ^2.0.0-rc.1
routingkit: ^5.1.2
spanner: ^1.0.5
250 changes: 250 additions & 0 deletions packages/examples/basic/forms.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import 'dart:convert';
import 'dart:io';

import 'package:relic/relic.dart';

Future<void> main() async {
final uploadDir = await Directory.systemTemp.createTemp(
'relic_form_example_',
);

final app = RelicApp()
..get('/', _home)
..post('/simple', _simpleForm)
..post('/multiple-values', _multipleValues)
..post('/upload', (final req) => _upload(req, uploadDir))
..post('/limited-upload', (final req) => _limitedUpload(req, uploadDir))
..post('/stream-upload', _streamUpload);

final server = await app.serve();
stdout.writeln('Serving form example at http://localhost:${server.port}');
stdout.writeln('Temp upload directory: ${uploadDir.path}');
}

Response _home(final Request req) {
return _page(title: 'Relic form example');
}

Future<Response> _simpleForm(final Request req) async {
try {
final form = await req.urlEncodedForm();
return _resultPage(req, _formResult(form));
} on FormException catch (error) {
return _formError(req, error);
}
}

Future<Response> _multipleValues(final Request req) async {
try {
final form = await req.formData();
return _resultPage(req, _formResult(form));
} on FormException catch (error) {
return _formError(req, error);
}
}

Future<Response> _upload(final Request req, final Directory uploadDir) async {
MultipartFormData? form;
try {
form = await req.multipartForm(
uploadStorage: TempUploadStorage(directory: uploadDir),
);
return _resultPage(req, _formResult(form));
} on FormException catch (error) {
return _formError(req, error);
} finally {
await form?.dispose();
}
}

Future<Response> _limitedUpload(
final Request req,
final Directory uploadDir,
) async {
MultipartFormData? form;
try {
form = await req.multipartForm(
limits: const FormLimits(
maxBodySize: 32 * 1024,
maxFieldCount: 8,
maxFileCount: 1,
maxPartCount: 8,
maxFieldSize: 64,
maxFileSize: 1024,
maxTotalFileSize: 1024,
),
uploadStorage: TempUploadStorage(directory: uploadDir),
);
return _resultPage(req, _formResult(form));
} on FormException catch (error) {
return _formError(req, error);
} finally {
await form?.dispose();
}
}

Future<Response> _streamUpload(final Request req) async {
try {
final lines = <String>[];

await for (final part in req.multipart()) {
switch (part) {
case MultipartFieldPart(:final name):
lines.add('field $name: ${await part.readAsString()}');

case MultipartFilePart(:final name, :final filename):
var bytes = 0;
await for (final chunk in part.body.read()) {
bytes += chunk.length;
}
lines.add(
'file $name: filename=${filename ?? ''}, '
'content-type=${part.body.bodyType?.mimeType ?? ''}, bytes=$bytes',
);

case MultipartOtherPart():
await part.discard();
}
}

return _resultPage(req, lines.join('\n'));
} on FormException catch (error) {
return _formError(req, error);
}
}

Response _formError(final Request req, final FormException error) {
return _resultPage(
req,
'FormException\nstatus: ${error.statusCode}\nmessage: ${error.message}',
statusCode: error.statusCode,
);
}

Response _resultPage(
final Request req,
final String body, {
final int statusCode = 200,
}) {
final text =
'''
method: ${req.method.name}
path: ${req.url.path}
content-type: ${req.mimeType ?? ''}

$body
''';
return _page(title: 'What we got', result: text, statusCode: statusCode);
}

Response _page({
required final String title,
final String result = '',
final int statusCode = 200,
}) {
return Response(
statusCode,
body: Body.fromString('''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${_escape(title)}</title>
<style>${_css()}</style>
</head>
<body>
<h1>${_escape(title)}</h1>
${result.isEmpty ? '' : '<h2>Request result</h2><pre>${_escape(result)}</pre><p><a href="/">Back</a></p>'}
${result.isEmpty ? _forms() : ''}
</body>
</html>''', mimeType: MimeType.html),
);
}

String _forms() {
return '''
<section>
<form method="post" action="/simple">
<h2>Simple urlencoded form</h2>
<p>Uses <code>request.urlEncodedForm()</code>.</p>
<label>Name <input name="name" value="Nova"></label>
<label>Email <input type="email" name="email" value="nova@example.com"></label>
<label>Message <textarea name="message">Hello from a normal HTML form.</textarea></label>
<button>Submit simple form</button>
</form>

<form method="post" action="/multiple-values">
<h2>Repeated values</h2>
<p>Uses <code>request.formData()</code>.</p>
<label><input type="checkbox" name="color" value="red" checked> Red</label>
<label><input type="checkbox" name="color" value="green" checked> Green</label>
<label><input type="checkbox" name="color" value="blue"> Blue</label>
<label><input type="checkbox" name="newsletter" value="yes" checked> Newsletter</label>
<button>Submit repeated values</button>
</form>

<form method="post" action="/upload" enctype="multipart/form-data">
<h2>Multipart upload</h2>
<p>Uses <code>request.multipartForm(uploadStorage: TempUploadStorage(...))</code>.</p>
<label>Display name <input name="displayName" value="Nova"></label>
<label>Bio <textarea name="bio">I like small server-rendered apps.</textarea></label>
<label>Avatar/text file <input type="file" name="avatar"></label>
<button>Upload with temp storage</button>
</form>

<form method="post" action="/limited-upload" enctype="multipart/form-data">
<h2>Intentional limit errors</h2>
<p>The file limit is 1 KiB and field limit is 64 bytes. Upload a larger file to see a 413 form error.</p>
<label>Small note <textarea name="note">Keep this short.</textarea></label>
<label>Limited file <input type="file" name="limitedFile"></label>
<button>Test limits</button>
</form>

<form method="post" action="/stream-upload" enctype="multipart/form-data">
<h2>Streaming upload</h2>
<p>Uses <code>request.multipart()</code> and counts bytes without aggregating files.</p>
<label>Any file <input type="file" name="streamedFile"></label>
<button>Stream file</button>
</form>

<form method="post" action="/upload">
<h2>Wrong content type error</h2>
<p>This posts urlencoded data to a route expecting multipart, producing a 415 error.</p>
<label>Value <input name="value" value="not multipart"></label>
<button>Trigger 415</button>
</form>
</section>
''';
}

String _formResult(final FormData form) {
final lines = <String>[];
for (final entry in form.entries) {
switch (entry) {
case FormFieldEntry():
lines.add('field ${entry.name}: ${entry.value}');
case FileFieldEntry():
final file = entry.file;
lines.add(
'file ${entry.name}: filename=${file.filename ?? ''}, '
'content-type=${file.bodyType?.mimeType ?? ''}, '
'size=${file.size ?? ''}',
);
}
}
return lines.isEmpty ? '(no fields or files)' : lines.join('\n');
}

String _escape(final Object? value) {
return const HtmlEscape().convert(value?.toString() ?? '');
}

String _css() {
return '''
body { font-family: sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
form, pre { border: 1px solid #ccc; padding: 1rem; margin: 1rem 0; }
label { display: block; margin: .5rem 0; }
textarea { width: 100%; min-height: 5rem; }
input[type="text"], input[type="email"] { width: 100%; }
''';
}
2 changes: 1 addition & 1 deletion packages/examples/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ description: Examples for Relic web server framework.
publish_to: none

environment:
sdk: ^3.9.0
sdk: ^3.10.0

resolution: workspace

Expand Down
8 changes: 4 additions & 4 deletions packages/relic/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ topics:
- http

environment:
sdk: ^3.9.0
sdk: ^3.10.0

resolution: workspace

Expand All @@ -23,12 +23,12 @@ dev_dependencies:
http: ^1.5.0
http_parser: ^4.0.2
lints: ^6.0.0
meta: ^1.16.0
meta: ^1.17.0
mime: ^2.0.0
mockito: ^5.4.4
path: ^1.8.3
path: ^1.9.0
serverpod_lints: ">=3.0.0 <5.0.0"
test: ^1.25.10
test: ^1.25.13
test_descriptor: ^2.0.1
test_utils:
path: ../test_utils
Expand Down
Loading
Loading