Handle file uploads
You’ll accept an avatar upload with validation, a multi-file import, a streamed large upload and a raw binary PUT, with size limits on each.
Prerequisites
Section titled “Prerequisites”- A gisila project. Somewhere to put files (local disk here; object storage works the same way).
1. Cap the body size first
Section titled “1. Cap the body size first”Uploads should never be unbounded. Set an app-wide default and raise it only on upload routes:
import 'dart:io';
import 'package:gisila/gisila.dart';
final config = AppConfig(maxRequestBodyBytes: 1024 * 1024); // 1 MiB everywhere@Post('/avatars', config: RouteConfig(maxBodyBytes: 5 * 1024 * 1024))Both caps apply, so the route can only be raised up to the app-wide one: set the app default to your largest route’s limit, and lower it per route where needed. Oversized bodies are refused with 413 while streaming, so nothing over the limit is buffered.
2. A single file with validation: FileField
Section titled “2. A single file with validation: FileField”class AvatarForm extends Form { final file = FileField( name: 'file', required: true, allowedContentTypes: {'image/png', 'image/jpeg', 'image/webp'}, ); final alt = StringField(name: 'alt', maxLength: 200);
@override List<FormField<Object?>> collectFields() => [file, alt];}
@Post('/avatars', config: RouteConfig(maxBodyBytes: 5 * 1024 * 1024))@RequireAuth()Future<Map<String, Object?>> uploadAvatar(AvatarForm form, RequestContext ctx) async { final upload = form.file.value!; final bytes = await upload.readAsBytes(); final path = 'storage/avatars/${ctx.principal!.id}.${upload.contentType.subType}'; await File(path).writeAsBytes(bytes); return {'path': path, 'size': bytes.length, 'alt': form.alt.value};}allowedContentTypes checks the type the client declared. Don’t trust it for anything security-relevant: check magic bytes if it matters, and never use upload.name as a path.
3. One file without a form: UploadedFile
Section titled “3. One file without a form: UploadedFile”A parameter of type UploadedFile reads the multipart field with the same name:
@Post('/attachments')@RequireAuth()Future<Map<String, Object?>> attach(UploadedFile file) async { final bytes = await file.readAsBytes(); return {'filename': file.name, 'size': bytes.length};}A missing field is a 400. Make the parameter nullable (UploadedFile? file) to make it optional.
4. Many files and fields: FormData
Section titled “4. Many files and fields: FormData”@Post('/imports')@Permissions({'data:write'})Future<Map<String, Object?>> importFiles(FormData body, ImportService imports) async { final mode = body.fields['mode'] ?? 'append'; final files = body.files.values.toList(); if (files.isEmpty) throw BadRequest('Attach at least one file', code: 'no_files'); var rows = 0; for (final f in files) { rows += await imports.importCsv(await f.readAsBytes(), mode: mode); } return {'files': files.length, 'rows': rows};}The multipart parser caps bodies at 1000 parts and text fields at 1 MiB each; both are a 400 when exceeded.
5. Stream large files
Section titled “5. Stream large files”readAsBytes() holds the whole file in memory. For large files, pipe the stream instead:
@Post('/videos', config: RouteConfig(maxBodyBytes: 500 * 1024 * 1024))@RequireAuth()Future<Map<String, Object?>> uploadVideo(UploadedFile file) async { final out = File('storage/videos/${DateTime.now().microsecondsSinceEpoch}.mp4').openWrite(); await out.addStream(file.openRead()); await out.close(); return {'ok': true};}Each file’s stream can be read only once.
6. Raw binary bodies
Section titled “6. Raw binary bodies”For a direct PUT of bytes (no multipart), take the Request:
@Put('/blobs/{id}', config: RouteConfig(maxBodyBytes: 50 * 1024 * 1024))@RequireAuth()Future<void> putBlob(String id, Request request) async { final out = File('storage/blobs/$id').openWrite(); await out.addStream(request.read()); await out.close();}Verify it works
Section titled “Verify it works”curl -s -F file=@me.png -F alt='My face' localhost:8080/avatars -H "authorization: Bearer $TOKEN"# {"path":"storage/avatars/42.png","size":48213,"alt":"My face"}
curl -s -F file=@notes.txt localhost:8080/avatars -H "authorization: Bearer $TOKEN"# 400 ... "details":{"file":"Unsupported file type \"text/plain\". Allowed: image/png, image/jpeg, image/webp"}
curl -s -X PUT --data-binary @big.bin -H 'content-type: application/octet-stream' \ localhost:8080/blobs/1 -H "authorization: Bearer $TOKEN" -o /dev/null -w '%{http_code}\n'# 200, or 413 when over the cap