02 · Building APIs with shelf¶
Level 1's async basics and
Level 2's streams covered async I/O in the
abstract. shelf is the standard Dart package for building HTTP servers:
it models a server as a single function — Request in, Response out (or
a Future of one) — and lets you compose behavior with middleware. This
module builds a small JSON API with it.
The simplest possible server¶
A Handler is just FutureOr<Response> Function(Request request).
shelf_io.serve binds it to a socket and calls it for every incoming
request.
import 'dart:convert';
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
Response _handler(Request request) {
final name = request.url.path.isEmpty ? 'world' : request.url.path;
return Response.ok('Hello, $name!');
}
Future<void> main() async {
final server = await shelf_io.serve(_handler, 'localhost', 8080);
print('Serving at http://${server.address.host}:${server.port}');
// A plain HttpClient stands in for a browser/curl request here.
final client = HttpClient();
final req = await client.get('localhost', 8080, '/dart');
final resp = await req.close();
print('Client got: ${await resp.transform(utf8.decoder).join()}');
await server.close();
client.close();
}
// Serving at http://localhost:8080
// Client got: Hello, dart!
request.url.path is the path with the leading slash stripped, so a
request to /dart shows up as 'dart', not '/dart' — easy to trip over
when matching paths by hand.
Routing with shelf_router¶
Real APIs have more than one endpoint. shelf_router's Router maps
HTTP method + path pattern to a handler, including <param> placeholders
passed as extra positional arguments.
import 'dart:convert';
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
final _books = <int, String>{1: 'Dart in Action', 2: 'Effective Dart'};
Response _listBooks(Request request) {
final asStrings = _books.map((k, v) => MapEntry(k.toString(), v));
return Response.ok(jsonEncode(asStrings), headers: {'content-type': 'application/json'});
}
Response _getBook(Request request, String id) {
final book = _books[int.parse(id)];
if (book == null) {
return Response.notFound(jsonEncode({'error': 'not found'}));
}
return Response.ok(jsonEncode({'id': id, 'title': book}), headers: {'content-type': 'application/json'});
}
Future<void> main() async {
final router = Router()
..get('/books', _listBooks)
..get('/books/<id>', _getBook);
final handler = const Pipeline().addMiddleware(logRequests()).addHandler(router.call);
final server = await shelf_io.serve(handler, 'localhost', 8081);
print('Serving at http://${server.address.host}:${server.port}');
final client = HttpClient();
for (final path in ['/books', '/books/1', '/books/99']) {
final req = await client.get('localhost', 8081, path);
final resp = await req.close();
print('$path -> ${resp.statusCode} ${await resp.transform(utf8.decoder).join()}');
}
await server.close();
client.close();
}
// Serving at http://localhost:8081
// /books -> 200 {"1":"Dart in Action","2":"Effective Dart"}
// /books/1 -> 200 {"id":"1","title":"Dart in Action"}
// /books/99 -> 404 {"error":"not found"}
Route parameters like id always arrive as String — _getBook has to
int.parse it itself, and a malformed id (/books/abc) would throw
FormatException inside the handler rather than failing at the routing
layer.
The trap: jsonEncode rejects non-String map keys¶
The very first version of _listBooks above used jsonEncode(_books)
directly on a Map<int, String>. That throws at runtime:
JSON objects only have string keys, and jsonEncode does not
stringify integer keys for you — it throws instead. This is one of the more
confusing runtime errors in Dart because the map itself is perfectly valid
Dart; it only breaks the moment it meets jsonEncode. The fix, shown above,
is to .map((k, v) => MapEntry(k.toString(), v)) before encoding.
Middleware: composing cross-cutting behavior¶
Middleware wraps a handler with a function that runs before/after it —
logRequests() above is one; you can write your own the same way, e.g. to
require an API key on every request.
import 'package:shelf/shelf.dart';
Middleware requireApiKey(String expected) {
return (Handler inner) {
return (Request request) {
final key = request.headers['x-api-key'];
if (key != expected) {
return Response.forbidden('missing or invalid API key');
}
return inner(request);
};
};
}
Pipeline().addMiddleware(a).addMiddleware(b).addHandler(h) runs a's
"before" logic, then b's, then h, then b's "after" logic, then a's —
middleware nests like an onion, not a flat list, which matters once one
middleware's job is to catch errors thrown further in.
Cheat sheet¶
| Concept | Meaning |
|---|---|
Handler |
FutureOr<Response> Function(Request) — the whole server model |
shelf_io.serve(handler, host, port) |
Bind a handler to a real socket |
Router()..get(path, fn) |
Route by method + path, with <param> captures |
| Route params | Always arrive as String; parse/validate yourself |
Pipeline().addMiddleware(...).addHandler(...) |
Compose cross-cutting behavior around a handler |
jsonEncode on non-String map keys |
Throws at runtime — stringify keys first |
logRequests() |
Built-in middleware that prints one line per request |
How It Actually Works¶
A shelf server is fundamentally a function: Handler is typedef Handler =
FutureOr<Response> Function(Request request). Every incoming HTTP
connection accepted by dart:io's HttpServer (which itself listens via
the OS's non-blocking socket APIs and hands off completed requests to the
event loop) is converted into a shelf.Request and passed through this
function chain — there's no hidden framework magic; routing, middleware, and
your handler are all literally just functions composed together, which is
why middleware can be understood purely as "a function that takes a
Handler and returns a new Handler" wrapping the original with code that
runs before and/or after.
Because each request is handled asynchronously (the handler function
returns a FutureOr<Response>), a single dart:io isolate can serve many
concurrent connections without threads — while one request's handler is
awaiting a database query or another I/O operation, the event loop is free
to process other requests' events. This is the same single-isolate,
event-loop concurrency model from the async-basics lesson, just applied to
serving HTTP instead of console I/O — true parallelism across CPU cores
requires spawning separate isolates (see the isolates lesson), each running
its own instance of the shelf pipeline.
jsonEncode rejecting non-String map keys is rooted in the JSON
specification itself, not a Dart limitation — JSON objects only have string
keys, so dart:convert's encoder walks your Map and, upon finding a
non-String key, throws rather than silently calling .toString() on it
(which could produce ambiguous or lossy output, e.g. two different objects
whose toString() collides).
Exercise¶
Build a shelf_router API with two routes: POST /echo, which reads the
request body with await request.readAsString() and returns it back with
status 200, and GET /health, which returns {"status":"ok"} as JSON.
Add a middleware that logs the method and path of every request before
logRequests() runs. Verify both routes with an HttpClient in main(),
printing status code and body for each.