Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

Add controller namespace prefix to template mapping #5670

Merged
merged 3 commits into from

8 participants

@Xerkus

Zf2 modules are not at the core of the framework unlike zf1.
We no longer rely on naming conventions but rather on design by contract. As such current controller to template name resolution, where only top level namespace and class name are used, makes no sense. And there are valid use cases where it does not work at all.

Consider controller class Vendor\Module\Controller\FooController:
it will be resolved to vendor/foo/action. So as Vendor\OtherModule\Controller\FooController and even Vendor\OtherModule\Controller\Bar\FooController.

Unless... namespace parameter is specified in route, then resulting template name will suddenly change. Even if controller class does not match that namespace (surprise, mothaf...a!). Most unexpected and unreliable behaviour.

To fix that annoying bit this PR introduces new behaviour. for controller class to view template name resolution
It is very simple:
1. strip \Controller\ namespace
2. strip trailing Controller in classname
3. inflect CamelCase to dash
4. replace namespace separator with slash

Eg: Xerkus\FooModule\Controller\Bar\FooController -> xerkus/foo-module/bar/foo/action

To prevent BC break, this behaviour will only be applied if namespace is whitelisted:

'view_manager' => array(
    'controller_map' => array(
        // for one of my modules
        'Xerkus\FooModule' => true,
        // for all modules under my vendor namespace
        'Xerkus' => true,
        //for one controller
        'ZfcUser\Controller\UserController' => true
    ),
);

Most common use case is for modules that follow PSR-0, namely <Vendor name>\(Namespace\)*<Class Name> rule.

As side effect of whitelist introduction, this PR introduces a way to specify a map of namespaces to template name prefixes

Eg

'view_manager' => array(
    'controller_map' => array(
        'Xerkus\FooModule' => 'xrks-foo',
        // Xerkus\FooModule\Controller\BarController -> xrks-foo/bar/action
        'ZfcUser' => 'zf-commons/zfc-user',
        // ZfcUser\Controller\UserController -> zf-commons/zfc-user/user/action
        'ZfcUser\Controller\UserController' => 'user'
        // ZfcUser\Controller\UserController -> user/action
    ),
);

I consider that more of an edge case use case.

As side note: i'd want to see that behaviour as default in zf3, unless it will be handled completely differently.

@Xerkus Xerkus Add controller namespace prefix to template mapping
InjectTemplateListener changes behaviour when controller is matched by
map entry.  Mapped namespace prefix is replaced with provided value and
rest of the controller inflected. If map entry value is boolean true,
then whole controller class is inflected. In all cases\Controller\
namespace and trailing Controller are stripped.

Most notable use case is when module name is not top level namespace

With map entry 'Vendor\Module' => true and controller class
Vendor\ModuleName\Controller\FooController resulting template name will be
vendor/module-name/foo/action-name
2042f7e
library/Zend/Mvc/View/Http/InjectTemplateListener.php
((5 lines not shown))
+ {
+ krsort($map);
+ $this->controllerMap = $map;
+ }
+
+ public function mapController($controller)
+ {
+ foreach ($this->controllerMap as $rule => $map) {
+ if (
+ false == $map
+ || !($controller === $rule || strpos($controller, $rule . '\\') === 0)
+ ) {
+ continue;
+ }
+
+ if (is_string($map)) {
@Ocramius Collaborator
Ocramius added a note

Are there cases when it's not a string?

@Xerkus
Xerkus added a note

boolean true

@Ocramius Collaborator
Ocramius added a note

Ok, so this needs more documentation then.

Also, I'd wrap all this giant foreach contents' into a private method

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@weierophinney

2 things:

  • First, what if you don't use a "Controller" segment in your namespace? or in your class name?
  • Second, what is the performance impact?

The first question is what prevented us from implementing this for subnamespaces previously, as we cannot accurately guess the namespace, nor enforce naming conventions on the classes themselves. The second question also quickly arose, due to the logic heuristics necessary to resolve.

I think you may have these largely solved by having the controller_map, which makes it opt-in behavior, and explicit.

Can you ask on the ML to see if you can get more people to test this, please?

@Xerkus

\Controller\ namespace is not required, if it is present - then it is stripped.
Trailing Controller is stripped from class name unless class name is exactly Controller
I intentionally moved from "fake module" namespace and class name to FQCN mapping.

Using perfect real life example with Apigility:

'view_manager' => array(
    'controller_map' => array(
        //For all apigility modules
        'ZF\Apigility' => true,
    ),
);

For controller ZF\Apigility\Admin\Controller\AppController mapping will be zf/apigility/admin/app/<action>
With current behaviour it is zf/app/<action>
It is good as long as in whole ZF top level namespace there is no controller named AppController

Performance impact should be neglectible since this mapping should happen once per dispatch. And i expect controller map to hold one entry per vendor or module name at most, in realistic usage scenario.
Impl uses simple string operations, no regexes.
Essentially it is whitelisting with optional prefix replacement. Not much logic required.

Will post on ML a bit later

@EvanDotPro
Collaborator

Put me down as :+1: as well.

@weierophinney weierophinney added this to the 2.3.0 milestone
@weierophinney weierophinney referenced this pull request from a commit
@weierophinney weierophinney [#5670] CS fixes
- Added docblock
- Fluent interface
7d157a4
@weierophinney weierophinney merged commit 0063122 into zendframework:develop
@weierophinney weierophinney self-assigned this
@vnagara

:+1: Previous behaviour annoyed me too and did some impact.
Nice done.

@adamlundrigan

The PR containing documentation for this addition hasn't been merged (zendframework/zf2-documentation#1298)

@pauloelr pauloelr referenced this pull request from a commit in fabiocarneiro/EdpModuleLayouts
@fabiocarneiro fabiocarneiro fix if condition
otherwise it would find the name in any place, including the controller name
5f0e45c
@Xerkus Xerkus deleted the Xerkus:feature/controller-to-template-map branch
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Jan 3, 2014
  1. @Xerkus

    Add controller namespace prefix to template mapping

    Xerkus authored
    InjectTemplateListener changes behaviour when controller is matched by
    map entry.  Mapped namespace prefix is replaced with provided value and
    rest of the controller inflected. If map entry value is boolean true,
    then whole controller class is inflected. In all cases\Controller\
    namespace and trailing Controller are stripped.
    
    Most notable use case is when module name is not top level namespace
    
    With map entry 'Vendor\Module' => true and controller class
    Vendor\ModuleName\Controller\FooController resulting template name will be
    vendor/module-name/foo/action-name
Commits on Jan 8, 2014
  1. @Xerkus
  2. @Xerkus
This page is out of date. Refresh to see the latest.
View
88 library/Zend/Mvc/View/Http/InjectTemplateListener.php
@@ -26,6 +26,13 @@ class InjectTemplateListener extends AbstractListenerAggregate
protected $inflector;
/**
+ * Array of controller namespace -> template mappings
+ *
+ * @var array
+ */
+ protected $controllerMap = array();
+
+ /**
* {@inheritDoc}
*/
public function attach(Events $events)
@@ -63,26 +70,29 @@ public function injectTemplate(MvcEvent $e)
$controller = $routeMatch->getParam('controller', '');
}
- $module = $this->deriveModuleNamespace($controller);
-
- if ($namespace = $routeMatch->getParam(ModuleRouteListener::MODULE_NAMESPACE)) {
- $controllerSubNs = $this->deriveControllerSubNamespace($namespace);
- if (!empty($controllerSubNs)) {
- if (!empty($module)) {
- $module .= '/' . $controllerSubNs;
- } else {
- $module = $controllerSubNs;
+ $template = $this->mapController($controller);
+ if (!$template) {
+ $module = $this->deriveModuleNamespace($controller);
+
+ if ($namespace = $routeMatch->getParam(ModuleRouteListener::MODULE_NAMESPACE)) {
+ $controllerSubNs = $this->deriveControllerSubNamespace($namespace);
+ if (!empty($controllerSubNs)) {
+ if (!empty($module)) {
+ $module .= '/' . $controllerSubNs;
+ } else {
+ $module = $controllerSubNs;
+ }
}
}
- }
- $controller = $this->deriveControllerClass($controller);
- $template = $this->inflectName($module);
+ $controller = $this->deriveControllerClass($controller);
+ $template = $this->inflectName($module);
- if (!empty($template)) {
- $template .= '/';
+ if (!empty($template)) {
+ $template .= '/';
+ }
+ $template .= $this->inflectName($controller);
}
- $template .= $this->inflectName($controller);
$action = $routeMatch->getParam('action');
if (null !== $action) {
@@ -91,6 +101,54 @@ public function injectTemplate(MvcEvent $e)
$model->setTemplate($template);
}
+ public function setControllerMap(array $map)
+ {
+ krsort($map);
+ $this->controllerMap = $map;
+ }
+
+ /**
+ * Maps controller to template if controller namespace is whitelisted or mapped
+ *
+ * @param string $controller controller FQCN
+ * @return string|false template name or false if controller was not matched
+ */
+ public function mapController($controller)
+ {
+ foreach ($this->controllerMap as $namespace => $replacement) {
+ if (
+ // Allow disabling rule by setting value to false since config
+ // merging have no feature to remove entries
+ false == $replacement
+ // Match full class or full namespace
+ || !($controller === $namespace || strpos($controller, $namespace . '\\') === 0)
+ ) {
+ continue;
+ }
+
+ $map = '';
+ // Map namespace to $replacement if its value is string
+ if (is_string($replacement)) {
+ $map = rtrim($replacement, '/') . '/';
+ $controller = substr($controller, strlen($namespace) + 1);
+ }
+
+ //strip Controller namespace(s) (but not classname)
+ $parts = explode('\\', $controller);
+ array_pop($parts);
+ $parts = array_diff($parts, array('Controller'));
+ //strip trailing Controller in class name
+ $parts[] = $this->deriveControllerClass($controller);
+ $controller = implode('/', $parts);
+
+ $template = trim($map . $controller, '/');
+
+ //inflect CamelCase to dash
+ return $this->inflectName($template);
+ }
+ return false;
+ }
+
/**
* Inflect a name to a normalized value
*
View
11 library/Zend/Mvc/View/Http/ViewManager.php
@@ -122,8 +122,8 @@ public function onBootstrap($event)
$routeNotFoundStrategy = $this->getRouteNotFoundStrategy();
$exceptionStrategy = $this->getExceptionStrategy();
$mvcRenderingStrategy = $this->getMvcRenderingStrategy();
+ $injectTemplateListener = $this->getInjectTemplateListener();
$createViewModelListener = new CreateViewModelListener();
- $injectTemplateListener = new InjectTemplateListener();
$injectViewModelListener = new InjectViewModelListener();
$this->registerMvcRenderingStrategies($events);
@@ -345,6 +345,15 @@ public function getRouteNotFoundStrategy()
return $this->routeNotFoundStrategy;
}
+ public function getInjectTemplateListener()
+ {
+ $listener = new InjectTemplateListener();
+ if (isset($this->config['controller_map'])) {
+ $listener->setControllerMap($this->config['controller_map']);
+ }
+ return $listener;
+ }
+
/**
* Configures the MvcEvent view model to ensure it has the template injected
*
View
128 tests/ZendTest/Mvc/View/InjectTemplateListenerTest.php
@@ -21,7 +21,12 @@ class InjectTemplateListenerTest extends TestCase
{
public function setUp()
{
+ $controllerMap = array(
+ 'MappedNs' => true,
+ 'ZendTest\MappedNs' => true,
+ );
$this->listener = new InjectTemplateListener();
+ $this->listener->setControllerMap($controllerMap);
$this->event = new MvcEvent();
$this->routeMatch = new RouteMatch(array());
$this->event->setRouteMatch($this->routeMatch);
@@ -125,6 +130,129 @@ public function testMapsSubNamespaceToSubDirectoryWithControllerFromEventTarget(
$this->assertEquals('zend-test/controller/test-asset/sample/test', $myViewModel->getTemplate());
}
+ public function testControllerMatchedByMapIsInflected()
+ {
+ $this->routeMatch->setParam('controller', 'MappedNs\SubNs\Controller\Sample');
+ $myViewModel = new ViewModel();
+
+ $this->event->setResult($myViewModel);
+ $this->listener->injectTemplate($this->event);
+
+ $this->assertEquals('mapped-ns/sub-ns/sample', $myViewModel->getTemplate());
+
+ $this->listener->setControllerMap(array('ZendTest' => true));
+ $myViewModel = new ViewModel();
+ $myController = new \ZendTest\Mvc\Controller\TestAsset\SampleController();
+ $this->event->setTarget($myController);
+ $this->event->setResult($myViewModel);
+
+ $this->listener->injectTemplate($this->event);
+
+ $this->assertEquals('zend-test/mvc/test-asset/sample', $myViewModel->getTemplate());
+ }
+
+ public function testControllerNotMatchedByMapIsNotAffected()
+ {
+ $this->routeMatch->setParam('action', 'test');
+ $myViewModel = new ViewModel();
+ $myController = new \ZendTest\Mvc\Controller\TestAsset\SampleController();
+
+ $this->event->setTarget($myController);
+ $this->event->setResult($myViewModel);
+ $this->listener->injectTemplate($this->event);
+
+ $this->assertEquals('zend-test/sample/test', $myViewModel->getTemplate());
+ }
+
+ public function testFullControllerNameMatchIsMapped()
+ {
+ $this->listener->setControllerMap(array(
+ 'Foo\Bar\Controller\IndexController' => 'string-value',
+ ));
+ $template = $this->listener->mapController('Foo\Bar\Controller\IndexController');
+ $this->assertEquals('string-value', $template);
+ }
+
+ public function testOnlyFullNamespaceMatchIsMapped()
+ {
+ $this->listener->setControllerMap(array(
+ 'Foo' => 'foo-matched',
+ 'Foo\Bar' => 'foo-bar-matched',
+ ));
+ $template = $this->listener->mapController('Foo\BarBaz\Controller\IndexController');
+ $this->assertEquals('foo-matched/bar-baz/index', $template);
+ }
+
+ public function testControllerMapMatchedPrefixReplacedByStringValue()
+ {
+ $this->listener->setControllerMap(array(
+ 'Foo\Bar' => 'string-value',
+ ));
+ $template = $this->listener->mapController('Foo\Bar\Controller\IndexController');
+ $this->assertEquals('string-value/index', $template);
+ }
+
+ public function testUsingNamespaceRouteParameterGivesSameResultAsFullControllerParameter()
+ {
+ $this->routeMatch->setParam('controller', 'MappedNs\Foo\Controller\Bar\Baz\Sample');
+ $myViewModel = new ViewModel();
+
+ $this->event->setResult($myViewModel);
+ $this->listener->injectTemplate($this->event);
+
+ $template1 = $myViewModel->getTemplate();
+
+ $this->routeMatch->setParam(ModuleRouteListener::MODULE_NAMESPACE, 'MappedNs\Foo\Controller\Bar');
+ $this->routeMatch->setParam('controller', 'Baz\Sample');
+
+ $moduleRouteListener = new ModuleRouteListener;
+ $moduleRouteListener->onRoute($this->event);
+
+ $myViewModel = new ViewModel();
+
+ $this->event->setResult($myViewModel);
+ $this->listener->injectTemplate($this->event);
+
+ $this->assertEquals($template1, $myViewModel->getTemplate());
+ }
+
+ public function testControllerMapOnlyFullNamespaceMatches()
+ {
+ $this->listener->setControllerMap(array(
+ 'Foo' => 'foo-matched',
+ 'Foo\Bar' => 'foo-bar-matched',
+ ));
+ $template = $this->listener->mapController('Foo\BarBaz\Controller\IndexController');
+ $this->assertEquals('foo-matched/bar-baz/index', $template);
+ }
+
+ public function testControllerMapRuleSetToFalseIsIgnored()
+ {
+ $this->listener->setControllerMap(array(
+ 'Foo' => 'foo-matched',
+ 'Foo\Bar' => false,
+ ));
+ $template = $this->listener->mapController('Foo\Bar\Controller\IndexController');
+ $this->assertEquals('foo-matched/bar/index', $template);
+ }
+
+ public function testControllerMapMoreSpecificRuleMatchesFirst()
+ {
+ $this->listener->setControllerMap(array(
+ 'Foo' => true,
+ 'Foo\Bar' => 'bar/baz',
+ ));
+ $template = $this->listener->mapController('Foo\Bar\Controller\IndexController');
+ $this->assertEquals('bar/baz/index', $template);
+
+ $this->listener->setControllerMap(array(
+ 'Foo\Bar' => 'bar/baz',
+ 'Foo' => true,
+ ));
+ $template = $this->listener->mapController('Foo\Bar\Controller\IndexController');
+ $this->assertEquals('bar/baz/index', $template);
+ }
+
public function testAttachesListenerAtExpectedPriority()
{
$events = new EventManager();
Something went wrong with that request. Please try again.