Writing Your Own Middleware in Wheels 4.0 #3261
bpamiri
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
There's a moment in every Wheels app where you realise the thing you need doesn't belong in a controller. Timing every request and stamping the duration in a header. Denying traffic during a deploy window before any controller runs. Auditing every admin action without copy-pasting a
beforeFilterinto a dozen controllers. None of those is about a specific resource — they're about the request itself, the layer below your actions.That layer is middleware, and in 4.0 you can write your own. This post is a companion to the earlier rate-limiting walkthrough — that one covered consuming the built-in
RateLimiter; this one is authoring middleware from scratch.Read: https://blog.wheels.dev/posts/writing-custom-middleware-wheels-4
What the post covers
The surface is genuinely small, which is the appeal:
The one-method contract. Every middleware does
component implements="wheels.middleware.MiddlewareInterface"and writes a single method:public string function handle(required struct request, required any next). No base component, no lifecycle hooks. Code before you callnext()runs on the way in; code after runs on the way out; you return the stringnext()gave you (optionally after mutating headers).nextis a closure. Callingnext(request)runs the rest of the pipeline — every downstream middleware plus the controller — and returns the response as a string.requestis not the CFMLrequestscope. It's a plain struct Dispatch builds:{params, route, pathInfo, method}. This creates the single sharpest edge in the API — on Adobe CF therequestparameter shadows the enginerequestscope, so a barerequest.wheels.x = ...insidehandlewrites your passed struct, not the scope. The built-inRequestIdworks around it with a helper that has norequestparameter.The part that actually matters: the lifecycle
The reason the post exists is one rule: every middleware instance is a singleton, shared across every concurrent request, for the entire application lifetime. Global middleware is resolved once at Dispatch init; route-scoped string middleware is cached after first encounter. The same instance handles request #1 and request #50,000 — concurrently.
That makes thread-safety mandatory the moment you keep mutable state. The post builds an audit-logger whose request counter is mutated only inside a
cflock, mirroring how the built-inRateLimiterbacks its store with aConcurrentHashMapand locks every read-modify-write. Per-request data goes inlocalor on the request struct — never on the instance.Three worked components
RequestTiming— stamps anX-Response-Timeheader, with the flush-safetry/catcharoundcfheaderthat every built-in uses.AuditLog— thread-safe,cflock-guarded counter; also shows why a string-registered middleware needs aninit()that returnsthis(the resolver callsCreateObject(path).init()on string entries).MaintenanceMode— returns a 503 body without callingnext(), short-circuiting the controller during a deploy. Same techniqueRateLimiter(429) andCors(OPTIONS preflight) use.Registration
Both paths, and how they compose:
set(middleware=[...])inconfig/settings.cfm. Array order = execution order, outermost-to-innermost. Entries can be object instances (new wheels.middleware.Cors(...), used as-is) or string paths ("app.middleware.RequestTiming", instantiated + cached)..scope(path="/admin", middleware=[...])inconfig/routes.cfm. Accepts an array or a comma-string; nested scopes inherit the parent's middleware (parent runs first).Discussion
A few things I'd genuinely like to hear from people on:
request-scope-shadowing bug on Adobe CF? Did the helper-with-no-request-param workaround feel obvious, or is there a cleaner pattern you've used?Read the full post: https://blog.wheels.dev/posts/writing-custom-middleware-wheels-4
Beta Was this translation helpful? Give feedback.
All reactions