Write custom middleware
You’ll write three middleware: a tenant resolver applied to every request, an idempotency-key handler applied by name to a few routes, and a timing header. Along the way you’ll see where each runs and how to pass data to services.
Prerequisites
Section titled “Prerequisites”- A gisila project. Middleware is plain Shelf:
typedef Middleware = Handler Function(Handler inner).
1. Global middleware with app.use
Section titled “1. Global middleware with app.use”Resolve the tenant from a header and store it on the request context:
import 'package:gisila/gisila.dart';
Middleware tenantMiddleware() { return (Handler inner) { return (Request request) async { final tenant = request.headers['x-tenant-id']; if (tenant == null || tenant.isEmpty) { throw BadRequest('Missing X-Tenant-Id', code: 'missing_tenant'); } RequestContext.maybeOf(request)!.extra['tenantId'] = tenant; return inner(request); }; };}
app.use(tenantMiddleware());Middleware added with app.use runs for every request, after CORS, rate limiting, the access log, the error mapper and the database session are in place, and before routing. Exceptions you throw become JSON errors. Middleware registered first runs outermost.
Read the value in a service:
class ProjectService extends Service { String get tenantId => ctx.extra['tenantId'] as String;}2. Named middleware with @UseMiddleware
Section titled “2. Named middleware with @UseMiddleware”Annotations must be const, so you register middleware under a name and refer to it:
Middleware idempotencyMiddleware(CacheProvider store) { return (Handler inner) { return (Request request) async { final key = request.headers['idempotency-key']; if (key == null) return inner(request);
final cached = await store.get('idem:$key'); if (cached != null) { return Response(200, body: cached, headers: {'content-type': 'application/json'}); } final response = await inner(request); if (response.statusCode < 300) { final body = await response.readAsString(); await store.set('idem:$key', body, ttl: const Duration(hours: 24)); return response.change(body: body); } return response; }; };}
app.registerNamedMiddleware('idempotency', idempotencyMiddleware(cache));@Post('/payments')@Permissions({'payments:write'})@UseMiddleware('idempotency')Future<Payment> charge(ChargeForm form, PaymentService payments) => payments.charge(form);Named middleware runs inside the route’s pipeline: after the route’s rate limit and body checks, before its timeout and the auth guard. Put @UseMiddleware on the class to apply it to every route of a controller. Register names before start(); an unknown name fails at startup.
3. Per-route middleware in RouteConfig
Section titled “3. Per-route middleware in RouteConfig”RouteConfig.middleware takes middleware objects directly, which works where configs are built at runtime, such as AppConfig.defaultRouteConfig:
Middleware timingHeader() => (inner) => (request) async { final sw = Stopwatch()..start(); final response = await inner(request); return response.change(headers: {'server-timing': 'app;dur=${sw.elapsedMilliseconds}'}); };
AppConfig(defaultRouteConfig: RouteConfig(middleware: [timingHeader()]));4. Choose where it goes
Section titled “4. Choose where it goes”| Need | Use |
|---|---|
| Every request | app.use(...) |
| Some routes, chosen in code next to the route | @UseMiddleware('name') + registerNamedMiddleware |
| Every generated route (not 404s or static files) | AppConfig.defaultRouteConfig: RouteConfig(middleware: [...]) |
| Business logic that needs the principal | A service, not middleware |
Verify it works
Section titled “Verify it works”test('rejects requests without a tenant', () async { final res = await client.get('/projects'); expect(res.statusCode, 400); expect(res.errorCode, 'missing_tenant');});
test('replays an idempotent POST', () async { final c = client.as(payer).withHeaders({'idempotency-key': 'abc', 'x-tenant-id': 't1'}); final first = await c.post('/payments', json: {'amount': 500}); final second = await c.post('/payments', json: {'amount': 500}); expect(second.body, first.body);});