HMVC Routing Ambiguity

I have a routing mechanism that sends requests based on the file system structure:

function Route($root) { $root = realpath($root) . '/'; $segments = array_filter(explode('/', substr($_SERVER['PHP_SELF'], strlen($_SERVER['SCRIPT_NAME'])) ), 'strlen'); if ((count($segments) == 0) || (is_dir($root) === false)) { return true; // serve index } $controller = null; $segments = array_values($segments); while ((is_null($segment = array_shift($segments)) !== true) && (is_dir($root . $controller . $segment . '/'))) { $controller .= $segment . '/'; } if ((is_file($controller = $root . $controller . $segment . '.php')) { $class = basename($controller . '.php'); $method = array_shift($segments) ?: $_SERVER['REQUEST_METHOD']; require($controller); if (method_exists($class = new $class(), $method)) { return call_user_func_array(array($class, $method), $segments); } } throw new Exception('/' . implode('/', self::Segment()), 404); // serve 404 } 

Basically, he tries to match as many segments of URLs in directories as he can by matching the next segment with the actual controller ( .php file with the same name). If more segments are provided, the first defines the action to invoke (return to the HTTP method), and the rest as action arguments.

The problem is that (depending on the structure of the file system) there are some ambiguities. Consider this:

 - /controllers - /admin - /company - /edit.php (has get() & post() methods) - /company.php (has get($id = null) method) 

Now the ambiguity is when I turn to domain.tld/admin/company/edit/ , the edit.php controller executes the request (as it should), however access to domain.tld/admin/company/ via GET or domain.tld/admin/company/get/ directly raises a 404 error because the company segment was mapped to the corresponding directory, although the other segments do not have a mapping in the file system. How can I solve this problem? Preferably without putting too much effort on the disk.

There are already many questions in SO related to this problem, I looked at some of them, but I could not find a single answer that would provide a reliable and effective solution.

+4
source share
3 answers

For critical things like this, it's really important to write a test with a test Framework such as PHPUnit.

Install it as described here (you need a pear): https://github.com/sebastianbergmann/phpunit/

I also use a virtual file system, so your test folder will not get caught: https://github.com/mikey179/vfsStream/wiki/Install

I just dumped your Route function into a file called Route.php . In the same directory, I created a test.php file with the following contents:

 <?php require_once 'Route.php'; class RouteTest extends PHPUnit_Framework_TestCase { } 

To check if everything works, open a command prompt and do the following:

 $ cd path/to/directory $ phpunit test.php PHPUnit 3.7.13 by Sebastian Bergmann. F Time: 0 seconds, Memory: 1.50Mb There was 1 failure: 1) Warning No tests found in class "RouteTest". FAILURES! Tests: 1, Assertions: 0, Failures: 1. 

If it looks, PHPUnit is installed correctly, and you are ready to write tests.

To make the route function more reliable and less attached to the server, and the file system, I changed it a little:

 // new parameter $request instead of relying on server variables function Route($root, $request_uri, $request_method) { // vfsStream doesn't support realpath(). This will do. $root .= '/'; // replaced server variable with $request_uri $segments = array_filter(explode('/', $request_uri), 'strlen'); if ((count($segments) == 0) || (is_dir($root) === false)) { return true; // serve index } $controller = null; $all_segments = array_values($segments); $segments = $all_segments; while ((is_null($segment = array_shift($segments)) !== true) && (is_dir($root . $controller . $segment . '/'))) { $controller .= $segment . '/'; } if (is_file($controller = $root . $controller . $segment . '.php')) { $class = basename($controller . '.php'); // replaced server variable with $request_method $method = array_shift($segments) ?: $request_method; require($controller); if (method_exists($class = new $class(), $method)) { return call_user_func_array(array($class, $method), $segments); } } // $all_segments variable instead of a call to self:: throw new Exception('/' . implode('/', $all_segments), 404); // serve 404 } 

Let's add a test to check if the function returns true if a pointer route is requested:

 public function testIndexRoute() { $this->assertTrue(Route('.', '', 'get')); $this->assertTrue(Route('.', '/', 'get')); } 

Since your test class extends PHPUnit_Framework_TestCase , you can now use methods such as $this->assertTrue to verify that a particular statement evaluates to true. Restart it again:

 $ phpunit test.php PHPUnit 3.7.13 by Sebastian Bergmann. . Time: 0 seconds, Memory: 1.75Mb OK (1 test, 2 assertions) 

Passed this test! array_filter if array_filter correctly removes empty segments:

 public function testEmptySegments() { $this->assertTrue(Route('.', '//', 'get')); $this->assertTrue(Route('.', '//////////', 'get')); } 

It also allows you to check if a pointer route is requested if the $root directory for routes does not exist.

 public function testInexistentRoot() { $this->assertTrue(Route('./inexistent', '/', 'get')); $this->assertTrue(Route('./does-not-exist', '/some/random/route', 'get')); } 

To test more than this, we need files containing classes with methods. Therefore, let us use our virtual file system to configure the directory structure of files before starting each test.

 require_once 'Route.php'; require_once 'vfsStream/vfsStream.php'; class RouteTest extends PHPUnit_Framework_TestCase { public function setUp() { // intiialize stuff before each test } public function tearDown() { // clean up ... } 

PHPUnit has special methods for this kind of thing. The setUp method setUp run before each test method in this test class. And the tearDown method after executing the test method.

Now I create the Structure directory using vfsStream. (If you are looking for a tutorial for this: https://github.com/mikey179/vfsStream/wiki is a pretty good resource)

  public function setUp() { $edit_php = <<<EDIT_PHP <?php class edit { public function get() { return __METHOD__ . "()"; } public function post() { return __METHOD__ . "()"; } } EDIT_PHP; $company_php = <<<COMPANY_PHP <?php class company { public function get(\$id = null) { return __METHOD__ . "(\$id)"; } } COMPANY_PHP; $this->root = vfsStream::setup('controllers', null, Array( 'admin' => Array( 'company' => Array( 'edit.php' => $edit_php ), 'company.php' => $company_php ) )); } public function tearDown() { unset($this->root); } 

vfsStream::setup() now creates a virtual directory with the specified file structure and given file contents. And, as you can see, I will allow my controllers to return the method name and parameters as a string.

Now we can add some more tests to our test suite:

 public function testSimpleDirectMethodAccess() { $this->assertEquals("edit::get()", Route(vfsStream::url('controllers'), '/controllers/admin/company/edit/get', 'get')); } 

But this time the test fails:

 $ phpunit test.php PHPUnit 3.7.13 by Sebastian Bergmann. ... Fatal error: Class 'edit.php.php' not found in C:\xampp\htdocs\r\Route.php on line 27 

So, something is wrong with the $class variable. If we now check the next line in the route function with a debugger (or some echo s).

 $class = basename($controller . '.php'); 

We can see that the $controller variable contains the correct file name, but why does the .php application exist? These seams should be a common mistake. I think it should be:

 $class = basename($controller, '.php'); 

Because it removes the .php extension. And we get the correct class name edit .

Now let's test if an exception occurs if we request a random path that does not exist in our directory structure.

 /** * @expectedException Exception * @expectedMessage /random-route-to-the/void */ public function testForInexistentRoute() { Route(vfsStream::url('controllers'), '/random-route-to-the/void', 'get'); } 

PHPUnit automatically reads these comments and checks if an exception of type Exception occurs when this method is executed, and if the exception message was /random-route-to-the/void

These are the seams for the job. Checks if the $request_method parameter works correctly.

 public function testMethodAccessByHTTPMethod() { $this->assertEquals("edit::get()", Route(vfsStream::url('controllers'), '/admin/company/edit', 'get')); $this->assertEquals("edit::post()", Route(vfsStream::url('controllers'), '/admin/company/edit', 'post')); } 

If we run this test, we will face another problem:

 $ phpunit test.php PHPUnit 3.7.13 by Sebastian Bergmann. .... Fatal error: Cannot redeclare class edit in vfs://controllers/admin/company/edit.php on line 2 

It looks like we are using include / require several times for the same file.

 require($controller); 

Lets change it to

 require_once($controller); 

Now consider your problem and write a test to verify that the company directory and company.php do not interfere with each other.

 $this->assertEquals("company::get()", Route(vfsStream::url('controllers'), '/admin/company', 'get')); $this->assertEquals("company::get()", Route(vfsStream::url('controllers'), '/admin/company/get', 'get')); 

And here we get exception 404, as you stated in your question:

 $ phpunit test.php PHPUnit 3.7.13 by Sebastian Bergmann. .....E. Time: 0 seconds, Memory: 2.00Mb There was 1 error: 1) RouteTest::testControllerWithSubControllers Exception: /admin/company C:\xampp\htdocs\r\Route.php:32 C:\xampp\htdocs\r\test.php:69 FAILURES! Tests: 7, Assertions: 10, Errors: 1. 

The problem here is that we do not know when to enter the subdirectory and when to use the controller in the .php file. Therefore, we need to indicate exactly what you want. And I assume the following, because it makes sense.

  • Enter the subdirectory only if the controller does not contain the requested method.
  • If neither the controller nor the subdirectory contains the requested method, run 404

So instead of looking for directories like this here:

 while ((is_null($segment = array_shift($segments)) !== true) && (is_dir($root . $controller . $segment . '/'))) { $controller .= $segment . '/'; } 

We need to search for files. And if we find a file that does not contain the requested method, we look for a directory.

 function Route($root, $request_uri, $request_method) { $segments = array_filter(explode('/', $request_uri), 'strlen'); if ((count($segments) == 0) || (is_dir($root) === false)) { return true; // serve index } $all_segments = array_values($segments); $segments = $all_segments; $directory = $root . '/'; do { $segment = array_shift($segments); if(is_file($controller = $directory . $segment . ".php")) { $class = basename($controller, '.php'); $method = isset($segments[0]) ? $segments[0] : $request_method; require_once($controller); if (method_exists($class = new $class(), $method)) { return call_user_func_array(array($class, $method), array_slice($segments, 1)); } } $directory .= $segment . '/'; } while(is_dir($directory)); throw new Exception('/' . implode('/', $all_segments), 404); // serve 404 } 

This method works now as expected.

Now we can add a lot more test cases, but I don't want to stretch this anymore. As you can see, it is very useful to run a set of automated tests to ensure that some things in your function work. It is also very useful for debugging, because you will find out exactly where the error occurred. I just wanted you to start how to make TDD and how to use PHPUnit so that you can debug your code yourself.

"Give a man a fish, and you feed him for a day. Teach a man to fish, and you feed him for life."

Of course, you must write tests before writing code.

Here are some more links that might be interesting:

+3
source

Although your HVMC magic method is convenient for developers, it can be a little performance killer (all statistics / lstats). I once used a similar method for matching FS routes, but later abandoned the magic and replaced it with some good old hard-coded config:

 $controller_map = array( '/some/route/' => '/some/route.php', '/anouther/route/' => 'another/route.php', # etc, etc, ... ); 

It may not be as elegant as what you have and will require some configuration changes every time you add / remove a controller (srsly, this should not be a normal task ..), but it’s faster, eliminates all ambiguities. and gets rid of all useless calls to disks / pages-keram.

+2
source

Sorry, I didn’t have time to check my solution, but here is my suggestion:

 while ((is_null($segment = array_shift($segments)) !== true) && (is_dir($root . $controller . $segment . '/')) && ( (is_file($controller = $root . $controller . $segment . '.php') && (!in_array(array_shift(array_values($segments)), ['get','post']) || count($segments)!=0 ) ) ) { $controller .= $segment . '/'; } 

A simple explanation of the above code will be that if you encounter a route that is both a file and a directory, check if this is done by get / post or if this is the last segment in the $ segment array. If so, consider it as a file, otherwise add segments to the $ controller variable.

Although the sample code I gave is just what I had in mind, it has not been tested. However, if you use this workflow in a comparison, you can turn it off. I suggest you follow smassey's answer and keep declaring routes for each controller.

Note I use * array_shift * on * array_values ​​*, so I just pull the next segment value without the intervention of the $ segment array. [Edit]

+1
source

All Articles