Server & Web Routes
The server is where everything comes together. We use Shelf to handle HTTP requests and Shelf Router to map URLs to specific handler functions.
Bootstrap the Shelf server
The entry point of our application initializes the database, storage, and template engine before starting the server. We also use Pipeline to add middleware, such as logRequests and our custom contextual_shelf middleware for structured logging.
Future<HttpServer> runServer({String host = '0.0.0.0', int port = 8080}) async {
final logger = buildLogger();
final httpLogger = buildHttpLogger(logger);
final storage = StorageService();
await storage.init();
final database = AppDatabase();
await database.init();
final templates = TemplateRenderer(
FileSystemRoot(AppPaths.templatesDir, throwOnMissing: true),
sharedData: {'app_name': 'Movie Catalog', 'year': DateTime.now().year},
);
final app = MovieCatalogApp(
database: database,
storage: storage,
templates: templates,
logger: logger,
);
final handler = Pipeline()
.addMiddleware(requestIdMiddleware())
.addMiddleware(httpLogger.middleware)
.addHandler(app.buildHandler());
final server = await serve(handler, host, port);
logger.info('Server running', Context({'host': host, 'port': port}));
return server;
}
Router map
We divide our routes into two main sections: Web (for HTML pages) and API (for JSON data). This keeps the application organized and easy to maintain.
- Web routes
- API routes
..get('/', _index)
..get('/genres', _genresIndex)
..get('/genres/<id|[0-9]+>', _showGenre)
..get('/movies/new', _newMovie)
..post('/movies', _createMovie)
..get('/movies/<id|[0-9]+>', _showMovie)
..get('/movies/<id|[0-9]+>/edit', _editMovie)
..post('/movies/<id|[0-9]+>/edit', _updateMovie)
..get('/movies/<id|[0-9]+>/delete', _confirmDeleteMovie)
..post('/movies/<id|[0-9]+>/delete', _deleteMovie)
..get('/api/genres', _apiListGenres)
..get('/api/genres/<id|[0-9]+>', _apiShowGenre)
..get('/api/movies', _apiListMovies)
..get('/api/movies/<id|[0-9]+>', _apiShowMovie)
..post('/api/movies', _apiCreateMovie)
..patch('/api/movies/<id|[0-9]+>', _apiUpdateMovie)
..delete('/api/movies/<id|[0-9]+>', _apiDeleteMovie)
Movie handlers
Handlers are responsible for processing requests, interacting with the database, and returning a response.
Listing Movies
The index handler fetches movies from the database, including their associated genres, and renders them using a template.
final movies = await database.dataSource
.query<$Movie>()
.withRelation('genre')
.orderBy('createdAt', descending: true)
.get();
final viewModels = movies.map(_movieViewModel).toList();
final html = await templates.render('movies/index.liquid', {
'movies': viewModels,
});
return _html(html);
Creating a Movie
Creating a movie is a multi-step process. We've broken it down to show how each part works:
- 1. Form Parsing
- 2. Validation
- 3. File Storage
- 4. Database Insert
- 5. Logging
We use shelf_multipart to handle file uploads (the movie poster) alongside regular form fields.
final form = request.formData();
if (form == null) {
return Response(HttpStatus.badRequest, body: 'Expected form data');
}
final fields = <String, String>{};
List<int>? posterBytes;
String? posterName;
await for (final formData in form.formData) {
if (formData.name == 'poster' && formData.filename != null) {
posterName = formData.filename;
posterBytes = await formData.part.readBytes();
} else {
fields[formData.name] = await formData.part.readString();
}
}
Before saving, we ensure the data is valid. If not, we re-render the form with error messages.
final errors = _validateMovieForm(fields);
if (errors.isNotEmpty) {
final genres = await database.dataSource
.query<$Genre>()
.orderBy('name')
.get();
final html = await templates.render('movies/new.liquid', {
'genres': genres.map(_genreViewModel).toList(),
'errors': errors,
'values': fields,
});
return _html(html, status: HttpStatus.badRequest);
}
The poster is saved to our storage service.
final posterPath = await storage.savePoster(
originalName: posterName,
bytes: posterBytes,
);
We use the MovieInsertDto to safely insert the new movie into the database.
final repo = database.dataSource.repo<$Movie>();
final movie = await repo.insert(
MovieInsertDto(
title: fields['title']!.trim(),
releaseYear: int.parse(fields['releaseYear']!),
summary: _nullIfEmpty(fields['summary']),
posterPath: posterPath,
genreId: _parseOptionalInt(fields['genreId']),
),
);
Finally, we log the event with context for better observability.
logger.info(
'Movie created',
Context({
'movie_id': movie.id,
'title': movie.title,
'request_id': request.context['requestId'],
}),
);
Updating a Movie
Updating follows a similar pattern, using MovieUpdateDto to apply changes.
- Database Update
- Logging
final repo = database.dataSource.repo<$Movie>();
final movie = await repo.update(
MovieUpdateDto(
title: fields['title']!.trim(),
releaseYear: int.parse(fields['releaseYear']!),
summary: _nullIfEmpty(fields['summary']),
genreId: _parseOptionalInt(fields['genreId']),
),
where: {'id': movieId},
);
logger.info(
'Movie updated',
Context({
'movie_id': movie.id,
'title': movie.title,
'request_id': request.context['requestId'],
}),
);
Genre handlers
Genres are simpler as they are mostly read-only in this example.
- Index
- Show
final genres = await database.dataSource
.query<$Genre>()
.orderBy('name')
.get();
final html = await templates.render('genres/index.liquid', {
'genres': genres.map(_genreViewModel).toList(),
});
return _html(html);
final genreId = int.tryParse(id);
if (genreId == null) {
return Response(HttpStatus.badRequest, body: 'Invalid id');
}
final genre = await database.dataSource.query<$Genre>().find(genreId);
if (genre == null) {
return Response.notFound('Genre not found');
}
final movies = await database.dataSource
.query<$Movie>()
.withRelation('genre')
.where('genreId', genreId)
.orderBy('createdAt', descending: true)
.get();
final html = await templates.render('genres/show.liquid', {
'genre': _genreViewModel(genre),
'movies': movies.map(_movieViewModel).toList(),
});
return _html(html);
Structured logging
Structured logging (via Contextual) is essential for debugging and observability. Instead of plain text logs, we emit JSON logs with attached context like request IDs and user information.
- Logger
- HTTP middleware
- Request ID
We configure a logger that outputs structured JSON.
Logger buildLogger() {
final logger = Logger()
..environment('development')
..addChannel(
'console',
ConsoleLogDriver(),
formatter: PrettyLogFormatter(),
);
return logger;
}
This middleware logs every incoming request and its response time.
HttpLogger buildHttpLogger(Logger logger) {
final writer = DefaultLogWriter(
logger,
sanitizer: Sanitizer(mask: '[REDACTED]'),
);
return HttpLogger(LogAllRequests(), writer);
}
Every request gets a unique ID, which is attached to all logs for that request.
Middleware requestIdMiddleware() {
return (inner) {
return (request) async {
final requestId = _randomId();
final updated = request.change(context: {'requestId': requestId});
return inner(updated);
};
};
}