Forms and validation
import 'package:gisila/gisila.dart';Base class for a typed request form. Declare one field per input you accept and list them in collectFields().
abstract class Form { Form(); List<FormField<Object?>> collectFields(); List<FormField<Object?>> get fields; Future<void> bind(Map<String, Object?> data); Map<String, Object?> toMap();}When to use: for any route that takes user input you need to validate: sign-up, login, create and update endpoints. The same form binds JSON, application/x-www-form-urlencoded and multipart/form-data bodies, and every field error is reported in one 400. When the body is a plain DTO that needs no validation, a class with fromJson is simpler (see body parameter types).
Declare a Form subtype as a controller parameter and the generated code binds it with bindForm. The class needs a zero-argument constructor.
| Member | Description |
|---|---|
collectFields() |
Return the declared fields. Called once per instance. |
fields |
The declared fields, unmodifiable, in declaration order. |
bind(data) |
Gives each field data[field.name], parses and validates it, and throws one FormValidationException with every field error. Keys you didn’t declare are ignored. |
toMap() |
The cleaned values as {name: value} for every bound field. Optional fields the client left out appear with a null value. |
Example
class RegisterForm extends Form { final email = EmailField(name: 'email', required: true); final password = PasswordField(name: 'password', required: true); final name = StringField(name: 'name', required: true, maxLength: 100); final age = IntField(name: 'age', min: 13);
@override List<FormField<Object?>> collectFields() => [email, password, name, age];}
@Post('/register')@Public()Future<User> register(RegisterForm form, UserService users) => users.register( email: form.email.value!, password: form.password.value!, name: form.name.value!, age: form.age.value, );A failing request gets:
{"error": {"status": 400, "code": "validation_failed", "message": "Invalid form data", "details": {"email": "This field is required", "age": "Must be ≥ 13"}}}See also: Forms concept, Validate input guide.
bindForm
Section titled “bindForm”Future<F> bindForm<F extends Form>(Request request, F Function() ctor)Reads the request body, decodes it by Content-Type and binds it to a new form.
| Content-Type | Behaviour |
|---|---|
application/json, application/vnd.api+json, text/json |
Decoded; must be a JSON object, otherwise 400. Malformed JSON is a 400. An empty body binds as {}. |
application/x-www-form-urlencoded, multipart/form-data |
Parsed with parseFormData; text fields and files are merged into one map. |
| missing or empty | Binds as {}, so required fields report errors. |
| anything else | BadRequestException (400). |
When to use: the generator calls it for you. Call it by hand in a route that takes a raw Request, or in custom Shelf handlers.
final form = await bindForm(request, RegisterForm.new);FormField
Section titled “FormField”Abstract typed field. T is the parsed type.
abstract class FormField<T> { FormField({ required String name, bool required = false, List<FieldValidator<Never>> validators = const [], });
final String name; final bool required; final List<FieldValidator<Never>> validators;
T? get value; Object? get rawValue; bool get isSet; bool isAbsent(Object? raw); T parse(Object? raw); Future<void> bind(Object? raw);}When to use: use the concrete fields below. Extend FormField<T> for a type they don’t cover (an enum, a money amount, a list of ids): implement parse and throw FormValidationException (or any exception, whose toString() becomes the message) on bad input.
| Member | Description |
|---|---|
name |
Key read from the body. Required on every field. |
required |
When true, an absent value is the error This field is required. |
validators |
Run after parse, in order; the first non-null message wins. Pass a List<FieldValidator<T>>. |
value |
The parsed value; null when absent and optional, or before binding. |
rawValue |
value as Object?. |
isSet |
true once bound, even if the value is null. |
isAbsent(raw) |
null and '' count as absent by default (HTML forms send empty strings). |
parse(raw) |
Turn the raw body value into T. |
bind(raw) |
Absent check, parse, validators. Called by Form.bind. |
enum Plan { free, pro }
class PlanField extends FormField<Plan> { PlanField({required super.name, super.required});
@override Plan parse(Object? raw) => Plan.values.firstWhere( (p) => p.name == raw, orElse: () => throw FormValidationException( errors: {name: 'Must be one of ${Plan.values.map((p) => p.name).join(', ')}'}), );}FieldValidator
Section titled “FieldValidator”typedef FieldValidator<T> = String? Function(T value);Returns null when the value is valid, or an error message.
final username = StringField( name: 'username', required: true, validators: [ (String v) => v.contains(' ') ? 'Must not contain spaces' : null, (String v) => reserved.contains(v) ? 'This name is reserved' : null, ],);StringField
Section titled “StringField”StringField({ required String name, bool required = false, List<FieldValidator<String>> validators = const [], int? minLength, int? maxLength, RegExp? pattern, bool trim = true,})Text input. Rejects non-string values (so a JSON number is an error). Trims by default, then checks minLength, maxLength and pattern.
When to use: names, titles, free text, identifiers. Errors: Expected a string value, Must be at least N characters, Must be at most N characters, Does not match the expected pattern.
EmailField
Section titled “EmailField”EmailField({ required String name, bool required = false, List<FieldValidator<String>> validators = const [], int? minLength, int? maxLength, bool trim = true,})A StringField with the pattern ^[^\s@]+@[^\s@]+\.[^\s@]{2,}$. A syntax check only; confirm ownership by sending a mail.
PasswordField
Section titled “PasswordField”PasswordField({ required String name, bool required = false, List<FieldValidator<String>> validators = const [], int minLength = 8, int? maxLength,})A StringField with minLength 8 by default and no trimming. Set a maxLength (for example 128) so clients can’t send megabytes into your password hasher.
IntField
Section titled “IntField”IntField({ required String name, bool required = false, List<FieldValidator<int>> validators = const [], int? min, int? max,})Accepts JSON numbers (doubles are truncated) and numeric strings. Errors: Must be an integer, Must be ≥ min, Must be ≤ max.
DoubleField
Section titled “DoubleField”DoubleField({ required String name, bool required = false, List<FieldValidator<double>> validators = const [], double? min, double? max,})Accepts numbers and numeric strings. Errors: Must be a number, Must be ≥ min, Must be ≤ max. For money, prefer an IntField holding minor units.
BoolField
Section titled “BoolField”BoolField({ required String name, bool required = false, List<FieldValidator<bool>> validators = const [],})Accepts JSON booleans and the strings true/false, 1/0, yes/no, on/off (any case). An unchecked HTML checkbox sends nothing, so the value is null; read it as form.remember.value ?? false.
DateTimeField
Section titled “DateTimeField”DateTimeField({ required String name, bool required = false, List<FieldValidator<DateTime>> validators = const [],})Parses ISO 8601 strings with DateTime.tryParse. Error: Must be an ISO 8601 date/time.
JsonField
Section titled “JsonField”JsonField({ required String name, bool required = false, List<FieldValidator<Object>> validators = const [],})Accepts any JSON value (object, array, number) and only checks that one was given. Validate the structure yourself with validators, or bind a nested form by hand.
FileField
Section titled “FileField”FileField({ required String name, bool required = false, List<FieldValidator<UploadedFile>> validators = const [], Set<String>? allowedContentTypes, int? maxBytes,})A single uploaded file from a multipart/form-data body; value is an UploadedFile. allowedContentTypes checks the MIME type the client declared for the part.
class AvatarForm extends Form { final file = FileField( name: 'file', required: true, allowedContentTypes: {'image/png', 'image/jpeg'}, ); final caption = StringField(name: 'caption', maxLength: 200);
@override List<FormField<Object?>> collectFields() => [file, caption];}FormValidationException
Section titled “FormValidationException”class FormValidationException implements Exception { FormValidationException({ required Map<String, String> errors, String message = 'Invalid form data', }); final String message; final Map<String, String> errors;}Thrown by Form.bind and bindForm with one message per failing field. The framework’s error mapper renders it as 400 with code validation_failed and the errors map as details. It does not mark a database session as failed (ok stays true).
When to use: throw it yourself from a service for checks that need the database (an email that is already taken), so the client gets the same shape as for field errors:
if (await users.emailExists(email)) { throw FormValidationException(errors: {'email': 'Already registered'});}