Customizing The Router In The CFWheels ColdFusion Framework
Like all of the ColdFusion frameworks, CFWheels provides a router that declaratively maps URLs onto Controller actions (the "C" in "MVC"). However, the convenience methods that Wheels provides out-of-the-box don't necessarily align with how I want to build a ColdFusion application. And so, I needed a way to customize the router (aka, the "mapper"). To do this, I created a proxy component that manipulates the mapper state internally and exposes a more Ben-friendly API.
This need to customize the router all started with the way in which I process form submissions. By default, Wheels really wants forms to be rendered and processed by two different controller actions. For example, rendering a new user form might declaratively map to one action (.new
):
GET /users/new
→ Users::new()
... and then submitting that form might declaratively map to a different action (.create
):
POST /users/new
→ Users::create()
I'm sure there's an academic reason for this control flow (Wheels is certainly not the only framework that does this); but, it's just not how my brain works. Instead, I want both the GET
and the POST
to be handled by the same controller and rendered by the same view.
Ideally, I'd have a convenience method on the router called .postback()
, which routes both GET
and POST
methods to the same controller. But, it's not entirely clear from the Wheels documentation how I might go about doing this. After a few different explorations, here's the approach that I came up with.
The global Wheels mapper()
function uses a fluent API. That is, you can continue to chain .
-methods on it, and it maintains state and route scoping internally. This is very nice to consume; but, not so easy to modify. To bridge the gap, I created a Mapper.cfc
ColdFusion component which invokes this fluent mapper()
API under the hood, but exposes it's own set of proxy methods.
In the Mapper.cfc
ColdFusion component, I define all of the same methods that the Wheels routing provides; and, by default, each of these methods turns around and invokes the underlying mapper()
state method using a private method, proxyMapperMethod()
:
Note: My
Mapper.cfc
extends thewheels.Global
component in order to have access to themapper()
method.
component | |
extends = "wheels.Global" | |
hint = "I provide a proxy for the underlying Wheels mapper() to allow for customized behavior." | |
{ | |
/** | |
* I initialize the mapper wrapper. | |
*/ | |
public void function init() { | |
variables.__wheels_mapper__ = mapper(); | |
} | |
// .... truncated .... // | |
/** | |
* I proxy the given method on the underlying Wheels mapper. | |
*/ | |
private any function proxyMapperMethod( | |
required string methodName, | |
required any methodArguments | |
) { | |
variables.__wheels_mapper__ = invoke( variables.__wheels_mapper__, methodName, methodArguments ); | |
// Always return THIS reference so we return the proxy and not the underlying | |
// Wheels mapper instance. | |
return this; | |
} | |
} |
If you look at the proxyMapperMethod()
code, you'll see that it provides it's fluent API — after it moves the mapper()
state forward (internally), it returns the this
reference to the proxy component. All of the public methods on this component extend that fluent API by propagating the return
value:
component | |
extends = "wheels.Global" | |
hint = "I provide a proxy for the underlying Wheels mapper() to allow for customized behavior." | |
{ | |
/** | |
* I initialize the mapper wrapper. | |
*/ | |
public void function init() { | |
variables.__wheels_mapper__ = mapper(); | |
} | |
// --- | |
// PUBLIC METHODS. | |
// --- | |
/** | |
* I proxy the method invocation for collection() on the underlying mapper. | |
*/ | |
public any function collection() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for constraints() on the underlying mapper. | |
*/ | |
public any function constraints() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for delete() on the underlying mapper. | |
*/ | |
public any function delete() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for end() on the underlying mapper. | |
*/ | |
public any function end() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for get() on the underlying mapper. | |
*/ | |
public any function get() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for member() on the underlying mapper. | |
*/ | |
public any function member() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for namespace() on the underlying mapper. | |
*/ | |
public any function namespace() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for package() on the underlying mapper. | |
*/ | |
public any function package() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for patch() on the underlying mapper. | |
*/ | |
public any function patch() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for post() on the underlying mapper. | |
*/ | |
public any function post() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for put() on the underlying mapper. | |
*/ | |
public any function put() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for resource() on the underlying mapper. | |
*/ | |
public any function resource() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for resources() on the underlying mapper. | |
*/ | |
public any function resources() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for root() on the underlying mapper. | |
*/ | |
public any function root() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I proxy the method invocation for scope() on the underlying mapper. | |
*/ | |
public any function scope() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
/** | |
* I return the underlying CFWheels mapper (that we're proxying). | |
*/ | |
public any function wheelsMapper() { | |
return __wheels_mapper__; | |
} | |
/** | |
* I proxy the method invocation for wildcard() on the underlying mapper. | |
*/ | |
public any function wildcard() { | |
return proxyMapperMethod( getFunctionCalledName(), arguments ); | |
} | |
// --- | |
// PRIVATE METHODS. | |
// --- | |
/** | |
* I proxy the given method on the underlying Wheels mapper. | |
*/ | |
private any function proxyMapperMethod( | |
required string methodName, | |
required any methodArguments | |
) { | |
variables.__wheels_mapper__ = invoke( variables.__wheels_mapper__, methodName, methodArguments ); | |
// Always return THIS reference so we return the proxy and not the underlying | |
// Wheels mapper instance. | |
return this; | |
} | |
} |
At this point, the Mapper.cfc
component does nothing more than the Wheels mapper()
— it provides the same fluent API. But, the mechanics are different. Since my Mapper.cfc
is a ColdFusion component, I can now sub-class the Mapper.cfc
definition, add new methods, and even augment the behavior of existing methods by leveraging the super
call-path.
For example, here's my sub-classed component, Routes.cfc
, which extends Mapper.cfc
and adds my postback()
method:
component | |
extends = "Mapper" | |
hint = "I provide custom behaviors for the Wheels mapper proxy." | |
{ | |
/** | |
* I provide a convenience method for defining routes that can post back to themselves. | |
*/ | |
public any function postback() { | |
return this | |
.get( argumentCollection = arguments ) | |
.post( argumentCollection = arguments ) | |
; | |
} | |
} |
As you can see, the postback()
method isn't doing anything more complex than calling .get()
and .post()
with the same set of arguments. But that's just it — it's a convenience method.
And, this Routes.cfc
component now opens the door to all kinds of extension. For example, if I wanted to omit the route slug for all .show
actions, I could just sub-class the .get()
behavior and explicitly define an empty pattern
:
component | |
extends = "Mapper" | |
hint = "I provide custom behaviors for the Wheels mapper proxy." | |
{ | |
public any function get( | |
string name, | |
string pattern | |
) { | |
// Don't require the detail pages to have a URL slug. | |
if ( ( arguments?.name == "show" ) && isNull( arguments.pattern ) ) { | |
arguments.pattern = ""; | |
} | |
return super.get( argumentCollection = arguments ); | |
} | |
} |
This is where the magic of the CFC-based mechanics come into play - I can still leverage the power of the core .get()
method by calling super.get()
after I've augmented the arguments
collection.
Using this Routes.cfc
instance is exactly the same as using the Wheels mapper()
function. The only difference lies in how the fluent API call chain is started:
<cfscript> | |
new Routes() | |
.resource( name = "users", nested = true ) | |
.postback( name = "show" ) | |
.end() | |
.end(); | |
</cfscript> |
CFWheels has some cool stuff; but, it also has some stuff that doesn't quick click with how I think about the world. But, the nice thins is that Wheels — and ColdFusion — make it relatively easy for me to apply my way of thinking through extension points.
Want to use code from this post? Check out the license.
Reader Comments
And all your code lies outside the
/wheels
directory?@Chris,
Yes, this was all in the
config
directory, right next to theroutes.cfm
-- I'm not sure if that's where this stuff always goes, but that's how we have it organized at work. At this point, I still have no idea what is native "Wheels" vs. what we've done at PAI.@Ben Nadel,
Very cool. I'm going to need to deep dive into this after my trip. I believe (most) everything native wheels is in the
/wheels
directory besides configs etc. Upgrading is just replace that directory.Post A Comment — ❤️ I'd Love To Hear From You! ❤️