|
| 1 | +--- |
| 2 | +description: Use middleware to process requests before they reach your controllers — add headers, enforce CORS, reject unauthorized requests, and more. |
| 3 | +--- |
| 4 | + |
| 5 | +# Middleware |
| 6 | + |
| 7 | +Middleware lets you intercept and modify requests at the **dispatch level**, before a controller is even instantiated. This is different from [filters](using-filters.md), which run inside a specific controller. |
| 8 | + |
| 9 | +Use middleware when you need logic that applies globally or to groups of routes — things like request IDs, CORS headers, security headers, rate limiting, or authentication gates. |
| 10 | + |
| 11 | +## How It Works |
| 12 | + |
| 13 | +Each middleware implements a simple contract: receive a `request` struct and a `next` function, do your work, then call `next(request)` to pass control down the chain. The last step in the chain is the actual controller dispatch. |
| 14 | + |
| 15 | +``` |
| 16 | +Request → RequestId → Cors → SecurityHeaders → Controller → Response |
| 17 | + ↑ |
| 18 | + next(request) |
| 19 | +``` |
| 20 | + |
| 21 | +This is sometimes called the "onion model" — the first middleware registered is the outermost layer. |
| 22 | + |
| 23 | +## The Middleware Interface |
| 24 | + |
| 25 | +Every middleware component must implement `wheels.middleware.MiddlewareInterface`: |
| 26 | + |
| 27 | +```cfm |
| 28 | +// vendor/wheels/middleware/MiddlewareInterface.cfc |
| 29 | +interface { |
| 30 | + public string function handle(required struct request, required any next); |
| 31 | +} |
| 32 | +``` |
| 33 | + |
| 34 | +- **`request`** — A struct containing `params`, `route`, `pathInfo`, and `method`, plus any data added by prior middleware. |
| 35 | +- **`next`** — A closure. Call `next(request)` to continue to the next middleware (or the controller). Skip it to short-circuit the pipeline. |
| 36 | +- **Return value** — The response string. |
| 37 | + |
| 38 | +## Registering Global Middleware |
| 39 | + |
| 40 | +Register middleware in `config/settings.cfm`. They run on **every request** in the order listed: |
| 41 | + |
| 42 | +```cfm |
| 43 | +// config/settings.cfm |
| 44 | +set( |
| 45 | + middleware = [ |
| 46 | + new wheels.middleware.RequestId(), |
| 47 | + new wheels.middleware.SecurityHeaders(), |
| 48 | + new wheels.middleware.Cors(allowOrigins="https://myapp.com") |
| 49 | + ] |
| 50 | +); |
| 51 | +``` |
| 52 | + |
| 53 | +You can pass component instances (as above) or CFC dot-paths as strings: |
| 54 | + |
| 55 | +```cfm |
| 56 | +set(middleware = ["wheels.middleware.RequestId", "app.middleware.RateLimiter"]); |
| 57 | +``` |
| 58 | + |
| 59 | +String paths are instantiated automatically with a no-arg `init()`. |
| 60 | + |
| 61 | +## Route-Scoped Middleware |
| 62 | + |
| 63 | +Attach middleware to specific route groups using the `middleware` argument on `scope()`: |
| 64 | + |
| 65 | +```cfm |
| 66 | +// config/routes.cfm |
| 67 | +mapper() |
| 68 | + .scope(path="/api", middleware=[new app.middleware.ApiAuth()]) |
| 69 | + .resources("users") |
| 70 | + .resources("products") |
| 71 | + .end() |
| 72 | + .resources("pages") // no ApiAuth middleware here |
| 73 | +.end(); |
| 74 | +``` |
| 75 | + |
| 76 | +Route-scoped middleware runs **after** global middleware. Scopes nest — child scopes inherit and append to parent middleware: |
| 77 | + |
| 78 | +```cfm |
| 79 | +mapper() |
| 80 | + .scope(path="/api", middleware=["app.middleware.ApiAuth"]) |
| 81 | + .scope(path="/admin", middleware=["app.middleware.AdminOnly"]) |
| 82 | + .resources("settings") // runs: global → ApiAuth → AdminOnly |
| 83 | + .end() |
| 84 | + .resources("users") // runs: global → ApiAuth |
| 85 | + .end() |
| 86 | +.end(); |
| 87 | +``` |
| 88 | + |
| 89 | +## Built-in Middleware |
| 90 | + |
| 91 | +Wheels ships with three middleware components you can use immediately. |
| 92 | + |
| 93 | +### RequestId |
| 94 | + |
| 95 | +Assigns a unique UUID to every request for tracing and debugging. |
| 96 | + |
| 97 | +- Sets `request.wheels.requestId` |
| 98 | +- Adds `X-Request-Id` response header |
| 99 | + |
| 100 | +```cfm |
| 101 | +set(middleware = [new wheels.middleware.RequestId()]); |
| 102 | +``` |
| 103 | + |
| 104 | +### SecurityHeaders |
| 105 | + |
| 106 | +Adds OWASP-recommended security headers to every response. |
| 107 | + |
| 108 | +| Header | Default | |
| 109 | +|--------|---------| |
| 110 | +| `X-Frame-Options` | `SAMEORIGIN` | |
| 111 | +| `X-Content-Type-Options` | `nosniff` | |
| 112 | +| `X-XSS-Protection` | `1; mode=block` | |
| 113 | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | |
| 114 | + |
| 115 | +```cfm |
| 116 | +// Use defaults |
| 117 | +set(middleware = [new wheels.middleware.SecurityHeaders()]); |
| 118 | +
|
| 119 | +// Customize |
| 120 | +set(middleware = [ |
| 121 | + new wheels.middleware.SecurityHeaders( |
| 122 | + frameOptions = "DENY", |
| 123 | + referrerPolicy = "no-referrer" |
| 124 | + ) |
| 125 | +]); |
| 126 | +``` |
| 127 | + |
| 128 | +Set any parameter to an empty string to disable that header. |
| 129 | + |
| 130 | +### Cors |
| 131 | + |
| 132 | +Handles Cross-Origin Resource Sharing headers and OPTIONS preflight requests. |
| 133 | + |
| 134 | +```cfm |
| 135 | +set(middleware = [ |
| 136 | + new wheels.middleware.Cors( |
| 137 | + allowOrigins = "https://myapp.com,https://admin.myapp.com", |
| 138 | + allowMethods = "GET,POST,PUT,DELETE", |
| 139 | + allowHeaders = "Content-Type,Authorization", |
| 140 | + allowCredentials = true, |
| 141 | + maxAge = 86400 |
| 142 | + ) |
| 143 | +]); |
| 144 | +``` |
| 145 | + |
| 146 | +| Parameter | Default | Description | |
| 147 | +|-----------|---------|-------------| |
| 148 | +| `allowOrigins` | `"*"` | Comma-delimited list of allowed origins | |
| 149 | +| `allowMethods` | `"GET,POST,PUT,PATCH,DELETE,OPTIONS"` | Allowed HTTP methods | |
| 150 | +| `allowHeaders` | `"Content-Type,Authorization,X-Requested-With"` | Allowed request headers | |
| 151 | +| `allowCredentials` | `false` | Whether to allow cookies/auth headers | |
| 152 | +| `maxAge` | `86400` | Preflight cache duration in seconds | |
| 153 | + |
| 154 | +For simple CORS needs, you may prefer the existing [CORS Requests](cors-requests.md) guide which covers header-only approaches. |
| 155 | + |
| 156 | +## Writing Custom Middleware |
| 157 | + |
| 158 | +Create a CFC that implements `wheels.middleware.MiddlewareInterface`: |
| 159 | + |
| 160 | +```cfm |
| 161 | +// app/middleware/RateLimiter.cfc |
| 162 | +component implements="wheels.middleware.MiddlewareInterface" output="false" { |
| 163 | +
|
| 164 | + public RateLimiter function init(numeric maxRequests = 100) { |
| 165 | + variables.maxRequests = arguments.maxRequests; |
| 166 | + return this; |
| 167 | + } |
| 168 | +
|
| 169 | + public string function handle(required struct request, required any next) { |
| 170 | + // Check rate limit (pseudo-code) |
| 171 | + local.clientIp = cgi.remote_addr; |
| 172 | + if ($isRateLimited(local.clientIp)) { |
| 173 | + cfheader(statusCode="429", statusText="Too Many Requests"); |
| 174 | + return "Rate limit exceeded. Try again later."; |
| 175 | + } |
| 176 | +
|
| 177 | + // Continue to next middleware / controller |
| 178 | + return arguments.next(arguments.request); |
| 179 | + } |
| 180 | +
|
| 181 | + private boolean function $isRateLimited(required string ip) { |
| 182 | + // Your rate limiting logic here |
| 183 | + return false; |
| 184 | + } |
| 185 | +
|
| 186 | +} |
| 187 | +``` |
| 188 | + |
| 189 | +### Patterns |
| 190 | + |
| 191 | +**Enrich the request** — add data for downstream middleware or the controller: |
| 192 | + |
| 193 | +```cfm |
| 194 | +public string function handle(required struct request, required any next) { |
| 195 | + arguments.request.currentUser = $lookupUser(); |
| 196 | + return arguments.next(arguments.request); |
| 197 | +} |
| 198 | +``` |
| 199 | + |
| 200 | +**Short-circuit** — return a response without calling `next()`: |
| 201 | + |
| 202 | +```cfm |
| 203 | +public string function handle(required struct request, required any next) { |
| 204 | + if (!$isAuthenticated(arguments.request)) { |
| 205 | + cfheader(statusCode="401"); |
| 206 | + return "Unauthorized"; |
| 207 | + } |
| 208 | + return arguments.next(arguments.request); |
| 209 | +} |
| 210 | +``` |
| 211 | + |
| 212 | +**Post-process** — modify the response after the controller runs: |
| 213 | + |
| 214 | +```cfm |
| 215 | +public string function handle(required struct request, required any next) { |
| 216 | + local.startTick = GetTickCount(); |
| 217 | + local.response = arguments.next(arguments.request); |
| 218 | + local.elapsed = GetTickCount() - local.startTick; |
| 219 | + cfheader(name="X-Response-Time", value="#local.elapsed#ms"); |
| 220 | + return local.response; |
| 221 | +} |
| 222 | +``` |
| 223 | + |
| 224 | +## Middleware vs. Filters |
| 225 | + |
| 226 | +| | Middleware | Filters | |
| 227 | +|---|---|---| |
| 228 | +| **Runs at** | Dispatch level (before controller) | Controller level (inside controller) | |
| 229 | +| **Scope** | Global or route group | Single controller | |
| 230 | +| **Access to** | Request struct, response string | Controller instance, `params`, views | |
| 231 | +| **Best for** | Cross-cutting concerns (auth, headers, logging) | Controller-specific logic (load record, check permissions) | |
| 232 | + |
| 233 | +Both can coexist. A typical setup might use middleware for security headers and request IDs globally, then filters for loading specific records inside individual controllers. |
0 commit comments