Why Codeignitor does not accept autoload Controller classes when validating routes? - php

Why does Codeignitor not accept Controller in composer autoload when validating routes?
It's checking by: class_exists($class, FALSE) where the second parameter disables checking in autoload.
https://github.com/bcit-ci/CodeIgniter
$e404 = FALSE;
$class = ucfirst($RTR->class);
$method = $RTR->method;
if (empty($class) OR ! file_exists(APPPATH.'controllers/'.$RTR->directory.$class.'.php'))
{
$e404 = TRUE;
}
else
{
require_once(APPPATH.'controllers/'.$RTR->directory.$class.'.php');
if ( ! class_exists($class, FALSE) OR $method[0] === '_' OR method_exists('CI_Controller', $method))
{
$e404 = TRUE;
}
elseif (method_exists($class, '_remap'))
{
$params = array($method, array_slice($URI->rsegments, 2));
$method = '_remap';
}
elseif ( ! method_exists($class, $method))
{
$e404 = TRUE;
}
/**
* DO NOT CHANGE THIS, NOTHING ELSE WORKS!
*
* - method_exists() returns true for non-public methods, which passes the previous elseif
* - is_callable() returns false for PHP 4-style constructors, even if there's a __construct()
* - method_exists($class, '__construct') won't work because CI_Controller::__construct() is inherited
* - People will only complain if this doesn't work, even though it is documented that it shouldn't.
*
* ReflectionMethod::isConstructor() is the ONLY reliable check,
* knowing which method will be executed as a constructor.
*/
elseif ( ! is_callable(array($class, $method)))
{
$reflection = new ReflectionMethod($class, $method);
if ( ! $reflection->isPublic() OR $reflection->isConstructor())
{
$e404 = TRUE;
}
}
}

Looking over the git history, the change was introduced in 49e68de96b420a444c826995746a5f09470e76d9, with the commit message being:
Disable autoloader call from class_exists() occurences to improve performance
Note: The Driver libary tests seem to depend on that, so one occurence in CI_Loader is left until we resolve that.
So the nominal reason is performance.
If you want to ensure that the controller classes will be loaded on each request, you can add the files explicitly to the Composer autoload.files attribute, like so:
composer.json
{
"autoload": {
"files": [
"src/Foo.php"
]
},
"name": "test/64166739"
}
src/Foo.php
<?php
class Foo {}
test.php
<?php
$loader = require('./vendor/autoload.php');
var_dump(class_exists('Foo', false));
When run (via php test.php for example), we get the following output:
bool(true)
Additional
Looking over the code around that call to class_exists, it would appear that the controller files should follow a convention such that, for example with the built in Welcome controller and the default settings, the file that defines it should exist at:
application/controllers/Welcome.php
and so after require_onceing that file, the call to class_exists is a reasonably simple sanity check to ensure that the file did in fact define that class. So, based on this assumption about how controllers are added to the CodeIgniter application (ie all in the application/controllers directory and named the same as the class that they define), it's reasonable to bypass the autoloader when performing that check.
If you wanted to ensure the controllers are loaded when needed, the CodeIgniter way, they should be added to the application as listed above.

Related

Change Zend Framework 2 default template resolver behavior

I've just started looking into Zend framework 2 .One thing that I can’t seem to figure out is how to change the behavior of the framework when its deciding what view template to use when i’m not passing it in the viewmodel.
When looking for the answer myself I found the following, which states that Zend resolves view templates using the pathing below:
{normalized-module-name}/{normalized-controller-name}/{normalized-action-name}
(Source: http://zend-framework-community.634137.n4.nabble.com/Question-regarding-template-path-stack-tp4660952p4660959.html)
Now I’m looking to edit or remove the normalized-module-name segment. All the view files stay in my module/views folder. The reason I want to change this is because I’m using sub namespaces as my module name, resulting in the first segment of the namespace as the normalized module name (which is not specific enough for me).
To give you an example, the module Foo\Bar will result in an example view being rendered from:
/modules/Foo/Bar/view/foo/test/index.phtml.
I would like to change that default behavior to:
/modules/Foo/Bar/view/bar/test/index.phtml
Starting with zf 2.3 you can use extra config parameter view_manager['controller_map'] to enable different template name resolving.
Look at this PR for more info: https://github.com/zendframework/zf2/pull/5670
'view_manager' => array(
'controller_map' => array(
'Foo\Bar' => true,
),
);
Will result in controller FQCN starting with 'Foo\Bar' to be resolved following those rules:
strip \Controller\ namespace
strip trailing Controller in classname
inflect CamelCase to dash
replace namespace separator with slash
Eg: Foo\Bar\Controller\Baz\TestController -> foo/bar/baz/test/actionname
Update:
Starting with zend-mvc v3.0 this is default behavior
I had a similar problem and here's my solution.
Default template injector is attached to an event manager of the current controller with priority -90, and it resolves a template name only if a view model is not provided with one.
Knowing this, you can create your own template injector with a required logic and attach it to the event manager with the higher priority.
Please see the code below:
public function onBootstrap(EventInterface $event)
{
$eventManager = $event->getApplication()->getEventManager();
$eventManager->getSharedManager()
->attach(
'Zend\Stdlib\DispatchableInterface',
MvcEvent::EVENT_DISPATCH,
new TemplateInjector(),
-80 // you can put here any negative number higher -90
);
}
Your template injector which resolves template paths instead of the default one.
class TemplateInjector
{
public function __invoke(MvcEvent $event)
{
$model = $event->getResult();
if (!$model instanceof ViewModel)
{
return;
}
$controller = $event->getTarget();
if ($model->getTemplate())
{
return ;
}
if (!is_object($controller))
{
return;
}
$namespace = explode('\\', ltrim(get_class($controller), '\\'));
$controllerClass = array_pop($namespace);
array_pop($namespace); //taking out the folder with controllers
array_shift($namespace); //taking out the company namespace
$moduleName = implode('/', $namespace);
$controller = substr($controllerClass, 0, strlen($controllerClass) - strlen('Controller'));
$action = $event->getRouteMatch()->getParam('action');
$model->setTemplate(strtolower($moduleName.'/'.$controller.'/'.$action));
}
}
Here's the link from my blog where I wrote about it in more details: http://blog.igorvorobiov.com/2014/10/18/creating-a-custom-template-injector-to-deal-with-sub-namespaces-in-zend-framework-2/
Right template to ViewModel is injected in MVC event 'dispatch' (defined in ViewManager) by Zend\Mvc\View\Http\InjectTemplateListener with priority -90
You'll have to create custom InjectTemplateListener and register it with higher priority to same event.
But I'd recommend to set template in every action by hand, because of performance - see http://samminds.com/2012/11/zf2-performance-quicktipp-1-viewmodels/
template name resolving is a heavy process(on system resources), and all the articles about ZF2 performance says that you should provide the template name manually to increase performance.
so don't waste time finding a way to do something that you will end up doing manually :D
In order to improve Next Developer answer, I write the following code in TemplateInjector.php:
class TemplateInjector
{
public function __invoke(MvcEvent $event)
{
$model = $event->getResult();
if (!$model instanceof ViewModel) {
return;
}
if ($model->getTemplate()) {
return;
}
$controller = $event->getTarget();
if (!is_object($controller)) {
return;
}
$splitNamespace = preg_split('/[\\\]+/', ltrim(get_class($controller), '\\'));
$moduleName = $splitNamespace[1];
$controller = $splitNamespace[0];
$action = $event->getRouteMatch()->getParam('action');
$model->setTemplate(strtolower($moduleName . '/' . $controller . '/' . $action));
}
}
I've changed the way to build the Template path. Using regexp is faster than using array methods.

Loading Codeigniter library from a different folder under application folder

Hi I am having an issue
Say I have a folder structure in CodeIgniter
application/
controllers/
models/
views/
gmail_library/
Now I have written a controller
class invite_friends extends CI_Controller {
function __construct() {
parent::__construct();
$this->load->gmail_library('Config'); // this line is giving me error
session_start();
}
}
How can I set this thing like this?
First of all, note that CodeIgniter doesn't use overloading by __call() to implement dynamic methods. Thus there is no way to get such a gmail_library() methods to work.
The conventional method
From the User Guide:
Your library classes should be placed within your
application/libraries folder, as this is where CodeIgniter will look
for them when they are initialized.
If you're using CI Loader class to load a library or helper, you should follow CI's conventions.
application/libraries/Myclass.php
$this->load->library('myclass');
$this->myclass->my_method();
Using relative paths
1) You put your library files in sub-directories within the main libraries folder:
application/libraries/gmail/Gmail_config.php
I renamed your Config.php file name to prevent the occurrence of conflict with CI config core class.
$this->load->library('gmail/gmail_config');
2) Also you can use relative path within the Loader::library() method to load the library file from the outside of the library folder, as follows:
The path to the file is relative. So you can use ../ to go one UP level in path.
Again: I renamed your Config.php file name to prevent the occurrence of conflict with CI config core class.
$this->load->library('../gmail_library/Gmail_config');
An old question, I know, but I came across this looking for a way to use classes (libraries) from outside the application folder and I liked to keep it in 'the CI way of doing this'. I ended up extending the CI_Loaderclass:
I basically copied the _ci_load_class function and added an absolute path
<? if (!defined('BASEPATH')) exit('No direct script access allowed');
class MY_Loader extends CI_Loader {
protected $absPath = '/home/xxxxx/[any-path-you-like]/common/';
/**
* Load class
*
* This function loads the requested class.
*
* #param string the item that is being loaded
* #param mixed any additional parameters
* #param string an optional object name
* #return void
*/
public function commonLibrary($class, $params = NULL, $object_name = NULL)
{
// Get the class name, and while we're at it trim any slashes.
// The directory path can be included as part of the class name,
// but we don't want a leading slash
$class = str_replace('.php', '', trim($class, '/'));
// Was the path included with the class name?
// We look for a slash to determine this
$subdir = '';
if (($last_slash = strrpos($class, '/')) !== FALSE)
{
// Extract the path
$subdir = substr($class, 0, $last_slash + 1);
// Get the filename from the path
$class = substr($class, $last_slash + 1);
}
// We'll test for both lowercase and capitalized versions of the file name
foreach (array(ucfirst($class), strtolower($class)) as $class)
{
$subclass = $this->absPath.'libraries/'.$subdir.config_item('subclass_prefix').$class.'.php';
// Is this a class extension request?
if (file_exists($subclass))
{
$baseclass = BASEPATH.'libraries/'.ucfirst($class).'.php';
if ( ! file_exists($baseclass))
{
log_message('error', "Unable to load the requested class: ".$class);
show_error("Unable to load the requested class: ".$class);
}
// Safety: Was the class already loaded by a previous call?
if (in_array($subclass, $this->_ci_loaded_files))
{
// Before we deem this to be a duplicate request, let's see
// if a custom object name is being supplied. If so, we'll
// return a new instance of the object
if ( ! is_null($object_name))
{
$CI =& get_instance();
if ( ! isset($CI->$object_name))
{
return $this->_ci_init_class($class, config_item('subclass_prefix'), $params, $object_name);
}
}
$is_duplicate = TRUE;
log_message('debug', $class." class already loaded. Second attempt ignored.");
return;
}
include_once($baseclass);
include_once($subclass);
$this->_ci_loaded_files[] = $subclass;
return $this->_ci_init_class($class, config_item('subclass_prefix'), $params, $object_name);
}
// Lets search for the requested library file and load it.
$is_duplicate = FALSE;
foreach ($this->_ci_library_paths as $path)
{
$filepath = $this->absPath.'libraries/'.$subdir.$class.'.php';
// Does the file exist? No? Bummer...
if ( ! file_exists($filepath))
{
continue;
}
// Safety: Was the class already loaded by a previous call?
if (in_array($filepath, $this->_ci_loaded_files))
{
// Before we deem this to be a duplicate request, let's see
// if a custom object name is being supplied. If so, we'll
// return a new instance of the object
if ( ! is_null($object_name))
{
$CI =& get_instance();
if ( ! isset($CI->$object_name))
{
return $this->_ci_init_class($class, '', $params, $object_name);
}
}
$is_duplicate = TRUE;
log_message('debug', $class." class already loaded. Second attempt ignored.");
return;
}
include_once($filepath);
$this->_ci_loaded_files[] = $filepath;
return $this->_ci_init_class($class, '', $params, $object_name);
}
} // END FOREACH
// One last attempt. Maybe the library is in a subdirectory, but it wasn't specified?
if ($subdir == '')
{
$path = strtolower($class).'/'.$class;
return $this->_ci_load_class($path, $params);
}
// If we got this far we were unable to find the requested class.
// We do not issue errors if the load call failed due to a duplicate request
if ($is_duplicate == FALSE)
{
log_message('error', "Unable to load the requested class: ".$class);
show_error("Unable to load the requested class: ".$class);
}
}
}
Put the file MY_Loader.php in the application/core folder and load your libs with:
$this->load->commonLibrary('optional_subfolders/classname', 'classname');
$this->classname->awesome_method();

New server ignoring capitalization in PHP app

I have worked on a codeigniter 2.1.3 application which was developed on windows running wamp 2.2 (php 5.4.3). I recently uploaded the application to a ubuntu 12.04 server running apache 2.2.22 and php 5.4.6.
My model classes are named like billView.php, categoryModel.php etc. Note the capital letters. The name of the classes inside the php files is also the same. And the name i give when calling the models from controller classes is also the same.
But when I run my app on Ubuntu, I get this error
Unable to locate the model you have specified: billview
The error is thrown from this line:
$this->load->model('billView');
(i.e. php is ignoring the capital letter)
When I rename the model file (only the model filename, class name stays intact) then the error disappears.
How to solve this problem without manually renaming all my files?
From the documentation:
Where Model_name is the name of your class. Class names must have the
first letter capitalized with the rest of the name lowercase. Make
sure your class extends the base Model class.
It's better to follow the naming convention than to work around it.
Hope this helps.
The problem that you are facing is that Windows filesystem (NTFS) is case insensitive, so in windows, billview.php and billView.php are the same file.
On Linux, as you might be guessing now, the typical filesystems (ext2/3/4, xfs, reiserfs...) are case sensitive, and for that reason, billview.php and billView.php are (or may be) different files. In your case, one exists and the other does not.
Inside CodeIgniter autoloader function/method/class/whatever, it is trying to require the file that has the class you are instantiating, so if you tell it that you need the model billview, it will try to require path/to/model/billview.php;, and this file does not exist, so the model doesn't get loaded and then your application fails.
Of course it is recommended to follow a naming convention if there is one as Amal Murali suggests, but it is not the issue here. If all your classes, files, and instances in code had the same capitalization (whether it may be all_lowercase, ALL_UPPERCASE, camelCase or sTuPiD_CaSe) everything would have worked.
So, please refer to your files/class names with the same capitalization as you have created them, and the capitalization in a class name should follow that of the file name it is stored in.
You will have the same problems if your html code refers to files (images, css, js files) in different capitalization for the same reason. The webserver will be looking for image.jpg but it will not exist (the existing file would be Image.JPG for example).
In a related note, variables in php are case sensitive, but functions and classes aren't. despite that, call them always with the right capitalization to avoid problems.
I personally have found this convention in CodeIgniter to make no sense. I wouldn't recommend hacking the CodeIgniter core but you can easily extend the CI_Loader class. Here is mine from CI version 2.2.0.
<?php
class MY_Loader extends CI_Loader
{
/**
* Model Loader
*
* This function lets users load and instantiate models.
*
* #param string the name of the class
* #param string name for the model
* #param bool database connection
* #return void
*/
public function model($model, $name = '', $db_conn = FALSE)
{
if (is_array($model))
{
foreach ($model as $babe)
{
$this->model($babe);
}
return;
}
if ($model == '')
{
return;
}
$path = '';
// Is the model in a sub-folder? If so, parse out the filename and path.
if (($last_slash = strrpos($model, '/')) !== FALSE)
{
// The path is in front of the last slash
$path = substr($model, 0, $last_slash + 1);
// And the model name behind it
$model = substr($model, $last_slash + 1);
}
if ($name == '')
{
$name = $model;
}
if (in_array($name, $this->_ci_models, TRUE))
{
return;
}
$CI =& get_instance();
if (isset($CI->$name))
{
show_error('The model name you are loading is the name of a resource that is already being used: '.$name);
}
//$model = strtolower($model);
foreach ($this->_ci_model_paths as $mod_path)
{
if ( ! file_exists($mod_path.'models/'.$path.$model.'.php'))
{
continue;
}
if ($db_conn !== FALSE AND ! class_exists('CI_DB'))
{
if ($db_conn === TRUE)
{
$db_conn = '';
}
$CI->load->database($db_conn, FALSE, TRUE);
}
if ( ! class_exists('CI_Model'))
{
load_class('Model', 'core');
}
require_once($mod_path.'models/'.$path.$model.'.php');
//$model = ucfirst($model);
$CI->$name = new $model();
$this->_ci_models[] = $name;
return;
}
// couldn't find the model
show_error('Unable to locate the model you have specified: '.$model);
}
}
?>
All I did was copy the the CI_Loader::model method then comment out these two lines
//$model = strtolower($model);
//$model = ucfirst($model);
All you have to do is put the above class in your application/core/ folder and it should work.

Ambiguity in HMVC Routing

I've a routing mechanism that dispatches requests by relying 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, it tries to map as many URL segments to directories as it can, matching the following segment to the actual controller (.php file with the same name). If more segments are provided, the first defines the action to call (falling back to the HTTP method), and the remaining as the action arguments.
The problem is that (depending on the file system structure) 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 - when I access domain.tld/admin/company/edit/ the edit.php controller serves the request (as it should), however accessing domain.tld/admin/company/ via GET or domain.tld/admin/company/get/ directly throws a 404 error because the company segment was mapped to the corresponding directory, even though the remaining segments have no mapping in the file system. How can I solve this issue? Preferably without putting too much effort in the disk.
There are already a lot of similar questions in SO regarding this problem, I looked at some of them but I couldn't find a single answer that provides a reliable and efficient solution.
For critical stuff like this it's really important to write test, with a test Framework like PHPUnit.
Install it like described here (You need pear):
https://github.com/sebastianbergmann/phpunit/
I also use a virtual file system so your test folder doesn't get cluttered: https://github.com/mikey179/vfsStream/wiki/Install
I simply dropped your Route function into a file called Route.php. In the same directory I now created a test.php file with the following content:
<?php
require_once 'Route.php';
class RouteTest extends PHPUnit_Framework_TestCase {
}
To check if it all works open the command line 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 this appears PHPUnit is correctly installed and you're ready to write tests.
In order make the Route function better testable and less coupled to server and the file system I modified it slightly:
// 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
}
Lets add a test to check wheter the function returns true if the index route is requested:
public function testIndexRoute() {
$this->assertTrue(Route('.', '', 'get'));
$this->assertTrue(Route('.', '/', 'get'));
}
Because your test class extends PHPUnit_Framework_TestCase you can now use methods like $this->assertTrue
to check wheter a certain statement evaluates to true. Lets run it again:
$ phpunit test.php
PHPUnit 3.7.13 by Sebastian Bergmann.
.
Time: 0 seconds, Memory: 1.75Mb
OK (1 test, 2 assertions)
To this test passed! Lets test if array_filter removes empty segments correctly:
public function testEmptySegments() {
$this->assertTrue(Route('.', '//', 'get'));
$this->assertTrue(Route('.', '//////////', 'get'));
}
Lets also test if the index route is requested if the $root directory for the routes doesn't exist.
public function testInexistentRoot() {
$this->assertTrue(Route('./inexistent', '/', 'get'));
$this->assertTrue(Route('./does-not-exist', '/some/random/route', 'get'));
}
To test more stuff than this we now need files containing classes with methods. So let's use our virtual file system to setup a directory structure with files before running 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 some special methods for this kind of thing. The setUp method gets executed before every test method in this test class. And the tearDown method after a test method as been executed.
Now I create a directory Structure using vfsStream. (If you are looking for a tutorial to do 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 given file structure and the given file contents.
And as you can see I let my controllers return the name of the method and the parameters as string.
Now we can add a few 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 there is something wrong with the $class variable. If we now inspect the following line in the Route function with a debugger (or some echos).
$class = basename($controller . '.php');
We can see the that the $controller variable holds the correct filename, but why is there a .php appended?
This seams to be a typing mistake. I think it should be:
$class = basename($controller, '.php');
Because this removes the .php extension. And we get the correct classname edit.
Now let's test if an exception gets thrown if we request an random path which doesn't 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 automaticly reads this comments and checks if an Exception of type Exception is thrown when executing this method and if the message of the Exception was /random-route-to-the/void
This seams to work. Lets check if the $request_method parameter works properly.
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 execute this test we run into an other issue:
$ 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
Looks like we use an include/require multiple times for the same file.
require($controller);
Lets change that to
require_once($controller);
Now let's face your issue and write a test to check that the directory company and the file 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 the 404 Exception, 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 right here is, we don't know excatly when to enter the subdirectory and when to use the controller in the .php file.
So we need to specify what exactly you want to happen. And I assume the following, because it makes sense.
Only enter a subdirectory if the controller doesn't contain the method requested.
If neither the controller nor the subdirectory contains the method requested throw a 404
So instead of searching directories like 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 which doesn't contain the method requested, then we search 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.
We could now add a lot more test cases, but I don't want to stretch this more.
As you can see it's very useful to run a set of automated tests to ensure
that some things in your function work. It's also very helpful for debugging, because
you get to know where exactly the error occured. I just wanted to give you a start on
how to do TDD and how to use PHPUnit, so 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 a lifetime."
Of course you should write the tests before you write the code.
Here a few more links which could be interesting:
PHPUnit Manual: http://www.phpunit.de/manual/current/en/
Official PEAR Website: http://pear.php.net/
Test Driven Development (TDD): http://en.wikipedia.org/wiki/Test-driven_development
Although your method of magic HVMC is convenient for developers.. it could become a bit of a performance killer (all the stats/lstats). I once used a similar method of mapping FS to routes but later gave up on the magic and replaced it with some good old fashion hard coded config:
$controller_map = array(
'/some/route/' => '/some/route.php',
'/anouther/route/' => 'another/route.php',
# etc, etc, ...
);
Perhaps it's not as elegant as what you have in place and will require some config changes everytime you add/remove a controller (srsly, this shouldn't be a common task..) but it is faster, removes all ambiguities, and gets rid of all of the useless disk/page-cache lookups.
Sorry, I haven't had the time to test 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 to the above code would be that if a route that is both a file and a directory is encountered, check if it is succeeded by get / post or if it is the last segment in the $segments array. If it is, treat it as a file, otherwise, keep adding segments to the $controller variable.
Although the code sample I gave is simply what I had in mind, it has not been tested. However, if you use this workflow in the comparison, you should be able to pull it off. I suggest you follow smassey's answer and keep to declaring routes for each controller.
Note: I am using *array_shift* on *array_values* so I only pull the next segment's value without tampering with the $segments array. [edit]

A Generic, catch-all action in Zend Framework... can it be done?

This situation arises from someone wanting to create their own "pages" in their web site without having to get into creating the corresponding actions.
So say they have a URL like mysite.com/index/books... they want to be able to create mysite.com/index/booksmore or mysite.com/index/pancakes but not have to create any actions in the index controller. They (a non-technical person who can do simple html) basically want to create a simple, static page without having to use an action.
Like there would be some generic action in the index controller that handles requests for a non-existent action. How do you do this or is it even possible?
edit: One problem with using __call is the lack of a view file. The lack of an action becomes moot but now you have to deal with the missing view file. The framework will throw an exception if it cannot find one (though if there were a way to get it to redirect to a 404 on a missing view file __call would be doable.)
Using the magic __call method works fine, all you have to do is check if the view file exists and throw the right exception (or do enything else) if not.
public function __call($methodName, $params)
{
// An action method is called
if ('Action' == substr($methodName, -6)) {
$action = substr($methodName, 0, -6);
// We want to render scripts in the index directory, right?
$script = 'index/' . $action . '.' . $this->viewSuffix;
// Script file does not exist, throw exception that will render /error/error.phtml in 404 context
if (false === $this->view->getScriptPath($script)) {
require_once 'Zend/Controller/Action/Exception.php';
throw new Zend_Controller_Action_Exception(
sprintf('Page "%s" does not exist.', $action), 404);
}
$this->renderScript($script);
}
// no action is called? Let the parent __call handle things.
else {
parent::__call($methodName, $params);
}
}
You have to play with the router
http://framework.zend.com/manual/en/zend.controller.router.html
I think you can specify a wildcard to catch every action on a specific module (the default one to reduce the url) and define an action that will take care of render the view according to the url (or even action called)
new Zend_Controller_Router_Route('index/*',
array('controller' => 'index', 'action' => 'custom', 'module'=>'index')
in you customAction function just retrieve the params and display the right block.
I haven't tried so you might have to hack the code a little bit
If you want to use gabriel1836's _call() method you should be able to disable the layout and view and then render whatever you want.
$this->_helper->layout()->disableLayout();
$this->_helper->viewRenderer->setNoRender(true);
I needed to have existing module/controller/actions working as normal in a Zend Framework app, but then have a catchall route that sent anything unknown to a PageController that could pick user specified urls out of a database table and display the page. I didn't want to have a controller name in front of the user specified urls. I wanted /my/custom/url not /page/my/custom/url to go via the PageController. So none of the above solutions worked for me.
I ended up extending Zend_Controller_Router_Route_Module: using almost all the default behaviour, and just tweaking the controller name a little so if the controller file exists, we route to it as normal. If it does not exist then the url must be a weird custom one, so it gets sent to the PageController with the whole url intact as a parameter.
class UDC_Controller_Router_Route_Catchall extends Zend_Controller_Router_Route_Module
{
private $_catchallController = 'page';
private $_catchallAction = 'index';
private $_paramName = 'name';
//-------------------------------------------------------------------------
/*! \brief takes most of the default behaviour from Zend_Controller_Router_Route_Module
with the following changes:
- if the path includes a valid module, then use it
- if the path includes a valid controller (file_exists) then use that
- otherwise use the catchall
*/
public function match($path, $partial = false)
{
$this->_setRequestKeys();
$values = array();
$params = array();
if (!$partial) {
$path = trim($path, self::URI_DELIMITER);
} else {
$matchedPath = $path;
}
if ($path != '') {
$path = explode(self::URI_DELIMITER, $path);
if ($this->_dispatcher && $this->_dispatcher->isValidModule($path[0])) {
$values[$this->_moduleKey] = array_shift($path);
$this->_moduleValid = true;
}
if (count($path) && !empty($path[0])) {
$module = $this->_moduleValid ? $values[$this->_moduleKey] : $this->_defaults[$this->_moduleKey];
$file = $this->_dispatcher->getControllerDirectory( $module ) . '/' . $this->_dispatcher->formatControllerName( $path[0] ) . '.php';
if (file_exists( $file ))
{
$values[$this->_controllerKey] = array_shift($path);
}
else
{
$values[$this->_controllerKey] = $this->_catchallController;
$values[$this->_actionKey] = $this->_catchallAction;
$params[$this->_paramName] = join( self::URI_DELIMITER, $path );
$path = array();
}
}
if (count($path) && !empty($path[0])) {
$values[$this->_actionKey] = array_shift($path);
}
if ($numSegs = count($path)) {
for ($i = 0; $i < $numSegs; $i = $i + 2) {
$key = urldecode($path[$i]);
$val = isset($path[$i + 1]) ? urldecode($path[$i + 1]) : null;
$params[$key] = (isset($params[$key]) ? (array_merge((array) $params[$key], array($val))): $val);
}
}
}
if ($partial) {
$this->setMatchedPath($matchedPath);
}
$this->_values = $values + $params;
return $this->_values + $this->_defaults;
}
}
So my MemberController will work fine as /member/login, /member/preferences etc, and other controllers can be added at will. The ErrorController is still needed: it catches invalid actions on existing controllers.
I implemented a catch-all by overriding the dispatch method and handling the exception that is thrown when the action is not found:
public function dispatch($action)
{
try {
parent::dispatch($action);
}
catch (Zend_Controller_Action_Exception $e) {
$uristub = $this->getRequest()->getActionName();
$this->getRequest()->setActionName('index');
$this->getRequest()->setParam('uristub', $uristub);
parent::dispatch('indexAction');
}
}
You could use the magic __call() function. For example:
public function __call($name, $arguments)
{
// Render Simple HTML View
}
stunti's suggestion was the way I went with this. My particular solution is as follows (this uses indexAction() of whichever controller you specify. In my case every action was using indexAction and pulling content from a database based on the url):
Get an instance of the router (everything is in your bootstrap file, btw):
$router = $frontController->getRouter();
Create the custom route:
$router->addRoute('controllername', new Zend_Controller_Router_Route('controllername/*', array('controller'=>'controllername')));
Pass the new route to the front controller:
$frontController->setRouter($router);
I did not go with gabriel's __call method (which does work for missing methods as long as you don't need a view file) because that still throws an error about the missing corresponding view file.
For future reference, building on gabriel1836 & ejunker's thoughts, I dug up an option that gets more to the point (and upholds the MVC paradigm). Besides, it makes more sense to read "use specialized view" than "don't use any view".
// 1. Catch & process overloaded actions.
public function __call($name, $arguments)
{
// 2. Provide an appropriate renderer.
$this->_helper->viewRenderer->setRender('overload');
// 3. Bonus: give your view script a clue about what "action" was requested.
$this->view->action = $this->getFrontController()->getRequest()->getActionName();
}
#Steve as above - your solution sounds ideal for me but I am unsure how you implmeented it in the bootstrap?

Categories