Skip to content

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.

  • A gisila project. Middleware is plain Shelf: typedef Middleware = Handler Function(Handler inner).

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;
}

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.

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()]));
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
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);
});