Cheat sheet
One page to keep open while you work.
Each section links to its full page.
Project layout
| Path | What is in it |
|---|---|
bin/server.dart |
starts the app: Dox().initialize(), migrations, Dox().startServer() |
bin/migrate.dart |
runs migrations for dox migration:* |
bin/seed.dart |
runs seeders for dox db:seed |
bin/queue.dart |
the queue worker for dox queue:* |
bin/schedule.dart |
the scheduler for dox schedule:* |
bin/openapi.dart |
you add it: writes the OpenAPI files for dox openapi:generate |
lib/config/ |
app.dart (AppConfig), env.dart, database.dart, cors.dart, cache.dart, storage.dart, logger.dart, queue.dart, schedule.dart. You add others, like openapi.dart. |
lib/routes/ |
api.dart, web.dart: one Router class each |
lib/app/http/controllers/ |
<name>.controller.dart |
lib/app/dtos/<name>/ |
<name>.dto.dart |
lib/app/http/middleware/ |
<name>.middleware.dart |
lib/app/serializers/ |
<name>.serializer.dart |
lib/app/models/<name>/ |
<name>.model.dart |
lib/app/jobs/ |
<name>.job.dart |
lib/providers/ |
DoxProvider classes: ORM, storage, queue, schedule. You add others, like auth. |
lib/generated/ |
container.g.dart, openapi.g.dart, jobs.g.dart and dtos.g.dart (an empty list when the app has no DTO). Made by build_runner. Do not edit. |
db/migration/ |
migrations and migrations.dart (the registry) |
db/seeders/, db/factories/ |
seeders (seeders.dart is the registry) and model factories |
See CLI and App config.
CLI
dox create my_app # newest template
dox serve # dev server + build_runner watch
dox build # AOT bundle in build/bundle
dox build_runner:build # make .g.dart files
dox key:generate # APP_KEY in .env
dox env:generate # lib/config/env.dart from .env.example
dox env:check # check the env, exit 1 when bad
dox make:controller Blog [-r]
dox make:middleware Auth
dox make:dto CreateBlog
dox make:serializer Blog
dox make:model Blog -m # model + migration
dox make:migration create_blogs_table [--sql]
dox make:job SendWelcomeEmail
dox migrate # migration:run
dox migrate --seed # then run the seeders
dox migration:rollback --step=1
dox migration:status
dox db:seed [--class=UserSeeder]
dox queue:work # run queued jobs
dox schedule:work # run scheduled tasks
dox openapi:generate [dir]
See CLI.
Routes
class ApiRouter extends Router {
@override
String get prefix => 'api';
@override
List<dynamic> get middleware => <dynamic>[];
@override
void register() {
Route.get('/ping', (DoxRequest req) => 'pong');
}
}
| Code | Does |
|---|---|
Route.get('/blogs', (C c) => c.index) |
controller C from the container. Also post, put, patch, delete, options |
Route.get('/blogs', listBlogs) |
a plain function |
Route.post('/blogs', c.store, body: schema) |
body: is for the docs only. It checks nothing. |
Route.get('/blogs/{id}', c.show) |
path param. show(int id) |
Route.get('/posts/{page?}', c.list) |
optional param. list(int? page) |
Route.get('/docs/{path*}', c.page) |
rest of the path, with / |
Route.get('/me', <dynamic>[AuthMiddleware(), c.me]) |
middleware first, controller last |
Route.match(<String>['GET', 'POST'], '/contact', handler) |
one handler for a list of methods |
Route.any('/webhook', handler) |
one handler for GET, POST, PUT, PATCH and DELETE |
Route.redirect('/home', '/') / Route.permanentRedirect('/old', '/new') |
send a path to another URL. 302 by default, 301 for permanentRedirect. |
Route.get('/users/{id}', c.show).where('id', '[0-9]+') |
the route matches only when the param fits the regex |
Route.fallback((DoxRequest req) { ... }) |
answers a GET or HEAD request that matches no route and no static file. Use it for a 404 page or an SPA. |
globalMiddleware: <dynamic>[const MethodOverride()] |
lets an HTML form reach PUT, PATCH and DELETE routes |
Route.group('/blogs', () { ... }) |
prefix. Dox adds the / between parts. |
Route.middleware(<dynamic>[M()], () { ... }) |
middleware for a group |
Route.domain('api.app.com', () { ... }) |
match by Host |
Route.resource('blogs', BlogController) |
index, create, store, show, edit, update, destroy |
Route.health('/health') |
200 {"status": "ok"}, or 503 when a check fails. See Health check. |
Route.staticFiles('/', 'public') |
serve a folder (CSS, JS, images) when no route matches. maxAge: sets Cache-Control |
Storage.route('files') |
serve a storage disk. Only signed URLs work, unless signed: false (dox_storage) |
Route.enabledCors(() { ... }) / Route.disabledCors(...) |
CORS for some routes |
Route.websocket('echo', (WebSocket s, DoxRequest req) { ... }) |
a raw WebSocket. For rooms and acks, use Socket.IO. |
Controller parameters, by type: DoxRequest, one @Dto class, String/int/double/num/bool for path params (by position), a @DoxModel (loaded by its key), an @Inject() class. Only positional parameters.
In the examples, c.index and the like are methods of a controller C from the container.
Dependency injection
@Inject() // one instance per isolate
class PostService {
PostService(this.posts); // from the container, by type
final PostRepository posts;
}
@Inject(singleton: false) // a new instance each time
class Clock {}
AppConfig(container: registerContainer); // from lib/generated/container.g.dart
Dox().ioc.get<PostService>();
Dox().ioc.override<PostRepository>(FakePostRepository()); // tests
Routers and DoxProvider classes are found without @Inject(). Run dart run build_runner build after a change.
See Dependency injection.
Middleware order: globalMiddleware, router middleware, group, route.
See Routing, Controller, Middleware.
Middleware
class ApiKeyMiddleware implements IDoxMiddleware {
@override
dynamic handle(IDoxRequest req) {
String apiKey = Env.get<String>('API_KEY');
// Hash.equals: constant time, so the time does not leak the key
if (apiKey.isEmpty || !Hash.equals(req.header('X-Api-Key') ?? '', apiKey)) {
return response('no').statusCode(401); // stop here
}
return req; // go on
}
}
| Middleware | Does |
|---|---|
const RequestId() |
X-Request-Id header, req.requestId |
const ETag() |
ETag header, 304 when If-None-Match matches |
RequestTimeout(Duration(seconds: 30)) or Route.get(...).timeout(...) |
503 when the route is too slow |
const ValidateSignature() |
403 unless the url is from Route.signedUrl(...) |
implements IDoxTerminableMiddleware |
terminate(req, res) runs after the answer is sent |
See Middleware and Signed URLs.
Request
| Code | Gives |
|---|---|
await req.validate<CreateBlog>() |
checked DTO, or 422 |
req.input('name') |
one value, query or body (body wins) |
req.all() / req.only(<String>['a', 'b']) |
many values |
req.input('user.name', 'guest') |
dot key, with a default |
req.except(<String>['password']) |
all values but these |
req.has('name') / req.filled('name') |
key is there and not empty (only filled reads dot keys) |
req.exists('name') / req.missing('name') |
key is there (any value) / not there |
req.boolean('remember') / req.integer('page', 1) |
value as bool / int |
req.file('avatar') / req.hasFile('avatar') |
uploaded file |
req.bearerToken() |
token of Authorization: Bearer |
req.path() / req.fullUrl() |
path / full URL with query |
req.pathIs('admin/*') / req.routeIs('admin.*') |
path or route name matches |
req.body / req.query / req.param['id'] |
one source only |
req.header('X-Token') / req.headers |
headers |
req.cookie('token') |
decrypted cookie |
req.ip() / req.scheme() / req.isSecure() |
client info. Needs trustedProxies behind a proxy. |
req.userAgent() / req.host() / req.origin() / req.locale |
more headers |
req.isJson() / req.isFormData() |
content type |
req.auth?.user<User>() |
logged in user (after AuthMiddleware) |
req.auth?.id() / req.auth == null |
user id, or true for a guest |
req.method / req.uri |
method and URI |
Files:
RequestFile file = req.input('avatar');
file.filename; file.extension; file.length; // bytes
String path = await file.store('avatars'); // save before you return (dox_storage)
See Request and Request and response.
Response
| Return | Client gets |
|---|---|
String |
text |
Map, List, model, Pagination, serializer |
JSON |
File |
streamed file, Range supported |
Storage().stream(path) / Storage().download(path) |
file / download |
Stream<List<int>> |
streamed bytes |
throw NotFoundHttpException() |
404 Not Found |
abort(403), abort(409, 'Taken') |
any status |
response(...) |
full control |
return response(<String, dynamic>{'id': 1})
.statusCode(201)
.header('X-Request-Id', 'abc')
.contentType(ContentType.json)
.cache(Duration(minutes: 5))
.cookie(DoxCookie('theme', 'dark', httpOnly: true, secure: true));
response('ok').cookie(DoxCookie('token', ''), setExpire: true); // delete
| Exception | Status |
|---|---|
BadRequestException() |
400 |
UnAuthorizedException() (dox_auth) |
401 |
ForbiddenHttpException() |
403 |
NotFoundHttpException() |
404 |
MethodNotAllowedHttpException(['GET']) |
405 |
PayloadTooLargeException() |
413 |
UnsupportedMediaTypeException() |
415 |
ValidationException() |
422 |
InternalErrorException() |
500 |
HttpStatusException(code) |
any |
See Response and HTTP exceptions.
DTO validation
@Dto()
class CreateBlog {
@Length(min: 3, max: 200)
@Trim()
final String title;
final String? body; // nullable = optional
final Status status; // enum = only its names
const CreateBlog({required this.title, this.body, this.status = Status.draft});
static const DtoSchema<CreateBlog> schema = $CreateBlogDto;
}
Then dox build_runner:build, and dtos: dtos in AppConfig.
| Kind | Annotations |
|---|---|
| Text | @Length(min:, max:), @MinLength(3), @MaxLength(50), @Email(), @Url(), @Uuid(), @Ip(), @Regex(r'..'), @In([..]), @NotIn([..]), @StartsWith('a'), @EndsWith('b'), @Alpha(), @AlphaNumeric(), @AlphaDash() |
| More text | @Json(), @Digits(6), @DigitsBetween(4, 6), @DateFormatted('Y-m-d'), @Password(min: 8, mixedCase: true, numbers: true, symbols: true) |
| Other fields | @Confirmed(), @Same('email'), @RequiredIf('type', 'company'), @RequiredWith(['phone']) |
| Change text | @Trim(), @Lowercase(), @Uppercase(), @AllowEmpty() |
| Numbers | @Min(1), @Max(9), @Range(1, 9), @Positive(), @GreaterThan(0), @LessThan(9), @In, @NotIn |
| Lists | @MinItems(1), @MaxItems(5), @UniqueItems() |
| Dates | @After('2000-01-01'), @Before('now') |
| Files | @MaxSize('2MB'), @MinSize('1KB'), @Mimes(['pdf']), @MimeTypes(['image/png']), @Image(), @Dimensions(minWidth: 100) |
Database (dox_orm) |
@Unique('users', column: 'email', ignoreParam: 'id'), @Exists('teams') |
| Keys | @Name('emailAddress') |
| Docs | @Describe('Shown in OpenAPI and MCP.') (only the JSON Schema description) |
| Class | @Dto(source: Source.query, keyCase: KeyCase.field, strict: true, messages: {...}) |
Cross field check: add void validate(DtoErrors errors) to the DTO. PATCH "missing or null": Maybe<T> with const Maybe.missing().
See Validation and Validation rules.
Model
@DoxModel(table: 'blogs', softDelete: true)
class Blog extends BlogGenerator {
@Column()
String? title;
@Column(name: 'user_id')
int? userId;
@BelongsTo(User)
User? user;
@HasMany(Comment)
List<Comment> comments = <Comment>[];
@override
List<String> get hidden => <String>['secret'];
}
| Code | Does |
|---|---|
await Blog().find(1) / find('slug', 'a') |
one row or null |
await Blog().all() |
every row that matches where |
await Blog().where('status', 'live').get() |
rows that match |
await Blog().where('id', '>', 5).getFirst() |
first match or null |
await Blog().findOrFail(1) / firstOrFail() |
the row, or throws ModelNotFoundException |
await Blog().count() |
int |
await Blog().where('slug', s).hasRows() / hasNoRows() |
bool |
await Blog().pluck('title') / valueOf('title') |
one column of all rows / of the first row |
await Blog().sum('views'), avg, min, max |
one value, null when no row |
await Blog().paginate(currentPage: 1, perPage: 20) |
Pagination<Blog>, rows in data |
await Blog().simplePaginate(currentPage: 1, perPage: 20) |
a page without the total count |
await Blog().cursorPaginate(perPage: 20, cursor: c) |
a page after a cursor, nextCursor |
await Blog().create(<String, dynamic>{'title': 'a'}) |
insert and return the model |
await Blog().firstOrCreate({...}, {...}), firstOrNew, updateOrCreate |
find, or create |
blog.title = 'b'; await blog.save() |
insert or update |
await blog.delete() |
soft delete when softDelete: true. Returns the deleted rows as an int. |
await blog.increment('views') / decrement |
change a number in one query |
await Blog().forceCreate({...}) |
create with every key, ignore fillable |
await Model.unguarded(() async { ... }) |
turn off the fillable check inside the callback |
await blog.sync('tags', <int>[1, 2]) |
many to many: make the links exactly these ids |
await blog.attach('tags', 3) / await blog.detach('tags', 3) |
add / remove links |
await blog.forceDelete() / await blog.restore() |
real delete / undo soft delete |
Blog().withTrash() |
include soft deleted rows |
Blog().onlyTrashed() |
only soft deleted rows |
await blog.touch() |
set updated_at to now, touchQuietly() with no events |
blog.replicate() |
a new unsaved copy of the model |
Blog().preload('comments').get() |
load a relation, one query |
Blog().whereHas('comments').get() / whereDoesntHave / has('comments', '>=', 3) |
filter by a relation |
await blog.$getRelation('user') |
load a relation on one model |
blog.related<Comment>('comments')?.where(...).get() |
relation query |
blog.toJson() / blog.toMap() |
map, hidden left out of toJson() |
Blog().query() |
a new, empty query |
Relations: @HasOne(T), @HasMany(T), @BelongsTo(T), @ManyToMany(T). Polymorphic: @MorphMany(T, 'name'), @MorphOne(T, 'name'), @MorphTo(). Options: foreignKey, localKey, onQuery, eager.
Events: override onCreating(), onCreated(), onSaving(), onUpdated(), onDeleting(), ... or Model.observe<Blog>(BlogObserver()). throw CancelModelEvent('why') in a "before" event stops it. saveQuietly() skips events.
See Model, Events, HasMany, Query by relations.
Query builder
List<Map<String, dynamic>> rows = await QueryBuilder.table('blogs')
.select(<String>['id', 'title'])
.where('status', 'live')
.orWhere('views', '>', 100)
.whereIn('id', <int>[1, 2, 3])
.whereNull('deleted_at')
.join('users', 'users.id', 'blogs.user_id')
.orderBy('id', 'desc')
.limit(10)
.offset(20)
.get();
| Code | Does |
|---|---|
whereNotIn, whereNotNull, orWhereIn, orWhereNull |
more filters |
where('a', null), where('a', '!=', null) |
IS NULL, IS NOT NULL |
whereBetween, whereColumn, whereLike, whereExists (and Not, or forms) |
more filters |
whereDate, whereTime, whereYear, whereMonth, whereDay |
parts of a date |
whereJsonContains('tags', 'a'), whereJsonDoesntContain |
JSON values |
whereIn('id', QueryBuilder.table('post').select('user_id')) |
sub query |
whereGroup((QueryBuilder<Blog> q) { ... }), orWhereGroup, whereNot |
conditions in brackets |
when(condition, (QueryBuilder<Blog> q) { ... }) |
add parts only when true |
whereRaw('lower(name) = @n', <String, dynamic>{'n': 'a'}) |
raw SQL, bound values |
leftJoin, rightJoin, joinRaw |
joins |
joinSub(q, 'alias', 'alias.id', 'blog.id'), leftJoinSub, fromSub(q, 'alias') |
sub queries |
union(q), unionAll(q) |
add the rows of another query |
groupBy('age'), having(...), havingRaw(...) |
groups |
distinct(), selectRaw('count(*) as total') |
distinct rows, raw select |
latest(), oldest(), inRandomOrder() |
order |
chunk(100, (rows) { ... }), chunkById(...) |
work on a big table |
insert({...}), insertMultiple([...]) |
insert |
upsert([...], ['email']) |
insert or update, returns an int |
insertOrIgnore([...]) |
insert, skip duplicates, returns an int |
where(...).update({...}) |
update, no events, no updated_at. Returns the changed rows as an int. |
where(...).increment('views'), decrement |
change a number in one query |
where(...).delete(), truncate() |
delete. delete() returns the deleted rows as an int. |
lockForUpdate(), sharedLock() |
row lock, inside a transaction |
QueryBuilder.query('select 1', substitutionValues: {...}) |
raw query |
withConnection('mysql') |
another connection |
toSql(), debug(true) |
see the SQL |
See Query builder.
Migrations and schema
class CreateBlogsTable extends Migration {
@override
Future<void> up(Schema schema) async {
await schema.create('blogs', (Table table) {
table.id();
table.foreignId('user_id').constrained('users').cascadeOnDelete();
table.string('title');
table.text('body').nullable();
table.enumeration('status', <String>['draft', 'live']).defaultTo('draft');
table.timestamps();
table.softDeletes();
});
}
@override
Future<void> down(Schema schema) async {
await schema.dropIfExists('blogs');
}
}
| Column types |
|---|
id(), increments, bigIncrements, string(c, [255]), char, text, mediumText, longText |
integer, bigInteger, smallInteger, tinyInteger, unsignedInteger (and Big, Small, Tiny) |
float, double, decimal(c, [8], [2]), boolean |
date, dateTime, dateTimeTz, time, timestamp, timestampTz |
json, jsonb, uuid, binary, enumeration(c, [...]), foreignId, foreignUuid |
timestamps(), softDeletes(), rememberToken(), morphs('owner') |
| Modifiers |
|---|
nullable(), defaultTo(v), defaultRaw('sql'), useCurrent(), useCurrentOnUpdate() |
unique(), index(), primary(), unsigned(), comment('t'), after('c'), change() |
keys: constrained([table]), references('id').on('t'), onDelete('cascade'), cascadeOnDelete(), nullOnDelete() |
Change a table: schema.table('t', (Table table) { table.string('a').nullable().change(); table.renameColumn('b', 'c'); table.dropColumn('d'); }).
Transactions
int? id = await QueryBuilder.transaction(() async {
Blog blog = await Blog().create(<String, dynamic>{'title': 'a'});
await QueryBuilder.table('blog_info').insert(<String, dynamic>{'blog_id': blog.id});
return blog.id;
}); // throw inside = rollback
await QueryBuilder.transaction(() async {
Blog blog = await Blog().lockForUpdate().findOrFail(1); // other writers wait
blog.title = 'b';
await blog.save();
});
See Transactions and Row locks.
Cache
await Cache().put('k', 'v', duration: Duration(minutes: 10));
await Cache().get('k', 'default');
await Cache().remember('posts', Duration(minutes: 5), () => Post().all());
await Cache().forget('k');
await Cache().store('redis').increment('hits');
await Cache().tag('users').flush();
CacheLock lock = Cache().store('redis').lock('report', Duration(seconds: 30));
Values must be JSON types. memory is per isolate. See Cache.
Storage
String path = await Storage().putRequestFile('avatars', file); // '/avatars/<uuid>.png'
Uint8List? bytes = await Storage().get(path);
await Storage().exists(path);
await Storage().delete(path);
String url = await Storage().signedUrl(path, expiresIn: Duration(minutes: 10));
await Storage().disk('s3').put('reports', bytes, extension: 'pdf');
return Storage().download(path); // from a controller
See File storage.
Env
@DoxEnv()
abstract class AppEnv {
static AppEnv load([EnvReader? reader]) => _$AppEnv.load(reader);
String get appKey; // APP_KEY, required
int get appPort => 3000; // APP_PORT, default 3000
String? get awsBucket; // AWS_BUCKET, optional
}
final AppEnv env = AppEnv.load();
A new project has this class in lib/config/env.dart. Use env.appPort in code. After you add a getter, run dart run build_runner build.
Env.get<int>('APP_PORT', 3000) is the low-level way. It reads a key by name. A real env var wins over .env. In .env, a # after a space starts a comment.
See Environment variables and Env reference.
Auth
Auth.initialize(AuthConfig(
defaultGuard: 'web',
guards: <String, AuthGuard>{
'web': AuthGuard(
driver: JwtAuthDriver(secret: SecretKey(env.appKey), expiresIn: Duration(days: 7)),
provider: AuthProvider(model: () => User()),
),
},
));
String? token = await Auth().attempt(<String, dynamic>{'email': e, 'password': p});
String? token2 = Auth().login(user);
user.password = await Hash.makeAsync(password); // bcrypt in another isolate
bool ok = await Hash.verifyAsync(password, hash);
Protect a route with AuthMiddleware(). Client sends Authorization: Bearer <token>.
AuthMiddleware(optional: true) lets guests through too. GuestMiddleware(redirectTo: '/dashboard') is for login pages.
See Authentication and Hashing.
Socket.IO
class ChatNamespace extends NamespaceHandler {
@override
String get path => '/chat';
@override
void onConnection(Socket socket) {
socket.join('lobby');
socket.on('chat', onChat);
}
void onChat(SocketEvent event) {
event.socket.to('lobby').emit('chat', event.data);
event.ack('ok');
}
}
Log users in at connect time with SocketAuth(findUser).handle in middleware. The user is in socket.user.
Turn it on with a SocketIoProvider (extend it in lib/). With many isolates, clients use transports: ['websocket']. Broadcasts reach every isolate with no Redis; many servers need the Redis adapter.
See Socket.IO.
OpenAPI
OpenApiConfig openApi = OpenApiConfig(
info: Info(title: 'Blog API', version: '1.0.0'),
registry: openApiRegistry, // lib/generated/openapi.g.dart
);
Docs at /docs, /openapi.json, /openapi.yaml. On only when APP_ENV is development or local, unless enabled is set. Not set means production. @Security.bearer(format: 'API key') above a middleware class shows its login scheme. Change one route with @Summary, @Tags, @Request(T), @Response(200, T), @ApiIgnore().
See OpenAPI.
Testing
import 'package:dox_core/testing.dart';
import 'package:dox_orm/testing.dart';
late DoxTestClient client;
setUpAll(() async => client = await DoxTestClient.start(appConfig));
setUp(refreshDatabase); // empty every table before each test
tearDownAll(() async => client.close());
test('ping', () async {
(await client.get('/api/ping')).expectStatus(200).expectBody('pong');
(await client.withToken(token).post('/api/posts', body: {...})).expectStatus(201);
});
| Fake | Code |
|---|---|
| cache | Dox().cache = CacheManager(CacheConfig(defaultStore: 'memory', stores: {...})) |
| storage | Disk fake = Storage.manager.fake('s3'); then Storage.manager.restore('s3') |
ArrayTransport mail = Mail.fake(); then mail.assertSent(), mail.assertQueued(), mail.assertNothingSent(), and Mail.restore() |
|
| queue | QueueFake queue = Queue.fake(); then queue.assertPushed<T>(), and Queue.restore() |
| env | AppEnv.load(EnvReader(environment: {...}, dotEnv: {...})) |
| DTO | await CreateBlog.schema.safeValidateMap(<String, dynamic>{...}) |
Run with dart test --concurrency=1 when files share a port or tables.
See Testing.