Skip to content

Middleware

import 'package:gisila/gisila.dart';

Most built-in middleware is switched on through AppConfig fields and installed by GisilaApp.buildHandler() in a fixed order. The factory functions are exported too, so you can use them in RouteConfig.middleware, in app.use(), or in a plain Shelf pipeline.

Outermost first. Each step only exists when its AppConfig field is set (security headers and request ids are on by default).

# Step Configured by
1 Security headers securityHeaders (default on)
2 Request id, creates the RequestContext requestIdHeader
3 Health check (answers and stops here) healthCheckPath, healthCheck
4 Metrics (and the /metrics endpoint) metrics
5 gzip compression compression
6 ETags etags
7 Cache attached to the context cache
8 CORS cors
9 App-wide rate limit rateLimit, rateLimitStore, trustedProxies
10 Body size limit maxRequestBodyBytes
11 Access log logRequests, logRequestBody, logResponseBody
12 Error mapper (exceptions to JSON) errorHandler, dbErrorMapper
13 Database session database
14 Your middleware from app.use(), in registration order
15 Server / X-Powered-By headers serverHeader, poweredByHeader
16 App-wide timeout requestTimeout
17 Router, then each route’s own pipeline RouteConfig

Each route then runs: CORS override, rate limit, content-type check, body cap, RouteConfig.middleware and @UseMiddleware, timeout, auth guard, your method. See gisilaRoute.

Consequences worth knowing: CORS pre-flights, rate-limited requests and oversized bodies never open a database session; exceptions thrown in your middleware are rendered as JSON errors; health checks skip auth, metrics and the database.

class CorsConfig {
const CorsConfig({
Set<String> allowedOrigins = const {'*'},
Set<String> allowedMethods = const {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'},
Set<String> allowedHeaders = const {'Content-Type', 'Authorization', 'X-Request-Id'},
Set<String> exposedHeaders = const {},
bool allowCredentials = false,
Duration maxAge = const Duration(hours: 1),
});
bool allowsOrigin(String origin);
}

When to use: when a browser app on another origin calls your API. Not needed for same-origin frontends or server-to-server calls.

Field Default Description
allowedOrigins {'*'} Exact origins such as https://app.example.com, or '*'.
allowedMethods GET, POST, PUT, PATCH, DELETE, OPTIONS Sent on pre-flight responses.
allowedHeaders Content-Type, Authorization, X-Request-Id Sent on pre-flight responses. Add custom headers your client sends.
exposedHeaders {} Response headers the browser may read (for example X-Request-Id).
allowCredentials false Allow cookies and auth headers. Can’t be combined with '*': that throws ArgumentError.
maxAge 1 hour How long browsers cache a pre-flight.
Middleware corsMiddleware([CorsConfig config = const CorsConfig()])

A pre-flight (an OPTIONS request with Origin and Access-Control-Request-Method) from an allowed origin is answered with 204 and the negotiated headers; from any other origin with 403. Other requests pass through and get Access-Control-Allow-Origin (and friends) when their origin is allowed. With explicit origins, Vary: Origin is added.

AppConfig(
cors: const CorsConfig(
allowedOrigins: {'https://app.example.com', 'http://localhost:5173'},
allowCredentials: true,
exposedHeaders: {'X-Request-Id'},
),
)
class RateLimitConfig {
const RateLimitConfig({
int requestsPerMinute = 120,
int? burstSize,
String Function(Request request)? keyExtractor,
Set<String> trustedProxies = const {},
int maxBuckets = 100000,
});
}

A token bucket per key: it holds burstSize tokens and refills at requestsPerMinute. A request without a token gets 429 with code too_many_requests and a Retry-After header.

When to use: app-wide (AppConfig.rateLimit) as a coarse flood guard, and per route (RouteConfig.rateLimit) for expensive or abuse-prone endpoints such as login, sign-up and password reset.

Field Default Description
requestsPerMinute 120 Steady-state rate.
burstSize requestsPerMinute Bucket capacity.
keyExtractor client address Bucket key per request, for example the API key or user id. In an annotation it must be a top-level function or static method (a const tear-off).
trustedProxies {} Extra proxies trusted for X-Forwarded-For, merged with AppConfig.trustedProxies.
maxBuckets 100000 Tracked keys per in-memory store; idle buckets are swept first, then least recently used.
String apiKeyOrIp(Request r) => r.headers['x-api-key'] ?? clientAddress(r) ?? 'unknown';
@Post('/search', config: RouteConfig(
rateLimit: RateLimitConfig(requestsPerMinute: 30, burstSize: 10, keyExtractor: apiKeyOrIp),
))
Future<SearchResult> search(SearchForm form, SearchService s) => s.run(form);
Middleware rateLimitMiddleware([
RateLimitConfig config = const RateLimitConfig(),
Set<String> trustedProxies = const {},
RateLimitStore? store,
String? scope,
])

The limiter itself. store defaults to a new InMemoryRateLimitStore for this middleware. With a shared store, scope names the limiter so its buckets are kept apart from others and match across isolates; without it, a scope unique to this instance is used. Throws ArgumentError for a malformed proxy entry.

When to use: directly only in custom pipelines; otherwise use the config fields.

abstract class RateLimitStore {
FutureOr<Duration?> take(String key, {required double capacity, required double refillPerSecond});
}

Where buckets live. take removes one token from bucket key (created full), returning null on success or how long until a token is available.

When to use: implement it (or use RedisRateLimitStore from gisila_redis) to share limits across isolates and servers; set it as AppConfig.rateLimitStore.

// package:gisila_redis
AppConfig(
rateLimit: const RateLimitConfig(requestsPerMinute: 600),
rateLimitStore: RedisRateLimitStore(GisilaRedis.fromUrl(Platform.environment['REDIS_URL']!)),
)
class InMemoryRateLimitStore implements RateLimitStore {
InMemoryRateLimitStore({int maxBuckets = 100000});
final int maxBuckets;
}

The default store: buckets in this isolate’s memory. With several isolates or servers, each allows the full rate.

String? clientAddress(Request request, {Set<String> trustedProxies = const {}})

The client address: the socket’s peer address, or, when the peer is a trusted proxy (exact IP or CIDR), the right-most X-Forwarded-For entry that is not itself trusted. null when the request didn’t come from a socket (in-memory tests) and carries no usable header.

When to use: in a custom keyExtractor, audit logs or abuse checks. Never trust X-Forwarded-For without a trusted proxy list.

class SecurityHeadersConfig {
const SecurityHeadersConfig({
HstsConfig? hsts = const HstsConfig(),
String? contentTypeOptions = 'nosniff',
String? frameOptions = 'DENY',
String? referrerPolicy = 'no-referrer',
String? contentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'",
String? htmlContentSecurityPolicy,
Map<String, String> extra = const {},
});
}

Headers added to every response that doesn’t already carry them (a handler can set its own). Set a field to null to leave that header out. On by default through AppConfig.securityHeaders.

Field Header Default
hsts Strict-Transport-Security max-age=15552000 (180 days)
contentTypeOptions X-Content-Type-Options nosniff
frameOptions X-Frame-Options DENY
referrerPolicy Referrer-Policy no-referrer
contentSecurityPolicy Content-Security-Policy on non-HTML responses default-src 'none'; frame-ancestors 'none'
htmlContentSecurityPolicy Content-Security-Policy on text/html responses none
extra any {} (for Permissions-Policy, Cross-Origin-Opener-Policy and so on)

When to use: keep the defaults for JSON APIs. Set htmlContentSecurityPolicy when you serve HTML (a frontend, API docs) and write a policy that fits it.

class HstsConfig {
const HstsConfig({
Duration maxAge = const Duration(days: 180),
bool includeSubDomains = false,
bool preload = false,
});
String get value;
}

Strict-Transport-Security settings. Turn on includeSubDomains only when every subdomain serves HTTPS; preload needs includeSubDomains and a maxAge of at least a year. value is the rendered header.

Middleware securityHeadersMiddleware(SecurityHeadersConfig config)

The middleware behind AppConfig.securityHeaders.

class CompressionConfig {
const CompressionConfig({
int minBytes = 1024,
int level = 6,
Set<String> mimeTypes = defaultCompressibleTypes,
bool Function(Request request, Response response)? shouldCompress,
});
static const Set<String> defaultCompressibleTypes;
}
Field Default Description
minBytes 1024 Bodies of known length below this are sent uncompressed. Streams are always compressed.
level 6 gzip level, 1 to 9 (otherwise ArgumentError).
mimeTypes defaultCompressibleTypes application/json, application/problem+json, application/javascript, application/xml, application/x-www-form-urlencoded, image/svg+xml, text/*. type/* covers a whole type.
shouldCompress null Final say per response, for example to skip pages that mix secrets with attacker-controlled text (BREACH).

When to use: when the app talks to clients directly. Leave it off behind a proxy or CDN that already compresses.

Middleware compressionMiddleware([CompressionConfig config = const CompressionConfig()])

gzips responses for clients sending Accept-Encoding: gzip. Skips HEAD, statuses without a body (1xx, 204, 304), responses that already have Content-Encoding (send identity to opt out), text/event-stream and types outside mimeTypes. Adds Vary: Accept-Encoding and weakens a strong ETag.

bool acceptsGzip(String? acceptEncoding)

Whether an Accept-Encoding header allows gzip (gzip, x-gzip or *, without q=0).

class EtagConfig {
const EtagConfig({int maxBytes = 1024 * 1024});
final int maxBytes;
}

Bodies larger than maxBytes (or of unknown length) get no generated ETag, since hashing means buffering them.

When to use: read-heavy GET endpoints whose clients poll; a 304 saves bandwidth (not server work, since the handler still runs).

Middleware etagMiddleware([EtagConfig config = const EtagConfig()])

Gives 200 responses to GET/HEAD an ETag (part of the SHA-256 of the body) and answers 304 Not Modified when If-None-Match matches. A handler’s own ETag is kept and honoured. Responses with Cache-Control: no-store are left alone.

bool ifNoneMatchHits(String? ifNoneMatch, String etag)

Weak comparison of an If-None-Match header against an ETag (W/"x" matches "x"; * matches everything). Use it when you compute ETags yourself.

Middleware requestIdMiddleware({
String headerName = 'X-Request-Id',
String Function()? generator,
})

Ensures every request has a RequestContext. Reuses an incoming id when it is 1 to 128 characters of [A-Za-z0-9._:-], otherwise mints a random 16-character id (or calls generator), and echoes it in headerName on the response. Installed automatically with AppConfig.requestIdHeader.

Middleware bodySizeLimitMiddleware(int maxBytes)

Rejects bodies over maxBytes with 413 (payload_too_large). A declared Content-Length over the limit is refused up front; otherwise the stream is metered and fails as soon as it crosses the limit, so nothing buffers more than maxBytes. Installed by AppConfig.maxRequestBodyBytes and RouteConfig.maxBodyBytes.

Stream<List<int>> limitBytes(Stream<List<int>> source, int maxBytes)

Passes source through and throws PayloadTooLarge once more than maxBytes have been seen. Use it to cap a stream you read yourself.

Middleware allowedContentTypesMiddleware({
Set<String> contentTypes = defaultAllowedContentTypes,
Set<String> guardedMethods = const {'POST', 'PUT', 'PATCH'},
})

Rejects requests in guardedMethods whose Content-Type (without parameters, case-insensitive) is missing or not in contentTypes, with 415 (unsupported_media_type, allowed types in details.allowed). Other methods pass. RouteConfig.contentTypes installs it per route.

app.use(allowedContentTypesMiddleware());
const defaultAllowedContentTypes = {
'application/json',
'application/x-www-form-urlencoded',
'multipart/form-data',
};

The three types gisila’s body parsers decode.

See also: Write custom middleware, Rate limiting guide, Request lifecycle.