Skip to content

Validate input

You’ll validate a sign-up endpoint: built-in field rules, custom validators, a custom field type, a database-dependent check, and query-parameter validation. Every failure produces the same 400 validation_failed envelope with one message per field.

import 'package:gisila/gisila.dart';
class SignUpForm extends Form {
final email = EmailField(name: 'email', required: true, maxLength: 254);
final password = PasswordField(name: 'password', required: true, minLength: 10, maxLength: 128);
final displayName = StringField(name: 'displayName', required: true, minLength: 2, maxLength: 50);
final age = IntField(name: 'age', min: 13, max: 130);
final acceptTerms = BoolField(name: 'acceptTerms', required: true);
@override
List<FormField<Object?>> collectFields() =>
[email, password, displayName, age, acceptTerms];
}

Every field needs a name (the body key). Only declared keys are read; anything else in the body is ignored.

Validators run after parsing and return an error message or null:

final displayName = StringField(
name: 'displayName',
required: true,
minLength: 2,
maxLength: 50,
pattern: RegExp(r'^[\w .-]+$'),
validators: [
(String v) => v.toLowerCase().contains('admin') ? 'This name is reserved' : null,
],
);
final acceptTerms = BoolField(
name: 'acceptTerms',
required: true,
validators: [(bool v) => v ? null : 'You must accept the terms'],
);

For types the built-in fields don’t cover, extend FormField<T> and implement parse:

class CountryField extends FormField<String> {
CountryField({required super.name, super.required});
static const _known = {'ET', 'KE', 'US', 'DE'};
@override
String parse(Object? raw) {
final code = raw is String ? raw.trim().toUpperCase() : null;
if (code == null || !_known.contains(code)) {
throw FormValidationException(errors: {name: 'Unknown country code'});
}
return code;
}
}

Add final country = CountryField(name: 'country', required: true); to the form and to collectFields().

@Post('/signup')
@Public()
Future<User> signUp(SignUpForm form, AccountService accounts) => accounts.signUp(form);

The generated code calls bindForm before your method. JSON, url-encoded and multipart bodies all work with the same form.

5. Check rules that need the database in the service

Section titled “5. Check rules that need the database in the service”

Throw FormValidationException so the client sees the same shape:

class AccountService extends Service {
Future<User> signUp(SignUpForm form) async {
final email = form.email.value!.toLowerCase();
if (await _users.existsByEmail(email)) {
throw FormValidationException(errors: {'email': 'Already registered'});
}
return _users.create(email: email, password: form.password.value!, name: form.displayName.value!);
}
}

Query parameters are typed by their Dart type and coerced; a bad value is a 400 bad_request. Range-check them yourself, or use PageParams for paging:

@Get('/search')
Future<List<User>> search(UserService users, {String? q, int? limit}) async {
if (q == null || q.trim().length < 2) {
throw BadRequest('q must be at least 2 characters', code: 'invalid_query');
}
final page = PageParams(limit: limit, maxLimit: 50);
return users.search(q.trim(), limit: page.limit);
}

Give optional query parameters nullable types and apply defaults in the body; a non-nullable optional parameter with a default value answers 400 when the client leaves it out.

7. Restrict content types and size per route

Section titled “7. Restrict content types and size per route”
@Post('/signup', config: RouteConfig(contentTypes: {'application/json'}, maxBodyBytes: 8 * 1024))

Other content types get 415, larger bodies 413.

Terminal window
curl -s -X POST localhost:8080/signup -H 'content-type: application/json' \
-d '{"email":"bad","password":"short","displayName":"x","acceptTerms":false}'
{"error":{"status":400,"code":"validation_failed","message":"Invalid form data","details":{
"email":"Does not match the expected pattern",
"password":"Must be at least 10 characters",
"displayName":"Must be at least 2 characters",
"acceptTerms":"You must accept the terms",
"country":"This field is required"}}}

In a test:

final res = await client.post('/signup', json: {'email': 'bad'});
expect(res.statusCode, 400);
expect(res.errorCode, 'validation_failed');