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, startServer() |
bin/migrate.dart |
runs migrations for dox migration:* |
bin/openapi.dart |
writes the OpenAPI files for dox openapi:generate |
lib/config/ |
app.dart (AppConfig), env.dart, database.dart, cors.dart, cache.dart, storage.dart, openapi.dart, services.dart, logger.dart, socketio.dart |
lib/routes/ |
api.dart, web.dart, websocket.dart: one Router class each |
lib/app/http/controllers/ |
<name>.controller.dart |
lib/app/http/dtos/ |
<name>.dto.dart |
lib/app/http/middleware/ |
<name>.middleware.dart |
lib/app/http/serializers/ |
<name>.serializer.dart |
lib/app/models/<name>/ |
<name>.model.dart |
lib/services/ |
DoxService classes: ORM, auth, websocket |
lib/dtos.g.dart, lib/openapi.g.dart |
made by build_runner. Do not edit. |
db/migration/ |
migrations and migrations.dart (the registry) |
See CLI and App config.
CLI
dox create my_app # newest 3.x 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 create:controller Blog [-r] [-ws]
dox create:middleware Auth
dox create:dto CreateBlog
dox create:serializer Blog
dox create:model Blog -m # model + migration
dox make:migration create_blogs_table [--sql]
dox migrate # migration:run
dox migration:rollback --step=1
dox migration:status
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.index) |
also post, put, patch, delete, options |
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.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.files('files') |
serve a storage disk, signed URLs only |
Route.enabledCors(() { ... }) / Route.disabledCors(...) |
CORS for some routes |
Route.websocket('ws', (WebsocketEvent e) { ... }) |
needs dox_websocket |
Controller parameters, by type: DoxRequest, one @Dto class, String/int/double/num/bool for path params (by position). Only positional parameters.
Middleware order: globalMiddleware, router middleware, group, route.
See Routing, Controller, Middleware.
Middleware
class ApiKeyMiddleware implements IDoxMiddleware {
@override
dynamic handle(IDoxRequest req) {
if (req.header('X-Api-Key') != 'secret') {
return response('no').statusCode(401); // stop here
}
return req; // go on
}
}
See Middleware.
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.has('name') |
key is there and not empty |
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.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
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 |
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 |
NotFoundHttpException() |
404 |
PayloadTooLargeException() |
413 |
UnsupportedMediaTypeException() |
415 |
ValidationException() |
422 |
InternalErrorException() |
500 |
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() |
| 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'), @Mimes(['pdf']), @Image() |
Database (dox_orm) |
@Unique('users', column: 'email', ignoreParam: 'id'), @Exists('teams') |
| Keys | @Name('emailAddress') |
| 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 (ignores where) |
await Blog().where('status', 'live').get() |
rows that match |
await Blog().where('id', '>', 5).getFirst() |
first match or null |
await Blog().count() |
int |
await Blog().paginate(currentPage: 1, perPage: 20) |
Pagination<Blog>, rows in data |
await Blog().create(<String, dynamic>{'title': 'a'}) |
insert and return the model |
blog.title = 'b'; await blog.save() |
insert or update |
await blog.delete() |
soft delete when softDelete: true |
await blog.forceDelete() / await blog.restore() |
real delete / undo soft delete |
Blog().withTrash() |
include soft deleted rows |
Blog().preload('comments').get() |
load a relation, one query |
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). 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.
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 |
whereRaw('lower(name) = @n', <String, dynamic>{'n': 'a'}) |
raw SQL, bound values |
leftJoin, rightJoin, joinRaw |
joins |
groupBy('age'), selectRaw('DISTINCT name') |
groups, raw select |
insert({...}), insertMultiple([...]) |
insert |
where(...).update({...}) |
update, no events, no updated_at |
where(...).delete(), truncate() |
delete |
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
See Transactions.
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 = Hash.make(password);
bool ok = Hash.verify(password, hash);
Protect a route with AuthMiddleware(). Client sends Authorization: Bearer <token>.
See Authentication and Hashing.
WebSocket
// a service
WebsocketServer(Dox());
// a route
Route.websocket('ws', (WebsocketEvent event) {
event.on('intro', chat.intro);
});
// a controller method
void intro(WebsocketEmitter emitter, dynamic message) {
emitter.room('chat').emitExceptSender('intro', message);
}
Client sends {"event": "joinRoom", "message": "chat"}. Many isolates need WebsocketRedisAdapter.
See WebSocket.
OpenAPI
OpenApiConfig openApi = OpenApiConfig(
info: Info(title: 'Blog API', version: '1.0.0'),
registry: openApiRegistry, // lib/openapi.g.dart
);
Docs at /docs, /openapi.json, /openapi.yaml. On only when APP_ENV is development or local, unless enabled: true. Change one route with @Summary, @Tags, @Request(T), @Response(200, T), @ApiIgnore().
See OpenAPI.
Testing
setUpAll(() async {
Dox().initialize(appConfig);
Dox().totalIsolate(1);
await Dox().startServer();
});
tearDownAll(() async => Dox().server.close());
test('ping', () async {
http.Response res = await http.get(Uri.parse('http://localhost:3000/api/ping'));
expect(res.statusCode, 200);
});
| Fake | Code |
|---|---|
| cache | Dox().cache = CacheManager(CacheConfig(defaultStore: 'memory', stores: {...})) |
| storage | Disk fake = Dox().storage.fake('s3'); then Dox().storage.restore('s3') |
| 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.