Skip to content

Typesafe web routes with Haxe in less than 100 lines

Andreas edited this page Aug 27, 2016 · 9 revisions

Haxe can provide an extra level of security with macros, enabling compile-time checking of code with practically no extra work.

Take web routes for example. Usually defined as strings, and a bit hard to keep track of in larger projects:

/customers
/customer/:id
/customer/:id/orders
/posts
/post/:date/:sort?
...etc

They are defined on the server, and called upon on the client through ajax requests, redirects or normal link clicks. A mistype or forgetting about a change can bring the system down, and having unit tests for every route combination is, as we know, wasteful.

Haxe and macros to the rescue! A route is quite simple in its essence. A slash-segmented string with constant values and parameters, some optional. As an array, it may be expressed as:

['customer', ':id', 'orders']

Now if we can call a route in a similar way...

['customer', customer.id, 'orders']

...we could verify that the call and the defined routes actually matches!

Defining the routes

We'd still like to keep the convenient string route format, the programmers will be happy for that. Fortunately with macros, we can parse the string and split it, building up a list for each defined route. Let's start a class for that:

#if macro
import haxe.macro.Context;
import haxe.macro.Expr;

using StringTools;
using Lambda;

private typedef RouteList = Array<Array<String>>;
#end

class Routes
{
	#if macro
	static var definedRoutes(default, null) : RouteList;
	#end

	macro public static function defineRoute(route : String) : Expr {
		var array = route.split('/');
		if (array[0].length == 0) array.shift(); // Removing initial slash

		function mapRoute(a : Array<String>) {
			definedRoutes.push(a.map(function(s) return s.startsWith(':') ? '' : s));
		}

		mapRoute(array);
		
		while (array.length > 0 && array[array.length - 1].endsWith('?')) {
			array.pop();
			mapRoute(array);
		}
		
		return macro $v{route};
	}	
}

This method transforms a route string to an array of segments. Constant segments are stored as-is, but parameters (segments starting with a colon) cannot be checked since they can contain any value, so they are left blank to signify "any value". Finally, optional segments are included by shortening the route list for each optional parameter. The string is returned to the compiler in its original form with return macro $v{route}.

The routes are stored in a static RouteList var. This is what we will compare against when compilation is finished.

This can now be used, for example if you're making an app with Node/Express, to define routes with a very small modification:

import Routes.defineRoute as route;

app.get(route('/customer/:id'), function(req, res) {
	// Serve customer data
});

app.get(route('/customer/:id/orders'), function(req, res) {
	// Serve customer orders
});

The macro will simply parse the routes and add them to the list for checking later, and return the string with no modifications.

Calling routes

Now when we can define routes, let's create a method for calling them. The input is slightly more complicated here however. There will be parameters sent at runtime, so we cannot use strings anymore. This is because a http request in the client could look like this:

var result = haxe.Http.requestUrl("/customer/" + customer.id);

But that argument would be sent to the macro as an EBinop expression, which can be a bit complicated to handle. So to keep things simple, passing an array instead is much better, since each element can be examined. But even better, we can make use of the Rest argument macro feature, building the route as a function with any number of arguments.

Lets also use the excellent warning/error feature, so the compiler will alert us on a problem at the correct place in the code. What we want is a Position mapped to an Array<String>.

Haxe has some problems with Position used in a Map, so it needs to be transformed using Context.getPosInfos first. Now let's write some code, finally!

// Position -> Array<String>
private typedef RouteCalls = Map<{min: Int, max: Int, file: String}, Array<String>>;

class Routes
{
	// ...continued

	#if macro
	static var calledRoutes(default, null) : RouteCalls;
	#end

	macro public static function callRoute(values : Array<Expr>) : Expr {
		var route = values.map(function(e) return switch(e.expr) {
			case EConst(CString(s)): s;
			case _: '';
		});
		if (route[0].length == 0) route.shift(); // Removing initial slash
		
		calledRoutes.set(Context.getPosInfos(Context.currentPos()), route);
		
		return macro '/' + $a{values}.join('/');
	}
}

It's similar to the defineRoute method, but in the end it will transform the array back to a string, because most libraries takes a URL string. This also provides some additional type-safety, since the compiler only allows Array<String> as input to this method.

Now the client http request will be written as:

import Routes.callRoute;
import mithril.M;

var result = haxe.Http.requestUrl(callRoute("customer", customer.id));

// Or using Mithril
M.request({
    method: "GET", 
    url: callRoute("customer", customer.id)
}).then(function(customer) {
    // Set view data
});

Initializing

Another slight issue is that depending on the compilation order, routes can actually be called before they are defined, or not defined/called at all on the client. So we need some lazy initialization for the definedRoutes and calledRoutes vars. That would also be a good place for listening to the "compilation finished" event.

	#if macro
	static var isInit = false;

	static function init() {
		isInit = true;
		definedRoutes = new RouteList();
		calledRoutes = new RouteCalls();
		Context.onAfterGenerate(testCalledRoutes);
	}
	#end

	macro public static function defineRoute(route : String) : Expr {
		if (!isInit) init();
		// ...
	}

	macro public static function callRoute(values : Array<Expr>) : Expr {
		if (!isInit) init();
		// ...
	}

Verifying the routes

We're now prepared for the final step, verifying the routes. This will be quite simple because we have a simple data format (arrays of strings). A problem is however, how to verify this on the client when the routes are defined on the server?

Haxe's serialization feature comes in handy here. We can check if there are no routes defined, and then load a file containing the routes. This is a very fast operation, reading a file and unserializing it, so there will be no performance issue. This is what it will look like:

static function testCalledRoutes() {
	var file = 'routes.txt';
	// Load routes if they haven't been defined, else save the current routes
	// this enables the class to work on both server and client.
	if(definedRoutes.length == 0) {
		// No routes defined, we're on client. Load the file.
		definedRoutes = cast Unserializer.run(File.getContent(file));
	} else {
		// We're on the server, save the routes to file.
		File.saveContent(file, Serializer.run(definedRoutes));
	}

Note: This assumes that your client and server project is located in the same directory, which makes sense if they are sharing data objects and other code. If they aren't, test for it and change the file path.

All that's left now is to check if the called routes matches the defined routes.

static function testCalledRoutes() {
	// ...continued

	var joinedRoutes = definedRoutes.map(function(a) return a.join('/'));
		
	for (pos in calledRoutes.keys()) {
		var route = calledRoutes.get(pos);
		var matched = joinedRoutes.has(route.join('/'));
			
		if (!matched)
			Context.error('Route not found', Context.makePosition(pos));
	}
}

Thanks to the simple data format, we can just join the arrays and test if they match. If there is no match, we use Context.error to halt compilation and show the position where the problematic route is located.

That's it, and it only turned out to be 85 lines of code, including a few comments. It cannot be said enough how powerful and expressive macros in Haxe are.

Full code gist here: https://gist.github.com/ciscoheat/6b95e09f851b28ea3db9dc87314cca9f