Building JSON APIs
An API write follows the same short path as the rest of Maat: validate the request, perform the action, then shape the response.
class PostController extends ResourceController { @override Future<Object?> store(Request request) async { final data = await request.validate({ 'title': 'required|string|max:120', 'body': 'required|string', });
final post = await Post.query().create(data); return Response.json(PostResource(post).resolve(request), status: 201); }}
Route.resource('posts', PostController());Nothing separates an API from the rest of a Maat application: the same router, the same validation, the same models. What changes is the shape of what a handler returns and how errors come back.
Resource Routes
Route.resource('posts', PostController());registers the five actions Laravel does:
| Verb | URI | Action |
|---|---|---|
GET/HEAD |
/posts |
index |
POST |
/posts |
store |
GET/HEAD |
/posts/{id} |
show |
PUT/PATCH |
/posts/{id} |
update |
DELETE |
/posts/{id} |
destroy |
Narrow it with only: or except:. The controller extends
ResourceController and overrides what it exposes; the rest answer 404.
The Resource Controller
Override only the actions the endpoint exposes. An action left on the base
ResourceController returns a 404, and only or except can prevent its
route from being registered at all:
class PostController extends ResourceController { @override Future<Object?> index(Request request) async { final posts = await Post.query().paginate(page: 1, perPage: 15); return resourceCollection(posts, PostResource.new, request); }
@override Future<Object?> show(Request request, String id) async { final post = await Post.query().findOrFail(id); return PostResource(post).resolve(request); }}
Route.resource('posts', PostController(), only: ['index', 'show']);Group resources under a prefix in a service provider:
Route.prefix('api').name('api.').group(apiRoutes);Prefix the names as well as the paths. Route.resource names its routes
after the resource, so an API posts.index and a web posts.index collide
— and the router throws rather than letting one silently replace the other.
Shaping the Response
A JsonResource decides what a model looks like on the wire, so the model
keeps no presentation concerns:
class PostResource extends JsonResource<Post> { PostResource(super.resource);
@override Map<String, Object?> toJson(Request request) => { 'id': resource.id, 'title': resource.title, 'comments': resourceCollection( whenLoaded('comments'), CommentResource.new, request, ), };}Return PostResource(post).resolve(request) from a handler. The payload is
wrapped in data — override wrap to turn that off.
when(condition, () => value)omits a key entirely rather than setting it to null. Absent and null say different things to a client.whenLoaded('comments')omits the key unless the relation was eager loaded, which is what stops a collection endpoint from issuing a query per row.resourceCollection(items, PostResource.new, request)shapes many. It takes a constructor tear-off rather than a static, because Dart does not inherit statics:PostResource.collectioncould never resolve.
Pagination
Pass a Paginator to resourceCollection and the response gains Laravel’s
links and meta alongside data:
final page = await Post.query().paginate(page: 1, perPage: 15);return resourceCollection(page, PostResource.new, request);{ "data": [...], "links": { "first": "...", "last": "...", "prev": null, "next": "...?page=2" }, "meta": { "current_page": 1, "per_page": 15, "total": 42, "last_page": 3 }}Errors
The exception handler renders JSON when the client asks for it — an
Accept: application/json header or any path beginning with /api. A validation
failure becomes 422 with the same errors map the HTML flow gets; a
NotFoundHttpException becomes 404, and so on.
{ "message": "The given data was invalid.", "errors": { "title": ["..."] } }A non-API client that sends no Accept header gets the HTML error page, exactly
as in Laravel. Send the header when the endpoint does not use the /api prefix.
Throw ValidationException({'field': ['message']}) yourself for anything the
rule strings cannot express — a foreign key that does not exist, a parent
that would point at itself. Without it those surface as a database error and
a 500, which tells the client nothing about which field to fix.
Query Parameters
Everything in a query string arrives as text and much of it ends up in SQL. Parse each one into the type the column holds and reject what does not fit:
int _int(String raw, String field) => int.tryParse(raw) ?? (throw ValidationException({field: ['The $field field must be an integer.']}));Bindings mean a stray string cannot rewrite the query, but a 500 from a type error is still a worse answer than a 422 naming the field. Never let a parameter choose a column or a sort direction without checking it against a list you control.
Protecting Writes
Declare the middleware on the controller and it travels with the actions, wherever they are registered:
class PostController extends ResourceController { @override List<Object> middleware() => [ ControllerMiddleware('auth.token', only: [store, update, destroy]), ];}ControllerMiddleware names actions by tear-off rather than by string, so a
renamed method is a compile error instead of a silently unprotected route.
The examples/todo application implements auth.token as a single shared
secret. Two things matter more than the mechanism:
- Fail closed. An unset token answers 503, never “no token required”.
- Compare in constant time.
==on aStringreturns at the first differing character, which lets an attacker who can time responses recover a secret one character at a time.
Configuration Files Are Getters
Map<String, dynamic> get app => {'api_token': env('API_TOKEN', '')};Not final. A top-level final is initialised once per isolate, so its
env(...) calls freeze at whatever the first application to boot saw. Every
test that builds a second application with a different environment would
then be handed the first one’s configuration, with nothing to show for it.