Skip to content
This repository was archived by the owner on Jan 29, 2020. It is now read-only.

Added support for route-specific middleware pipe via configuration #192

Merged
merged 10 commits into from
Nov 30, 2015
46 changes: 44 additions & 2 deletions src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,19 @@ public function routeMiddleware(ServerRequestInterface $request, ResponseInterfa
return $middleware($request, $response, $next);
}

if (! is_string($middleware)) {
if (is_array($middleware)) {
$middlewarePipe = new MiddlewarePipe();

foreach ($middleware as $middlewareItem) {
$middlewarePipe->pipe(
is_callable($middlewareItem) ? $middlewareItem : $this->marshalMiddleware($middlewareItem)
);
}

return $middlewarePipe;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return $middlewarePipe($request, $response, $next);

}

if (!is_string($middleware)) {
throw new Exception\InvalidMiddlewareException(
'The middleware specified is not callable'
);
Expand Down Expand Up @@ -377,7 +389,7 @@ public function routeMiddleware(ServerRequestInterface $request, ResponseInterfa
* pipeline.
*
* @param string|Router\Route $path
* @param callable|string $middleware Middleware (or middleware service name) to associate with route.
* @param callable|string|array $middleware Middleware (or middleware service name) to associate with route.
* @param null|array $methods HTTP method to accept; null indicates any.
* @param null|string $name the name of the route
* @return Router\Route
Expand Down Expand Up @@ -531,6 +543,36 @@ private function checkForDuplicateRoute($path, $methods = null)
}
}

/**
* Attempts to retrieve middleware from the container, or instantiate it directly.
*
* @param string $middleware
*
* @return array
* @throws Exception\InvalidMiddlewareException If unable to obtain callable middleware
*/
private function marshalMiddleware($middleware)
{
$callable = $this->marshalMiddlewareFromContainer($middleware);

if (is_callable($callable)) {
return $callable;
}

$callable = $this->marshalInvokableMiddleware($middleware);

if (is_callable($callable)) {
return $callable;
} else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for else keyword here

throw new Exception\InvalidMiddlewareException(
sprintf(
'Unable to resolve middleware "%s" to a callable',
$middleware
)
);
}
}

/**
* Attempt to retrieve the given middleware from the container.
*
Expand Down
22 changes: 15 additions & 7 deletions src/Container/ApplicationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Zend\Expressive\Router\FastRouteRouter;
use Zend\Expressive\Router\Route;
use Zend\Expressive\Router\RouterInterface;
use Zend\Stratigility\MiddlewarePipe;

/**
* Factory to use with an IoC container in order to return an Application instance.
Expand Down Expand Up @@ -175,8 +176,10 @@ private function injectRoutes(Application $app, ContainerInterface $container)
} else {
$methods = Route::HTTP_METHOD_ANY;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing this, I wonder if it might make sense to do a "container-aware middleware pipe" which would lazy-load the middleware only when calling it. Looking into the Stratigility codebase, this would require both an alternate Next as well as Dispatch implementation — both of which would be easier to accomplish if we make a few properties protected and/or allow injection. I'll try to look into this this week.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaelmoussa In reviewing this for merge... I think the code above is in the wrong place! Everywhere else, we delay fetching from the container unless we're dispatching the middleware. As such, I think this needs to be moved to routeMiddleware().

However, for that to happen, a few other changes are necessary:

  • RouteResult::getMatchedMiddleware() (and its accompanying property, $matchedMiddleware, and the $middleware argument for fromRouteMatch()) would need to adjust the typehint to string|array|callable.
  • Route::getMiddleware() (and its accompanying property $middleware, and the $midleware argument to the constructor) would need to adjust the typehint to string|array|callable.
  • Similarly, we also need to update Application::route() (this needs updating anyways with your change!)

The above bit that creates a middleware pipe would then move into routeMiddleware(), between the is_callable() and is_string() checks. We could either inline it as you've done here, or create a method that returns the MiddlewarePipe instance, and call that.

If you go that route, you could likely move the bits from is_string() up to return $callable() into a method that could be used by both methods, for marshaling the individual middlewares.

So, now the question: do you want to tackle those changes, or do you want me to do a PR against your branch? This approach would mean we don't have to worry about changes to Stratigility (as I think it's fine to fetch all the middleware for a given MiddlewarePipe at once in this circumstance), and I think the feature has some cool benefits; I'm already thinking about how we could have the ApplicationFactory also do global routed-middleware, into which the matched middleware would be injected. 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@weierophinney Makes sense. Why go through the effort of potentially expensive middleware retrievals from the container if you don't even know if those middlewares will be used in this request?

I think 3ecc821 takes care of this in the way you had in mind. If not, hopefully it's a good start. Let me know if I understood correctly and/or if this needs additional polish.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@weierophinney I noticed that the changes thus far would not (yet) allow this type of array configuration in the pre- and post-routing pipelines. Of course, one could just specify them separately, like this:

return [
    'middleware_pipeline' => [
        'pre_routing' => [
            [
                'middleware' => 'Foo',
                'path' => '/foobar',
            ],
            [
                'middleware' => 'Bar',
                'path' => '/foobar',
            ],
            [
                'middleware' => 'Foobar',
                'path' => '/foobar',
            ],
        ],
    ],
];

but something like this would be much more concise and potentially easier to read:

return [
    'middleware_pipeline' => [
        'pre_routing' => [
            [
                'middleware' => [
                    'Foo',
                    'Bar',
                    'FooBar'
                ],
                'path' => '/foobar',
            ],
        ],
    ],
];

Do you agree? Would you want to try to cram that into this PR as well or do it as a future PR hopefully still targeting 1.0.0?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interestingly, this comment doesn't show on the website; I think because
its on code you changed in a later commit. Hopefully you'll see this
message!

Yes, I think we should add this into the current pull request, as it makes
the behavior and configuration syntax consistent.

On Tue, Nov 24, 2015 at 11:28 PM, Michael Moussa notifications@github.com
wrote:

In src/Container/ApplicationFactory.php
#192 (comment)
:

         $methods = (null === $methods) ? Route::HTTP_METHOD_ANY : $methods;
  •        $route   = new Route($spec['path'], $spec['middleware'], $methods, $name);
    
  •        if (!is_callable($spec['middleware']) && is_array($spec['middleware'])) {
    
  •            $middlewarePipe = new MiddlewarePipe();
    
  •            foreach ($spec['middleware'] as $middleware) {
    
  •                $middlewarePipe->pipe(is_callable($middleware) ? $middleware : $container->get($middleware));
    
  •            }
    
  •            $spec['middleware'] = $middlewarePipe;
    
  •        }
    

@weierophinney https://github.com/weierophinney I noticed that the
changes thus far would not (yet) allow this type of array configuration in
the pre- and post-routing pipelines. Of course, one could just specify them
separately, like this:

return [ 'middleware_pipeline' => [ 'pre_routing' => [ [ 'middleware' => 'Foo', 'path' => '/foobar', ], [ 'middleware' => 'Bar', 'path' => '/foobar', ], [ 'middleware' => 'Foobar', 'path' => '/foobar', ], ], ],];

but something like this would be much more concise and potentially easier
to read:

return [ 'middleware_pipeline' => [ 'pre_routing' => [ [ 'middleware' => [ 'Foo', 'Bar', 'FooBar' ], 'path' => '/foobar', ], ], ],];

Do you agree? Would you want to try to cram that into this PR as well or
do it as a future PR hopefully still targeting 1.0.0?


Reply to this email directly or view it on GitHub
https://github.com/zendframework/zend-expressive/pull/192/files#r45830835
.

Matthew Weier O'Phinney
matthew@weierophinney.net
https://mwop.net/

$name = isset($spec['name']) ? $spec['name'] : null;
$route = new Route($spec['path'], $spec['middleware'], $methods, $name);

$name = isset($spec['name']) ? $spec['name'] : null;

$route = new Route($spec['path'], $spec['middleware'], $methods, $name);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change can be reverted?


if (isset($spec['options'])) {
$options = $spec['options'];
Expand Down Expand Up @@ -209,12 +212,17 @@ private function injectMiddleware(array $collection, Application $app, Container
continue;
}

$path = isset($spec['path']) ? $spec['path'] : '/';
$middleware = $spec['middleware'];
$error = array_key_exists('error', $spec) ? (bool) $spec['error'] : false;
$pipe = $error ? 'pipeErrorHandler' : 'pipe';
$path = isset($spec['path']) ? $spec['path'] : '/';
$error = array_key_exists('error', $spec) ? (bool) $spec['error'] : false;
$pipe = $error ? 'pipeErrorHandler' : 'pipe';

$app->{$pipe}($path, $middleware);
if (is_array($spec['middleware'])) {
foreach ($spec['middleware'] as $middleware) {
$app->{$pipe}($path, $middleware);
}
} else {
$app->{$pipe}($path, $spec['middleware']);
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/Router/Route.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public function __construct($path, $middleware, $methods = self::HTTP_METHOD_ANY
throw new Exception\InvalidArgumentException('Invalid path; must be a string');
}

if (! is_callable($middleware) && ! is_string($middleware)) {
if (! is_callable($middleware) && ! is_string($middleware) && ! is_array($middleware)) {
throw new Exception\InvalidArgumentException('Invalid middleware; must be callable or a service name');
}

Expand Down Expand Up @@ -121,7 +121,7 @@ public function getName()
}

/**
* @return string|callable
* @return string|callable|array
*/
public function getMiddleware()
{
Expand Down
2 changes: 1 addition & 1 deletion src/Router/RouteResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ public function getMatchedRouteName()
/**
* Retrieve the matched middleware, if possible.
*
* @return false|callable|string Returns false if the result represents a
* @return false|callable|string|array Returns false if the result represents a
* failure; otherwise, a callable or a string service name.
*/
public function getMatchedMiddleware()
Expand Down
48 changes: 48 additions & 0 deletions test/ApplicationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@
use PHPUnit_Framework_TestCase as TestCase;
use Prophecy\Argument;
use ReflectionProperty;
use Zend\Diactoros\Response;
use Zend\Diactoros\Response\SapiEmitter;
use Zend\Diactoros\ServerRequest as Request;
use Zend\Diactoros\ServerRequest;
use Zend\Expressive\Application;
use Zend\Expressive\Emitter\EmitterStack;
use Zend\Expressive\Router\Route;
use Zend\Expressive\Router\RouteResult;
use Zend\Stratigility\Route as StratigilityRoute;
use ZendTest\Expressive\TestAsset\InvokableMiddleware;

/**
* @covers Zend\Expressive\Application
Expand Down Expand Up @@ -228,6 +231,51 @@ public function testRouteMiddlewareIsPipedAtFirstCallToRoute()
$this->assertSame($routeMiddleware, $test);
}

public function testRouteMiddlewareCanRouteArrayOfMiddlewareAsMiddlewarePipe()
{
$middleware = [
function () {
},
'FooBar',
[InvokableMiddleware::class, 'staticallyCallableMiddleware'],
InvokableMiddleware::class,
];

$request = new ServerRequest([], [], '/', 'GET');
$routeResult = RouteResult::fromRouteMatch(__METHOD__, $middleware, []);
$this->router->match($request)->willReturn($routeResult);

$container = $this->mockContainerInterface();
$this->injectServiceInContainer($container, 'FooBar', function () {
});

$app = new Application($this->router->reveal(), $container->reveal());
$app->routeMiddleware($request, new Response(), function () {
});
}

public function uncallableMiddleware()
{
return [
['foo'],
[['foo']]
];
}

/**
* @dataProvider uncallableMiddleware
* @expectedException \Zend\Expressive\Exception\InvalidMiddlewareException
*/
public function testThrowsExceptionWhenRoutingUncallableMiddleware($middleware)
{
$request = new ServerRequest([], [], '/', 'GET');
$routeResult = RouteResult::fromRouteMatch(__METHOD__, $middleware, []);
$this->router->match($request)->willReturn($routeResult);

$this->getApp()->routeMiddleware($request, new Response(), function () {
});
}

public function testCannotPipeRouteMiddlewareMoreThanOnce()
{
$app = $this->getApp();
Expand Down
73 changes: 71 additions & 2 deletions test/Container/ApplicationFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
use Zend\Diactoros\Response\EmitterInterface;
use Zend\Expressive\Application;
use Zend\Expressive\Container\ApplicationFactory;
use Zend\Expressive\FinalHandler;
use Zend\Expressive\Router\Route;
use Zend\Expressive\Router\RouterInterface;
use ZendTest\Expressive\ContainerTrait;
use Zend\Expressive\Container\Exception\InvalidArgumentException;
use ZendTest\Expressive\TestAsset\InvokableMiddleware;

/**
* @covers Zend\Expressive\Container\ApplicationFactory
Expand Down Expand Up @@ -104,13 +106,28 @@ public function testFactoryWillPullAllReplaceableDependenciesFromContainerWhenPr
$this->assertSame($this->finalHandler, $app->getFinalHandler());
}

public function testFactorySetsUpRoutesFromConfig()
public function callableMiddlewares()
{
return [
['HelloWorld'],
[
function () {
}
],
[[InvokableMiddleware::class, 'staticallyCallableMiddleware']],
];
}

/**
* @dataProvider callableMiddlewares
*/
public function testFactorySetsUpRoutesFromConfig($middleware)
{
$config = [
'routes' => [
[
'path' => '/',
'middleware' => 'HelloWorld',
'middleware' => $middleware,
'allowed_methods' => [ 'GET' ],
],
[
Expand Down Expand Up @@ -339,6 +356,58 @@ public function testRaisesExceptionForNonCallableNonServiceMiddleware($middlewar
$app = $this->factory->__invoke($this->container->reveal());
}

/**
* @group piping
*/
public function testCanPipePreRoutingMiddlewareAsArray()
{
$config = [
'middleware_pipeline' => [
'pre_routing' => [
[
'middleware' => [
'Hello',
function () {
},
],
],
],
],
];

$this->injectServiceInContainer($this->container, 'config', $config);
$this->injectServiceInContainer($this->container, 'Hello', function () {
});

$this->factory->__invoke($this->container->reveal());
}

/**
* @group piping
*/
public function testCanPipePostRoutingMiddlewareAsArray()
{
$config = [
'middleware_pipeline' => [
'post_routing' => [
[
'middleware' => [
'Hello',
function () {
},
],
],
],
],
];

$this->injectServiceInContainer($this->container, 'config', $config);
$this->injectServiceInContainer($this->container, 'Hello', function () {
});

$this->factory->__invoke($this->container->reveal());
}

/**
* @group piping
*/
Expand Down
5 changes: 5 additions & 0 deletions test/TestAsset/InvokableMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
class InvokableMiddleware
{
public function __invoke($request, $response, $next)
{
return self::staticallyCallableMiddleware($request, $response, $next);
}

public static function staticallyCallableMiddleware($request, $response, $next)
{
return $response->withHeader('X-Invoked', __CLASS__);
}
Expand Down