Skip to content

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.

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);

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(', ')}'}),
);
}
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({
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({
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({
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({
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({
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({
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({
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({
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({
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];
}
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'});
}