Validation
This page shows how to check request input with typed DTO classes.
Write a class with @Dto(). Ask for it in your controller.
Dox reads the request, checks it, and gives you a typed object.
When the input is wrong, Dox answers 422 with the errors. Your controller does not run.
import 'package:dox_core/dox_core.dart';
part 'create_user.dto.g.dart';
@Dto()
class CreateUser {
@Trim()
@Length(min: 3, max: 50)
final String name;
@Email()
final String email;
@MinLength(8)
final String password;
@Min(18)
final int? age; // nullable, so optional
const CreateUser({
required this.name,
required this.email,
required this.password,
this.age,
});
static const DtoSchema<CreateUser> schema = $CreateUserDto;
}
class UserController {
Future<User> store(CreateUser body) async {
// body is already checked
User user = User()
..name = body.name
..email = body.email
..password = await Hash.makeAsync(body.password);
await user.save();
return user;
}
}
Setup
You do this once per app.
- Make a DTO with
dox make:dto CreateUser. It writeslib/app/dtos/create_user/create_user.dto.dart. - Run the generator:
dox build_runner:build(ordox build_runner:watch). It writes a.dto.g.dartfile next to each DTO, and one list of all DTOs inlib/generated/dtos.g.dart. - Give that list to the app:
import 'package:my_app/generated/dtos.g.dart';
AppConfig appConfig = AppConfig(
dtos: dtos, // from lib/generated/dtos.g.dart
// ...
);
Dox uses no reflection, so it needs this list to find your DTOs. Without it, the server stops at start with a clear error.
Use a DTO
Add a parameter with the DTO type. Dox checks the request before your method runs.
Future<User> store(CreateUser body) async { ... }
// with the request and a path param
Future<User> update(DoxRequest req, int id, UpdateUser body) async { ... }
Or check inside the method. req.validate<T>() returns the object, or throws the 422 error.
See Controller: parameters for the full rules.
Required and optional
The Dart type decides. You do not write required or nullable.
| Field | Key missing | Value null |
|---|---|---|
final String name; |
error required |
error required |
final int? age; |
null |
null |
final Role role; with this.role = Role.user |
Role.user |
error required |
- Rules on an optional field run only when it has a value.
- A default value is used only when the key is missing. The rules do not check it.
- An empty string counts as missing. So a required
Stringrejects"". Add@AllowEmpty()to keep"". - To read
""asnullinstead (like Laravel'sConvertEmptyStringsToNull), use@Dto(emptyStringsToNull: true). Then{"bio": ""}clears aMaybe<String?>field. A required field still getsrequired. - On a query field,
@OrDefault()turns a bad value into the default, with no error:@OrDefault() @NotIn(<int>[0]) final int page;withthis.page = 1in the constructor readspage=abcandpage=0as1. The field needs a default in the constructor or a nullable type.@OrDefault()only covers type errors and the normal rules of the field. Errors from@Unique,@Existsandvalidate()are not dropped: they still fail the request.
Missing or null? (for PATCH)
A T? field gives null for both. To know the difference, use Maybe<T>:
@Length(max: 500)
final Maybe<String?> bio; // default: const Maybe.missing()
// in the controller
if (body.bio.isPresent) {
user.bio = body.bio.value; // can be null: the client cleared it
}
Types
| Dart type | JSON | Query and form data |
|---|---|---|
String, int, double, num, bool |
the same JSON type | text, converted |
DateTime |
an ISO 8601 string | the same |
an enum |
the name, like "admin" |
the same |
RequestFile |
not allowed | a file part |
a @Dto class |
an object | bracket keys: address[city] |
List<T> |
an array | tags=a&tags=b or tags[]=a |
Map<String, T> |
an object | bracket keys: meta[color] |
JSON is strict: "18" is not an int. Query and form data are text, so Dox converts them.
Every detail is in Types in detail.
Where the data comes from
| Request | Dox reads |
|---|---|
GET, HEAD, DELETE |
the query string |
POST, PUT, PATCH |
the body (JSON, form or multipart) |
Dox reads one source. It never mixes the query and the body. To choose the source yourself:
@Dto(source: Source.query) // on the DTO
class SearchUsers { ... }
await req.validate<SearchUsers>(source: Source.query); // for one call
Path params are not part of a DTO. Ask for them as controller parameters, like int id.
Keys
The input key is the field name in snake_case: firstName reads first_name.
final String firstName; // key: first_name
@Name('emailAddress')
final String email; // key: emailAddress
@Dto(keyCase: KeyCase.field)uses the field names as they are.- Unknown keys are dropped. With
@Dto(strict: true), an unknown key is an error.
Rules
Put rules on a field as annotations. They run from top to bottom. The first rule that fails gives the error.
| Kind | Annotations |
|---|---|
| Text | @Length, @MinLength, @MaxLength, @Email, @Url, @Uuid, @Ip, @Regex, @In, @NotIn, @StartsWith, @EndsWith, @Alpha, @AlphaNumeric, @AlphaDash |
| Change text | @Trim, @Lowercase, @Uppercase, @AllowEmpty |
| Numbers | @Min, @Max, @Range, @Positive, @GreaterThan, @LessThan, @In, @NotIn |
| Lists | @MinItems, @MaxItems, @UniqueItems |
| Dates | @After, @Before |
| Files | @MaxSize, @MinSize, @Mimes, @MimeTypes, @Image, @Dimensions |
| Database | @Unique, @Exists |
Every rule, with its error code and message, is in Validation rules.
- A rule on a list checks each item:
@Email()on aList<String>. Only the list rules check the list itself. - For a few allowed values, use a Dart
enum, or@In(<String>['draft', 'published']).
Nested objects and lists
A field can be another @Dto class, or a list of them.
@Dto()
class CreateOrder {
final Address address;
@MinItems(1)
final List<OrderItem> items;
const CreateOrder({required this.address, required this.items});
static const DtoSchema<CreateOrder> schema = $CreateOrderDto;
}
Address and OrderItem are @Dto classes too. An error in a nested value has a path key, like items.1.quantity.
Files
A RequestFile field reads a file from multipart/form-data.
@Image()
@MaxSize('2MB')
@Dimensions(minWidth: 100, maxWidth: 2000)
final RequestFile avatar;
@Mimes(<String>['pdf', 'docx'])
final List<RequestFile> documents;
Dox checks the real file type from its first bytes. It does not trust the file name or the client. To save the file, see File upload.
Checks across fields: validate()
Add a validate(DtoErrors errors) method to the DTO. Dox calls it after all field rules passed.
void validate(DtoErrors errors) {
if (password != passwordConfirmation) {
errors.add('password_confirmation', 'The passwords do not match');
}
}
- The key is the input key (snake_case). Use
_rootfor an error about the whole body. errors.has(key)tells you if a key already has an error.- The method can be
async. Do not throw for bad input. Add an error.
Database rules: @Unique and @Exists
These read the database, so they run last.
They come from dox_orm. Add import 'package:dox_orm/dox_orm.dart'; to the DTO file.
@Unique('users', column: 'email')
final String email;
@Exists('teams') // column defaults to id
final int teamId;
For an update, skip the row you are updating with ignoreParam:
All options are in Validation rules: database. The full order of checks is in The order of checks.
The error response
When a check fails, Dox answers 422 with one message per field:
{
"email": "The email is not a valid email",
"tags.0": "The tags may not be greater than 20 characters",
"items.1.quantity": "The quantity must be greater than 0"
}
The key is the path to the value, with the input keys. List items use the index.
For another error shape, read error.issues of the ValidationException in your ExceptionHandler. Each issue has its dottedPath, its rule code (like min_length), its message and its params.
Messages
Change a message in one of these places. The first one wins.
- On the rule:
@Email(message: 'Please check your email'). - On the DTO, by code or by
key.code. A full dotted path works too, like'address.city.required'(it also reaches a nested DTO). Use*for list items, like'tags.*.required':
@Dto(messages: <String, String>{
'email.required': 'We need your email',
'integer': 'The {field} must be a number',
})
- For the whole app, with a
MessageResolver. Use it for other languages:
class AppMessages extends MessageResolver {
static const Map<String, Map<String, String>> _texts = <String, Map<String, String>>{
'th': <String, String>{'required': 'กรุณากรอก {field}'},
};
@override
String resolve(MessageRequest request) {
String? template = _texts[request.locale]?[request.code]; // locale from Accept-Language
if (template == null) {
return super.resolve(request);
}
return format(template, request.params);
}
}
AppConfig appConfig = AppConfig(
validationMessages: AppMessages(),
// ...
);
Placeholders: {field} (the last key), {value}, and rule values like {min} and {max}.
Custom rules
Extend Rule<T>. T is the field type (or the item type, for a list).
class Slug extends Rule<String> {
const Slug({super.message});
@override
String get name {
return 'slug'; // the error code
}
@override
String get defaultMessage {
return 'The {field} must be a slug';
}
@override
bool check(String value, RuleContext context) {
return _pattern.hasMatch(value);
}
static final RegExp _pattern = RegExp(r'^[a-z0-9]+(-[a-z0-9]+)*$');
}
// use it
@Slug()
final String slug;
- A rule with values: add
finalfields and overrideparams. They become placeholders. context.lookup('other.key')reads another input value.context.sibling('key')reads the raw input of a field next to this one, in the same object. For example,context.sibling('password_confirmation')in a rule onpassword. It givesundefinedwhen the field is missing.- A rule that needs the database: extend
AsyncRule<T>and writecheckAsync.
Dynamic input
When the shape is known only at runtime (like form fields from the database), use a fluent schema:
See Fluent schemas. Use a DTO for everything else.
Testing
A DTO is a normal Dart class. Make one directly to skip the checks:
To test the checks, use safeValidateMap. It never throws:
ParseResult<CreateUser> result = await CreateUser.schema.safeValidateMap(<String, dynamic>{
'name': 'A',
'email': 'not-an-email',
'password': 'secret123',
});
expect(result.success, isFalse);
expect(result.errors['email'], 'The email is not a valid email');
validateMapdoes the same, but returns the object or throws.source: Source.queryreads the map like a query string.params: {'id': '5'}gives path params.@Uniqueand@Existsneed a test database.