Skip to content

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.

# pubspec.yaml
dependencies:
  shelf: ^1.4.0
  shelf_router: ^1.1.4

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:

Converting object to an encodable object failed: _Map len:2

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.