Michael Pidde => { Programmer(); }

Routing

Over the past few evenings I've been taking a detour from other projects to finally throw together a simple program to manage a database of food pantry items. It records various data such as the date purchased, best by date, purchase price, weight, count, and so forth. While it would have been incredibly easy to throw Laravel at this problem, I decided as usual to reinvent the wheel in order to learn more about how the wheel is made in the first place. These types of little problems are fun to work through and are rarely necessary or suggested in everyday work code.

I've cobbled together crappy routers in the past, but I find that the one I built for this program, loosely emulating what's found in Laravel sans being OOP-centric or supporting nullable arguments, is fairly elegant and was easier to put together than I was originally anticipating. My acceptance criteria were simple: for a route definition such as "/food/{id}", match a URL like "/food/12" and pass a variable id = 12 in some way to the handler function. Also, be able to match and pass through multiple parameters, such as "/{controller}/{action}/{id}".

The first thing I wanted to do was iron out the public API behavior. I wanted to be able to define a series of routes by calling a route function, passing in the accepted HTTP method, the route pattern, and either a controller function or an inline closure. Ultimately, I'd be working with something like this:

route('GET', '/food/add', 'add_food');
route('POST', '/food/add', 'add_food_action');
route('GET', '/food/{id}', 'view_food');

The route function isn't doing anything incredibly complex. The logical steps are:

  1. If the actual request method doesn't match what the route expects, it's not a match.
  2. The pattern passed in needs to be parsed to pull out parameters, store them somewhere safe, and replace them with regex patterns. We do this via the get_parsed_pattern_and_parameters function which returns the original pattern (for future reference if wanted or needed), the replaced pattern, and the parameters in an array.
  3. A request context array is set up. This will ultimately hold all of the data about the current request that we want to be able to pass around in our program.
  4. The regex pattern is tested against the current request URI.
    1. If there are any match groups (which would be the case if it matched a route with parameters), get the values and set the key/value pairs accordingly in the request context.
  5. If there's a closure or a defined function that matches the callback, run it and pass the request context to it so any parameters can get passed on down the call stack.

Here's the entirety of the implementation:

function route($method, $pattern, $callback) {
    if($_SERVER['REQUEST_METHOD'] != $method) {
        return;
    }

    $parsed = get_parsed_pattern_and_parameters($pattern);
    $pattern = $parsed['pattern'];
    $request_context = [
        'request_pattern' => $pattern,
    ];

    $uri = $_SERVER['REQUEST_URI'];
    $matches = [];
    if(preg_match($pattern, $uri, $matches)) {
        if(count($matches) > 1) {
            // The first element is a throwaway for our purposes, it shows the entire pattern as a match.
            // We only need to worry about the groups, if there are any.
            array_shift($matches);

            for($i = 0; $i < count($matches); ++$i) {
                $request_context[$parsed['parameters'][$i]] = $matches[$i];
            }
        }
        
        if(is_callable($callback) || function_exists($callback)) {
            $callback($request_context);
            die;
        }
    }
}

function get_parsed_pattern_and_parameters($pattern) {
    $regex_part = '([\w\-]+)';
    $matches = [];
    $parameters = [];
    $original_pattern = $pattern;

    if(preg_match_all('/{[\w]+}/', $pattern, $matches)) {
        foreach($matches[0] as $match) {
            $pattern = str_replace($match, $regex_part, $pattern);
            $name = preg_replace('/[{}]/', '', $match);
            $parameters[] = $name;
        }
    }
    // Ending the regex with $ is very important. If it's not set as such,
    // a pattern of: /{id} will be matched with a URI of: /
    // This is false positive because we're expecting a URL parameter
    return [
        'original_pattern' => $original_pattern,
        'pattern' => '/' . str_replace('/', '\/', $pattern) . '$/',
        'parameters' => $parameters,
    ];
}

One last thing that seemed necessary was a catch-all or sorts. I added a simple default_route function to put at the end of the route list which accepts only one argument of a function name or closure. This will run if none of the prior routes were matched. In my case, I set this to run a 404 handler.