-
Notifications
You must be signed in to change notification settings - Fork 12
Typesafe web routes with Haxe in less than 100 lines
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!
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.
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
});
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();
// ...
}
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