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.
Pipeline order
Section titled “Pipeline order”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.
CorsConfig
Section titled “CorsConfig”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. |
corsMiddleware
Section titled “corsMiddleware”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'}, ),)Rate limiting
Section titled “Rate limiting”RateLimitConfig
Section titled “RateLimitConfig”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);rateLimitMiddleware
Section titled “rateLimitMiddleware”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.
RateLimitStore
Section titled “RateLimitStore”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_redisAppConfig( rateLimit: const RateLimitConfig(requestsPerMinute: 600), rateLimitStore: RedisRateLimitStore(GisilaRedis.fromUrl(Platform.environment['REDIS_URL']!)),)InMemoryRateLimitStore
Section titled “InMemoryRateLimitStore”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.
clientAddress
Section titled “clientAddress”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.
Security headers
Section titled “Security headers”SecurityHeadersConfig
Section titled “SecurityHeadersConfig”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.
HstsConfig
Section titled “HstsConfig”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.
securityHeadersMiddleware
Section titled “securityHeadersMiddleware”Middleware securityHeadersMiddleware(SecurityHeadersConfig config)The middleware behind AppConfig.securityHeaders.
Compression
Section titled “Compression”CompressionConfig
Section titled “CompressionConfig”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.
compressionMiddleware
Section titled “compressionMiddleware”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.
acceptsGzip
Section titled “acceptsGzip”bool acceptsGzip(String? acceptEncoding)Whether an Accept-Encoding header allows gzip (gzip, x-gzip or *, without q=0).
EtagConfig
Section titled “EtagConfig”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).
etagMiddleware
Section titled “etagMiddleware”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.
ifNoneMatchHits
Section titled “ifNoneMatchHits”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.
Request ids
Section titled “Request ids”requestIdMiddleware
Section titled “requestIdMiddleware”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.
Body limits and content types
Section titled “Body limits and content types”bodySizeLimitMiddleware
Section titled “bodySizeLimitMiddleware”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.
limitBytes
Section titled “limitBytes”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.
allowedContentTypesMiddleware
Section titled “allowedContentTypesMiddleware”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());defaultAllowedContentTypes
Section titled “defaultAllowedContentTypes”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.