The FW/1 plugin architecture for Mura CMS is a very powerful way of embedding complex applications and business logic within your Mura-based website.

However, this comes at the cost of your SES URLs. While Mura itself generates human- and search engine-friendly URLs, your embedded FW/1 app will only have this for the top level of any display object. As you drill down into the app, you will start getting URLs that look like this:

http://example.com/statistics/?MyPluginaction=statistics:main.player&playername=seb-duggan

In this example, the FW/1 app is placed in Mura on the page /statistics/ as a plugin display object. In an ideal world (and how it would probably be if it were a standalone FW/1 site) your URL would be more like:

http://example.com/statistics/player/seb-duggan/

So, how can we achieve this?

One way would be by setting up a bunch of .htaccess rules, and this would be a perfectly workable solution. But what happens when you decide to rename statistics to stats? Wouldn’t it be nice if the URLs were completely controlled by the plugin with no other dependencies, and if the plugin were smart enough to know how to route the URLs wherever it has been placed within the Mura site structure?

After a whole lot of trial-and-error, and whole lot more refactoring, I believe I’ve come up with a pretty robust (and reasonably elegant) solution…

The starting point

All my examples are loosely based on a site I’ve just built for my local cricket club (go and take a look if you like – and see if you can spot where the FW/1 app integrates!).

The normal way to set up an FW/1 plugin would be something like this:

config.xml.cfm

<displayobjects location="global">
	<displayobject
		name="Statistics"
		component="includes.displayObjects"
		displaymethod="dspStatistics"
		persist="false" />
</displayobjects>

displayObjects.cfc

public any function dspStatistics($) {
	return getApplication().doAction("statistics:main.default");
}

This sets up a display object called “Statistics” which can be included into your Mura page. When rendered, it runs the dspStatistics() method, which in turn renders the FW/1 page main.default in the statistics subsystem.

So far, so easy, and if you’ve created an FW/1 plugin before, it should all be very familiar.

Adding the SES

The key to the new system is adding some route definitions to your displayObject configuration file:

config.xml.cfm

<displayobjects location="global">
	<displayobject
		name="Statistics"
		component="includes.displayObjects"
		displaymethod="dspStatistics"
		persist="false">
		<route
			pattern="batting"
			action="statistics:main.batting" />
		<route
			pattern="batting/{'[0-9]{4}'}"
			action="statistics:main.batting" />
		<route
			pattern="player"
			action="statistics:main.playerredirect" />
		<route
			pattern="player/{member.filename}"
			action="statistics:main.player" />
		<route
			pattern="player/{member.filename}/detail"
			action="statistics:main.playerdetail" />
	</displayobject>
</displayobjects>

This doesn’t affect the basic functionality of the definition, but it adds in more data that we can extract and use.

You’ll see several types of route in the example above. The route’s pattern is what gets matched against that part of the URL that comes after the plugin integration point. For example, if you have your plugin object included in the page /statistics/, the first route would match the URL /statistics/batting/.

Parts of the URL that can vary are contained in curly braces. If the content of the curly braces is itself wrapped in single or double quotes, as in the second route, then this is a regular expression. So the second route would match /batting/2016/ (the regex matches a pattern of 4 digits).

If the pattern within the braces is not wrapped in quotes, then this is a value to validate against a bean. So, the fourth route would match against /statistics/player/seb-duggan/ - and would additionally validate that there is a member bean with a filename value of seb-duggan.

Each route has a fully-qualified FW/1 action, the page which should be rendered if the route is matched.

Writing it out like this, it seems quite complicated, but it’s really very simple!

The heavy lifting

There are a bunch of methods which need to be added to your plugin’s eventHandler.cfc to make all this work. I choose to keep them all in a separate file and include them into the eventHandler, but that’s up to you…

private function getSesRoutes() {
	var objectsLen = 0;
	var displayObject = "";
	var result = {};
	var route = {};
	var routes = [];
	var pluginXML = variables.pluginConfig.getPluginManager().getPluginXML(variables.pluginConfig.getModuleId());

	if ( structKeyExists(pluginXML.plugin.displayobjects,"displayobject") ) {
		objectsLen = arrayLen(pluginXML.plugin.displayobjects.displayobject);
		if (objectsLen) {
			for (var i=1; i<=objectsLen; i++) {
				displayObject = pluginXML.plugin.displayobjects.displayobject[i];
				if (structKeyExists(displayObject,"route")) {
					routes = [];
					for (var r=1; r<=arraylen(displayObject.route); r++) {
						route = {};
						route["pattern"] = displayObject.route[r].xmlAttributes.pattern;
						route["action"] = displayObject.route[r].xmlAttributes.action;
						arrayAppend(routes, route);
					}
					result[displayObject.xmlAttributes.displaymethod] = routes;
				}
			}
		}
	}
	return result;
}

private function getSesUrlMatches(required event) {
	var qs = getQueryService();
	var sql = "";
	var dbtype = application.configBean.getDBType();
	var lengthFunction = "";

	if (dbtype == "mssql") {
		lengthFunction = "datalength";
	} else if (listfindnocase("mysql,oracle", dbtype)) {
		lengthFunction = "length";
	} else if (dbtype == "postgresql") {
		lengthFunction = "char_length";
	} else if (dbtype == "nuodb") {
		lengthFunction = "character_length";
	}

	sql = "SELECT
			tcontent.filename,
			tplugindisplayobjects.displaymethod
		FROM
			tplugindisplayobjects
		INNER JOIN
			tcontentobjects
		ON
			tplugindisplayobjects.objectid = tcontentobjects.objectid
		INNER JOIN
			tcontent
		ON
			tcontentobjects.contenthistid = tcontent.contenthistid
		AND
			tcontent.active = 1
		WHERE
			tplugindisplayobjects.moduleid = :moduleid
		AND
			tplugindisplayobjects.displaymethod IN (:displaymethods)
		AND
			tcontent.siteid = :siteid
		AND
			left(:fullurl, #lengthfunction#(tcontent.filename)) = tcontent.filename
		ORDER BY
			#lengthfunction#(tcontent.filename) DESC";
	qs.addParam(name="moduleid", value=variables.pluginConfig.getModuleId(), cfsqltype="cf_sql_varchar");
	qs.addParam(name="siteid", value=arguments.event.getValue("siteid"), cfsqltype="cf_sql_varchar");
	qs.addParam(name="displaymethods", value=structkeylist(variables.pluginConfig.getApplication().getValue("sesRoutes")), list=true, cfsqltype="cf_sql_varchar");
	qs.addParam(name="fullurl", value=arguments.event.getValue("currentfilenameadjusted"), cfsqltype="cf_sql_varchar");

	return qs.execute(sql=sql).getResult();
}

private function validateSesUrlParams(required $, required event, required displayMethod, required sesUrlParts) {
	var routes = variables.pluginConfig.getApplication().getValue("sesRoutes");
	var sesUrlParams = [];

	if (not structkeyexists(routes, arguments.displayMethod)) {
		return false;
	}
	for (var route in routes[arguments.displayMethod]) {
		var routeParts = listToArray(route.pattern, "/");
		if (arrayLen(routeParts) != arrayLen(arguments.sesUrlParts)) {
			continue;
		}

		var match = true;
		for (var i=1; i<=arrayLen(routeParts); i++) {
			if (reFind("^{[""''].+[""'']}$", routeParts[i])) {
				match = validateSesUrlPartRegex($, routeParts[i], arguments.sesUrlParts[i]);
				arrayAppend(sesUrlParams, arguments.sesUrlParts[i]);
			} else if (reFind("^{.+}$", routeParts[i])) {
				match = validateSesUrlPartBean($, routeParts[i], arguments.sesUrlParts[i]);
				arrayAppend(sesUrlParams, arguments.sesUrlParts[i]);
			} else if (routeParts[i] != arguments.sesUrlParts[i]) {
				match = false;
				break;
			}
		}
		if (!match) {
			continue;
		}

		arguments.event.setValue("sesUrlParams", sesUrlParams);
		arguments.event.setValue("sesAction", route.action);
		return true;
	}
	return false;
}

private function validateSesUrlPartRegex(required $, required pattern, required urlPart) {
	arguments.pattern = reReplace(arguments.pattern, "^{[""''](.+)[""'']}$", "\1");

	return refindNoCase("^" & arguments.pattern & "$", arguments.urlPart);
}

private function validateSesUrlPartBean(required $, required pattern, required urlPart) {
	arguments.pattern = reReplace(arguments.pattern, "^{(.+)}$", "\1");
	var entity = listFirst(arguments.pattern, ".");
	var column = listLast(arguments.pattern, ".");

	var checkBean = arguments.$.getBean(entity).loadBy("#column#" = arguments.urlPart);
	return !checkBean.getIsNew();
}

The first method, getSesRoutes(), should be called from the eventHandler’s onApplicationLoad() method:

variables.pluginConfig.getApplication().setValue( "sesRoutes", getSesRoutes() );

This examines the displayObject config file and extracts all the routes for each displayObject, and we cache that in the plugin’s application scope.

We then need to handle the SES URLs by setting up a 404 handler in the eventHandler. By default, Mura will not recognise the URLs we’re trying to catch, and so onSite404() will be invoked:

public any function onSite404(required $, required event) {
	var qMatches = getSesUrlMatches(arguments.event);
	if (qMatches.recordcount) {
		var sesUrlParts = listtoarray(replace(event.getValue("currentfilenameadjusted"), qMatches.filename, ""), "/");
		if (validateSesUrlParams($, event, qMatches.displaymethod, sesUrlParts)) {
			local.contentBean = application.contentManager.getActiveContentByFilename(qMatches.filename, event.getValue('siteid'));
			event.setValue('contentBean', local.contentBean);
		}
	}
}

This is where all the clever stuff happens. First, getSesUrlMatches() looks to see if some part at the beginning of the URL matches any of the Mura pages that include one of the displayObjects with routes defined on them.

If a match is found then we pass the remainder of the URL through and try to match it against the defined routes. We check each section of the URL in turn; if the URL part is a {variable}, then it’s either matched as a regular expression, or the value is checked against the specified bean. Each variable value is appended to an array for use later by the controller methods.

If a route is successfully matched, then the array of variables (sesUrlParams) and the FW/1 action defined on the route (sesAction) are added to the Mura event object for use later by the FW/1 app; and finally, we load in the content for the Mura page we identified earlier and add that to the event object too.

If no match is found, then we simply fall through to the main Mura 404 page, so you don’t need to maintain a separate 404 page in your FW/1 app. And becasue of the way we validate the value of the bean value variable, we already know before going into the FW/1 app if the value passed is a valid one.

The last thing that needs to be modified is the displayObjects method:

public any function dspStatistics($) {
	var action = $.getEvent().getValue("sesAction", "statistics:main.default");
	return getApplication().doAction(action);
}

Here, we are getting the controller action from the event object which will have been set if a route has matched; and if that doesn’t exist, we default to the original action we had at the start.

Finally, you need to configure your FW/1 controller methods to accept the values we’ve extracted from the URLs. Simply add the following to any method that is expecting one or more variables from an SES URL:

var urlParams = rc.$.getEvent().getValue( "sesUrlParams", [] );

…and you have your array of each of the values matched in the URL. So, in my example, if I requested the page:

http://example.com/statistics/player/seb-duggan/detail/

…I would get the content rendered by the controller action statistics:main.playerdetail, and urlParams would contain one value, “seb-duggan”.

To wrap up

It may seem complicated, but most of it is code that you can add and forget about. The configuration is contained within config.xml.cfm and displayObjects.cfc, just as it was previously, and your FW/1 controller methods will get any variables they need from the event object.

Caveats

  1. I’ve written and tested this on Lucee. The only reason it wouldn’t work on Adobe CF is if I’ve used some Lucee-specific syntax. But I don’t think I have.
  2. I really don’t know what would happen if you had two plugins on the same Mura page, both of which were expecting to match an SES route. If for some reason you have this requirement, please let me know how you get on!
  3. My plugin was written using Mura ORM, and so my validation works against Mura ORM beans. This could be easily modified to check against a table and column, or you could just use regular expressions and do all the data validation within the FW/1 app - but then you would lose the ability to fall through to Mura’s 404 page.