JSON API
In addition to server-rendered HTML pages, our application provides a RESTful JSON API. This is useful for building mobile apps or single-page applications (SPAs) that need to interact with the same data.
Prerequisites
What You’ll Learn
- How to expose CRUD endpoints with Ormed-backed handlers
- How DTOs shape API write payloads
- How to share model logic between web and API surfaces
Step Outcome
By the end of this page, you should have:
- Stable JSON contracts for movie and genre resources
- Clear status-code behavior for success, validation failure, and missing resources
- DTO-backed write handlers with minimal parsing/validation boilerplate
Movie endpoints
The API endpoints use the same database logic as the web handlers but return JSON responses instead of HTML.
- List
- Show
- Create
- Update
- Delete
Fetches a list of movies. We use jsonEncode to convert the model list to a JSON string.
final movies = await database.dataSource
.query<$Movie>()
.withRelation('genre')
.orderBy('createdAt', descending: true)
.get();
return _json({'movies': movies.map(_movieViewModel).toList()});
Fetches a single movie by ID.
final movieId = int.tryParse(id);
if (movieId == null) {
return _json({'error': 'Invalid id'}, status: HttpStatus.badRequest);
}
final movie = await database.dataSource
.query<$Movie>()
.withRelation('genre')
.find(movieId);
if (movie == null) {
return _json({'error': 'Movie not found'}, status: HttpStatus.notFound);
}
return _json({'movie': _movieViewModel(movie)});
Creates a new movie from a JSON payload. We parse the request body and use MovieInsertDto.
final payload = await _readJson(request);
if (payload == null) {
return _json({'error': 'Invalid JSON'}, status: HttpStatus.badRequest);
}
final errors = _validateMoviePayload(payload);
if (errors.isNotEmpty) {
return _json({'errors': errors}, status: HttpStatus.badRequest);
}
final repo = database.dataSource.repo<$Movie>();
final movie = await repo.insert(
MovieInsertDto(
title: payload['title']!.trim(),
releaseYear: payload['releaseYear']!,
summary: _nullIfEmpty(payload['summary']),
posterPath: _nullIfEmpty(payload['posterPath']),
genreId: _parseOptionalInt(payload['genreId']),
),
);
return _json({'movie': _movieViewModel(movie)}, status: HttpStatus.created);
Updates an existing movie using a JSON payload and MovieUpdateDto.
final movieId = int.tryParse(id);
if (movieId == null) {
return _json({'error': 'Invalid id'}, status: HttpStatus.badRequest);
}
final payload = await _readJson(request);
if (payload == null) {
return _json({'error': 'Invalid JSON'}, status: HttpStatus.badRequest);
}
final repo = database.dataSource.repo<$Movie>();
final update = MovieUpdateDto(
title: payload['title'],
releaseYear: payload['releaseYear'],
summary: _nullIfEmpty(payload['summary']),
posterPath: _nullIfEmpty(payload['posterPath']),
genreId: _parseOptionalInt(payload['genreId']),
);
final movie = await repo.update(update, where: {'id': movieId});
return _json({'movie': _movieViewModel(movie)});
Deletes a movie by ID.
final movieId = int.tryParse(id);
if (movieId == null) {
return _json({'error': 'Invalid id'}, status: HttpStatus.badRequest);
}
final repo = database.dataSource.repo<$Movie>();
final deleted = await repo.deleteById(movieId);
if (deleted == 0) {
return _json({'error': 'Movie not found'}, status: HttpStatus.notFound);
}
return _json({'deleted': true, 'id': movieId});
Movie API contract (summary)
| Endpoint | Success | Common failures |
|---|---|---|
GET /api/movies | 200 | — |
GET /api/movies/:id | 200 | 404 when not found |
POST /api/movies | 201 | 400 validation / malformed JSON |
PATCH /api/movies/:id | 200 | 400 invalid payload, 404 missing |
DELETE /api/movies/:id | 200 | 404 missing |
Genre endpoints
- List
- Show
final genres = await database.dataSource
.query<$Genre>()
.orderBy('name')
.get();
return _json({'genres': genres.map(_genreViewModel).toList()});
final genreId = int.tryParse(id);
if (genreId == null) {
return _json({'error': 'Invalid id'}, status: HttpStatus.badRequest);
}
final genre = await database.dataSource.query<$Genre>().find(genreId);
if (genre == null) {
return _json({'error': 'Genre not found'}, status: HttpStatus.notFound);
}
final movies = await database.dataSource
.query<$Movie>()
.withRelation('genre')
.where('genreId', genreId)
.orderBy('createdAt', descending: true)
.get();
return _json({
'genre': _genreViewModel(genre),
'movies': movies.map(_movieViewModel).toList(),
});
Error Shape
Handlers in this tutorial return explicit JSON for failure modes. Typical responses are:
{"error": "Movie not found"}for missing records{"errors": [...]}for validation failures
Payload Format
All API payloads use the field names defined in your models. For example, when creating a movie, the JSON should look like this:
{
"title": "Inception",
"releaseYear": 2010,
"summary": "A thief who steals corporate secrets through the use of dream-sharing technology.",
"genreId": 1
}
Quick Manual Check
curl -s http://localhost:8080/api/movies | jq
curl -s http://localhost:8080/api/genres | jq