Custom Routes

Defining custom routes can serve several purposes. The primary purpose is to allow a URL to be mapped to a different internal address. Another primary purpose is to perform authorization checking on sections of a site. In Cora, custom routes are implemented by defining "paths" from a URL to some internal address.

Understanding Cora's Implementation

The code in Cora's Route class that pertains to hooking up custom defined routes looks like the below. It creates an empty array of paths called "$paths", loads "Cora\App\Paths.php" if it exists in the project, then finalizes "$paths" as the custom path array.

$paths = [];
if (file_exists($this->config['basedir'].'cora/app/Paths.php')) {
    include($this->config['basedir'].'cora/app/Paths.php');
}
$this->paths = $paths;

So in order to define custom routes for our app, all we need to do is create that file in our Cora\App directory and define $paths to be something that isn't an empty array.

Example Cora/App/Paths.php file:

<?php
// Create paths container
$paths = new \Cora\Container();

// Path 1
$path = new \Cora\Path();
    $path->url = '/users/login';
    $path->route = '/users/newLogin';
$paths->add($path);

// Path 2
$path = new \Cora\Path();
    $path->url = '/users/forgotPassword';
    $path->route = '/users/newForgotPassword';
$paths->add($path);

Alternate Syntax:

A quick note before we dive into things. The examples given in this document use regular object syntax, where each Path is an object, we define some properties on each, and then add them to our Paths collection. Alternatively, developers may prefer to pass in the path definition as an array to the constructor. This allows for some cleaner in-lining, and may be visually less cluttered to some peoples' eyes. The above example using this syntax looks like this:

 <?php
// Create paths container
$paths = new \Cora\Container();

// Path 1
$paths->add(new \Cora\Path([
    'url'   => '/users/login',
    'route' => '/users/newLogin'
]));

// Path 2
$paths->add(new \Cora\Path([
    'url'   => '/users/forgotPassword',
    'route' => '/users/newForgotPassword'
]));

Feel free to use that syntax if you prefer it.

Simple Internal Redirect

The simplest example of using a custom route is just going to be re-routing some URL to a different internal address. This takes defining a path with two attributes, the URL you want to match in the user's browser and the internal route you actually want executed.

$path = new \Cora\Path();
    $path->url = '/users/login';
    $path->route = '/users/newLogin';
$paths->add($path);

In this example, going to "www.MySite.com/users/login" will internally (not visible to the user) get redirected to "www.MySite.com/users/newLogin".

URL Variables

A more advanced feature of custom routes is the ability to use variables in the URL and internal Route. By default variables will capture letters, numbers, and underscores, but you can define them however you need.

In the below example, if a user were to go to:

MySite.com/blogs/politics-bjohnson

this would get redirected to an internal address of:

MySite.com/articles/personal/view/bjohnson/politics

Example:

$path = new \Cora\Path();
    $path->url = '/blogs/{category}-{person}';
    $path->route = '/articles/personal/view/{person}/{category}';
$paths->add($path);

Variables in the URL must have a separator of some sort between them. In the example above, the separator is a hyphen, but it could have just as easily been a forward slash like in the "route" attribute. Also notice that we reversed the order of {category} and {person} in the route. Once you've defined part of the input URL as a variable, you can reuse that variable however you like in the internal route.

The variables grabbed from the URL in this way will be available to the preMatch and preExec methods as an array named "vars". Using the example from above, it works like this to check if the category being viewed is politics:

$path->preExec = function($vars, $app) {
    if ($vars['category'] == 'politics') {
        return true; // grant access
    }
    return false; // deny access
};

Defining Variables

You can define variables in your URL using regular expressions if you need them to match a particular format. The most common use-case for this would likely be defining that a variable must be a number.

$path = new \Cora\Path();
    $path->url = '/blogs/{id}';
    $path->route = '/articles/personal/{id}';
    $path->def['{id}'] = '[0-9]+';
$paths->add($path);

In the above example we use a regular expression to define {id} as being a number.

{Anything} Variable

There's one special variable name which is predefined for you to match any character. That special variable is {anything}. {Anything} is useful when you don't care what the end of the URL is, but you do need to use it.

Say you have the following valid URLs as part of your app:

MySite.com/pets/dogs/retrievers
MySite.com/pets/dogs/terriers
MySite.com/pets/cats/persians/view/56

But you are re-organizing your site and decided to rename your Pets controller to Animals. The only problem is, you don't want to break links that people have bookmarked using the old version. A solution in this case would be to use the {anything} variable as a wildcard like so:

$path = new \Cora\Path();
    $path->url = '/pets/{anything}';
    $path->route = '/animals/{anything}';
    $path->passive = true;
$paths->add($path);

This would capture and redirect all the requests that start with Pets to instead use Animals, without having to worry about defining a specific route for each possible path that was available under Pets.

The "passive" attribute is likely something that would want to be used here, so it's included. You can read about it further down in this documentation.

HTTP Methods

By default, paths will be matched for all HTTP methods. Then once a path is matched and a route is executed, Cora's normal handling of HTTP methods will apply (a POST request being routed to methodPOST for example). However, both these behaviors can be changed on a path by path basis.

To make your path only match HTTP requests of a certain type, define an "actions" attribute on the path with the methods you want listed like so:

$path = new \Cora\Path();
    $path->url = '/users/test/{action}-{action2}';
    $path->route = '/home/view/{action2}/{action}';
    $path->actions = 'GET|POST';
$paths->add($path);

To disable Cora's RESTful routing behavior for a path, define an attribute "RESTful" as false:

$path = new \Cora\Path();
    $path->url = '/articles/api';
    $path->route = '/articles/api/create';
    $path->actions = 'POST';
    $path->RESTful = false;
$paths->add($path);

In the above example, a POST request to:

MySite.com/articles/api

Would get routed to the "Articles/Api" controller and "create" method (Not createPOST).

Route Authentication

So another popular use case for custom routes is going to be protecting certain features or sections of a site from un-authorized users. The way you can do that with custom routes is using the "preExec" attribute to define a function. If this preExec function returns false, execution of the path will be prevented.

In the example below "$app" is Cora's container. From it we are calling Cora's Auth library to check if a user is logged in. You can read-up on Cora's Auth library or use your own permission system if you like. Just remember that if this function returns false execution of the path will be prevented. The "$vars" parameter holds any variables defined in the URL (see URL Variables section).

Example:

$path = new \Cora\Path();
    $path->url = '/home/private';
    $path->route = '/home/view/protected/area';
    $path->preExec = function($vars, $app) {
        if (!$app->auth->access(new \Models\Auth\LoggedIn)) { return false; }
        return true;
    };
$paths->add($path);

Closely tied to route authentication is the use of Passive routes. So make sure to check out that section.

Passive Routes

Normally, the search for custom routes that match the URL ends after the first match found. However, when performing authentication for a route, you may not want this behavior. Say for instance that you have a "Private" controller and you want anything in that controller or in the Private subdirectory to be protected from unauthorized access. In this scenario you may want to check that a user has access, then have the search for custom routes continue afterwards. This is where "Passive" routes come into play.

First let's look at an example without a passive path that DOESN'T work, and explain why:

$path = new \Cora\Path();
    $path->url = '/dashboard/{anything}';
    $path->route = '/v2/dashboard/{anything}';
$paths->add($path);


$path = new \Cora\Path();
    $path->url = '/v2/dashboard/admin/{anything}';
    $path->preExec = function($vars, $app) {
        if ($app->auth->access(new \Models\Auth\IsAdmin)) { return true; }
        return false;
    };
$paths->add($path);

In the above example, there's two things going on. First, we are redirecting any requests for the Dashboard to instead use version 2 of the dashboard. Second, we are making sure any requests for access to the Admin area of the dashboard are checked for the proper permissions. However, this code will not work as desired. The problem is that in order to not waste processing power, Cora will stop at the first non-passive custom route it finds that matches the current URL. So because the "dashboard/{anything}" path is defined first, that will get matched for any request to the dashboard area and executed. It WILL properly redirect the request internally to V2, but then it will check for a valid Controller+Method combo without looking at the 2nd custom path that specifies the authentication check for the admin area.

The solution to this problem, is to add the "passive" attribute to the first path so that Cora will restart the search for matching custom paths after the internal route gets changed to v2. In the same example below with the passive attribute, cora will run the first path and change the route to v2, then restart the search through the custom paths collection and match the 2nd path with our authentication check, causing the code to work as desired:

$path = new \Cora\Path();
    $path->url = '/dashboard/{anything}';
    $path->route = '/v2/dashboard/{anything}';
    $path->passive = true;
$paths->add($path);


$path = new \Cora\Path();
    $path->url = '/v2/dashboard/admin/{anything}';
    $path->preExec = function($vars, $app) {
        if ($app->auth->access(new \Models\Auth\IsAdmin)) { return true; }
        return false;
    };
$paths->add($path);

Preventing Infinite Loops

(You don't need to do anything for this preventive measure, but I wanted to explain how it works.)

Once a path has been run, it is added to a list so that subsequent searches through the paths array for the current request will not trigger it again. This prevents possible infinite loops. This wouldn't affect our example above because we change the route to "v2" in the first path, but imagine if we didn't change the route at all, and instead just checked that a user trying to access the dashboard is logged in:

// Check that only logged in users can access dashboard.
$path = new \Cora\Path();
    $path->url = '/dashboard';
    $path->preExec = function($vars, $app) {
        if ($app->auth->access(new \Models\Auth\IsLoggedIn)) { return true; }
        return false;
    };
    $path->passive = true;
$paths->add($path);


// Check that only Admins can access dashboard admin area.
$path = new \Cora\Path();
    $path->url = '/dashboard/admin';
    $path->preExec = function($vars, $app) {
        if ($app->auth->access(new \Models\Auth\IsAdmin)) { return true; }
        return false;
    };
$paths->add($path);

In this example, we didn't change the internal route being looked at, so it remains whatever the public URL was. So if a user tries to access the dashboard, it will match the first path which will execute the check that the user is logged in, then because the path is passive, it restarts the search for custom paths. The 2nd time through, the route still hasn't changed so it matches the first path AGAIN. If we allowed the first path to run, it would put us in an infinite loop. However, because a list of executed paths is kept, the Router will check the list and know to bypass the first path the 2nd time through.

Path Priority

By default, custom routes are matched in the order they are defined from top to bottom. Once a match has been found for the current URL, that path is executed and the search for additional matching paths is cancelled. For this reason, in practical use-case situations you either have to use the "passive" attribute on paths or make sure you define the most specific paths at the top.

Example that doesn't work:

// Check that only logged in users can access dashboard.
$path = new \Cora\Path();
    $path->url = '/dashboard';
    $path->preExec = function($vars, $app) {
        if ($app->auth->access(new \Models\Auth\IsLoggedIn)) { return true; }
        return false;
    };
$paths->add($path);

// Check that only Admins can access dashboard admin area.
$path = new \Cora\Path();
    $path->url = '/dashboard/admin';
    $path->preExec = function($vars, $app) {
        if ($app->auth->access(new \Models\Auth\IsAdmin)) { return true; }
        return false;
    };
$paths->add($path);

The first path defined would get matched to any request to the dashboard area of the site, which would check that a user is logged in, and if that passes would immediately check for a matching Controller+Method pair and execute the route. This would allow ANY user who is logged in to access the Dashboard/Admin area because the 2nd path never gets checked.

Fix Option #1:

// Check that only Admins can access dashboard admin area.
$path = new \Cora\Path();
    $path->url = '/dashboard/admin';
    $path->preExec = function($vars, $app) {
        if ($app->auth->access(new \Models\Auth\IsAdmin)) { return true; }
        return false;
    };
$paths->add($path);

// Check that only logged in users can access dashboard.
$path = new \Cora\Path();
    $path->url = '/dashboard';
    $path->preExec = function($vars, $app) {
        if ($app->auth->access(new \Models\Auth\IsLoggedIn)) { return true; }
        return false;
    };
$paths->add($path);

What we've done above to fix the situation is move the MORE SPECIFIC path to the top so it gets run if there's a match instead of the less specific path. In this case, any request to the Dashboard/Admin area will match the first path and execute that, and any request to an area of the dashboard that isn't the admin area would fall through to the 2nd path.

Fix Option #2:

// Check that only logged in users can access dashboard.
$path = new \Cora\Path();
    $path->url = '/dashboard';
    $path->preExec = function($vars, $app) {
        if ($app->auth->access(new \Models\Auth\IsLoggedIn)) { return true; }
        return false;
    };
    $path->passive = true;
$paths->add($path);

// Check that only Admins can access dashboard admin area.
$path = new \Cora\Path();
    $path->url = '/dashboard/admin';
    $path->preExec = function($vars, $app) {
        if ($app->auth->access(new \Models\Auth\IsAdmin)) { return true; }
        return false;
    };
$paths->add($path);

Here, instead of moving the more specific path to the top of the page, we've added the "passive" attribute to the first path. This causes the search for custom paths to restart after processing the first path, which would make a request to the Admin area get caught by the 2nd path. This allows us to have less specific paths at the top, which fall through to more specific paths below.

Advanced Routes

So to fill out the routing capabilities for situations that require a little bit more complexity than is covered by the other sections of this documentation, the Route class offers the "preMatch" callback and the "args" property.

Explained: "preMatch" Callback

First let's talk about the preMatch() callback. This method is similar to the preExec() callback, except instead of getting called after a path has been matched, preMatch gets called after the Router finds what looks like a matching path, but before it's officially considered a match (which would then stop the search for additional paths and trigger preExec). If the preMatch callback returns true, then the path gets locked in as a match, if it returns false, then the potential path is rejected and the search continues.

$path = new \Cora\Path();
    $path->url = '/api/member/{id}/{anything}';
    $path->route = '/api/member/{anything}';
    $path->preMatch = function($vars, $app) {
        if ($app->loggedInUser->canViewMember($vars['id'])) {
            return true; // Causes Path to get matched and execute.
        }
        return false; // Rejects this path and search continues.
    };
$paths->add($path);

It's important to note that while returning false from the preExec callback causes a 403 Access Denied response, returning false from the preMatch callback simply rejects the possible path in question. If no other matching Path is found, the user will end up getting a 404 Not Found response. In this sense, preExec let's a user know the path exists, but they aren't being given access, while preMatch can hide the existance of the path altogether.

Explained: "args" Callback

So the "args" callback is an interesting feature and I haven't made up my mind on whether it's really useful... but I'll explain the reasoning behind it and how it works, and let you be the judge.

Normally, Controllers can't really be unit tested, as they just handle user input, pass off logic processing to other backend classes (which can be unit tested), and return some output. If you are testing the controllers, it's probably through a front-end tool like Selenium. The thought I had, which inspired this feature, was what I could do to make Controllers more testable by way of unit testing.

In developing apps... depending on how thin I made the controllers, the usage of the ADM ORM, etc, I could get in situations where I felt like unit testing the controller made sense as opposed to making the controller method any thinner. Here's a hypothetical example below where I use ADM to grab data models and I return them as JSON:

public function index($category, $year, $month)
{
    // Grab database query object
    $query = $this->app->articles->getDb();

    // Set query parameters
    $query->where('category', $category)
          ->where('year', $year)
          ->where('month', $month);

    // Grab GET variables from query string in URL
    $sortField = $this->input->get('sortField');
    $sortDirection = $this->input->get('sortDirection');

    // Optionally order the data as needed
    if ($sortField && $sortDirection) {
        $query->orderBy($sortField, $sortDirection);
    }

    // Fetch matching articles
    $articles = $this->app->articles->findAll($query);

    // Return results
    echo $articles->toJson();
}

This is a prime example of Controller code that feels like it could benefit from unit testing if left as-is. Now we COULD make this Controller thinner by offloading the calls to the ORM to an intermediate logic layer:

public function index($category, $year, $month)
{
    // Grab GET variables from query string in URL
    $sortField = $this->input->get('sortField');
    $sortDirection = $this->input->get('sortDirection');

    // Fetch matching articles
    $articles = $this->app->articleManager->findAll($category, $year, $month, $sortField, $sortDirection);

    // Return results
    echo $articles->toJson();
}

Then we could unit test the findAll method in our new "articleManager" class and accomplish unit testing that way. This IS the normal way I would handle the situation, and definitely what I would recommend any developer reading this guide do. The reason being, you'll probably have other logic tied to who can edit and delete articles, etc, where it would make sense to have that logic be portable and not tied to a specific controller endpoint. This would also make your code testable outside the context of the Cora Framework. However, since all the work to fetch the articles is already being handled by the ORM classes, the articleManager in this case might end up feeling like it's mostly a pointless middleman.

Let's say you don't care about code portability, and you'd rather reduce redundancy and just have the calls to the ORM live directly in the Controller like our original example.

The problem you run into is those pesky global references to $_GET variables. Yes, you COULD set any needed globals when running a unit test, but it just feels dirty. What I ended up deciding I wanted to accomplish, was to have a way to eliminate global variables from Controllers and instead have all user input passed in as method arguments. THAT is where the "args" callback comes into play.

The args callback should return an array of arguments to be passed into the controller method resolved from the path.

Example

If your path is defined as:

$path = new \Cora\Path();
    $path->url = '/api/v1.0/articles/{category}/{year}/{month}/';
    $path->route = '/api/v1.0/articles/view/{category}/{year}/{month}/';
    $path->args = function($vars, $app) {
        return [$vars['category'], $vars['year'], $vars['month'], $_GET['sortField'], $_GET['sortDirection']];
    };
$paths->add($path);

You go to the following URL in the browser:

MySite.com/api/v1.0/articles/politics/2017/06/?sortField=title&sortDirection=desc

and then your controller method looks like so, the result will be as commented:

public function view($category, $year, $month, $sortField = 'author', $sortDirection = 'asc') 
{
    echo "$category<br>";       // Will output "politics"
    echo "$year<br>";           // Will output "2017"
    echo "$month<br>";          // Will output "06"
    echo "$sortField<br>";      // Will output "title"
    echo "$sortDirection";      // Will output "desc"
}

In-case you missed it, what we did was pull the globals out of the Controller method and instead inject them as part of the method signature. The controller is now free from global variables, and we can even set default values. Our little API Controller method is now in a place where we could easily write some unit tests for it.

Is this useful? Like I said, I still haven't decided. But there you have it.