Commit 3e6fe5e9 authored by Fabien Potencier's avatar Fabien Potencier

merged branch DrBenton/after-on-controllers (PR #272)

Commits
-------

6445b9e9 Add Routes Middlewares

Discussion
----------

add "after($callback)" to Controller in order to mimic a "Route Middleware" feature in Silex

Hi !

As discussed in #262 and in my previous similar PR (https://github.com/fabpot/Silex/pull/270) with @GromNaN, here is another implementation of the Routes Middlewares feature, shipped with its Unit Test.

The feature addition is still 100% backward compatible, but this time it may be more "Silex style" compliant - and it's "100% no performance loss" :-)

With this PR the Silex ```Controller``` class has a new ```before()``` method, which registers a callback to trigger when the Route is matched, just before the route callback itself.
This ```before()``` method can be called more than one time on a single Route definition, allowing multiple secondary callbacks (a.k.a. "Routes Middlewares") to be executed before the Route main callback.

Would this feature be eligible for Silex integration, like this ?

Olivier

---------------------------------------------------------------------------

by DrBenton at 2012-03-01T22:16:05Z

* The method is now named ```addMiddleware```, which may be more explicit for people used to "a la Sinatra" framewoks like _Node.js Express_  or _Slim_.
* I no longer use the Silex ControllerResolver, but a dedicated Application protected service instead, fired just before the Application "before" process (I could have added a new listener on the ```Kernel```, but since we already have one for the right event for the "before" process, I used this one for simpler code).
* I also added some checks on the ```$request``` in the Unit Test.

---------------------------------------------------------------------------

by fabpot at 2012-03-02T11:40:30Z

Can you add some information about this new feature in the documentation (with some typical usages)? Thanks for your work on this feature.

---------------------------------------------------------------------------

by DrBenton at 2012-03-02T12:35:44Z

**Done !** (the method is now named ```middleware```, and I've removed the useless comment)

I've tried my best for the feature documentation, but unfortunately my english is fairly poor... :-/

At this point, the Middlewares have access to the Request, but if I'm not wrong it can't be used for redirecting, for instance. Should I handle ```Response``` returns from middlewares, as in ```before``` filter ?

---------------------------------------------------------------------------

by GromNaN at 2012-03-02T12:39:00Z

I guess you can throw an exception (access denied for example) and manage this exception with the error handlers.

---------------------------------------------------------------------------

by DrBenton at 2012-03-02T12:44:38Z

Yes, in the sample usage of the documentation I've used exactly that ```Exception``` process.
But I don't know if this is ok for everyone ?

---------------------------------------------------------------------------

by igorw at 2012-03-02T15:08:29Z

Very nice feature. But it should really be possible to return a response from the handler, like you can do with before() filters.

---------------------------------------------------------------------------

by DrBenton at 2012-03-02T21:15:41Z

All right, I think I know how to handle Responses, thanks to the code of the before() filter ; I'll push soon a new commit with the last code syle fixes and this feature !

---------------------------------------------------------------------------

by DrBenton at 2012-03-03T11:34:44Z

Ok, I've fixed the code style problems, and handled the Responses from middlewares.
What do you think of it ?

---------------------------------------------------------------------------

by DrBenton at 2012-03-05T13:11:56Z

So... Are we OK for this feature, gentlemen ? :-)
Do you want me to push a squashed commit ?

---------------------------------------------------------------------------

by igorw at 2012-03-05T22:24:53Z

Apart from the minor comments I made I am quite happy with the implementation as it is. I guess when those are fixed it can be merged.

---------------------------------------------------------------------------

by DrBenton at 2012-03-05T22:58:23Z

Sorry for all these "code style" errors, I'm still not enough used to Symfony 2 coding standards - but I'll get them ! :-)

Well, all these errors should be fixed now. I also swapped  the order of execution with the Silex global ```before``` filters, as you asked it: the global filters are now triggered **before** the middlewares of the matched route.

---------------------------------------------------------------------------

by igorw at 2012-03-05T23:10:35Z

I'm not so sure about checking for `instanceof Response`. Maybe we should enforce a middleware to return either null or a Response to prevent unexpected behaviour.

---------------------------------------------------------------------------

by DrBenton at 2012-03-06T14:37:42Z

Well, I just copied the behaviour of the ```before``` filter :
```
if ($ret instanceof Response) {
    $event->setResponse($ret);
}```
(in Application.php, line 237)

Since route middlewares act as this ```before``` filter (in a "specific for each route" way), I thought we could copy the code of this ```before``` functionnality ?

---------------------------------------------------------------------------

by DrBenton at 2012-03-08T14:34:13Z

So ? Do you want me to change the ```instanceof Response```, or can we keep the current "copy of before filter" implementation ?

---------------------------------------------------------------------------

by igorw at 2012-03-08T15:57:00Z

IMO we should add something like this:

    if ($response instanceof Response) {
        ...
    } elseif (null !== $response) {
        throw new \RuntimeException('Before filter (or middleware for route foo) returned an invalid response value. Must return null or an instance of Response.');
    }

---------------------------------------------------------------------------

by DrBenton at 2012-03-08T16:26:54Z

Hum, I see. Sounds good to me, I'll push it soon with the documentation fixes :-)

---------------------------------------------------------------------------

by igorw at 2012-03-08T16:42:48Z

And tests please ;-)

---------------------------------------------------------------------------

by DrBenton at 2012-03-08T22:15:11Z

Here it is, sir ! :-)

---------------------------------------------------------------------------

by DrBenton at 2012-03-10T19:45:59Z

Shall I push in a new PR these 13 commits in a single squashed one ?

---------------------------------------------------------------------------

by igorw at 2012-03-10T19:51:53Z

👍

---------------------------------------------------------------------------

by stof at 2012-03-10T19:52:43Z

No need to create a new PR for this. Simply squash your commits and force the push to the branch

---------------------------------------------------------------------------

by DrBenton at 2012-03-10T21:59:12Z

All right, here is the squashed commit ! :-)
parents eaa9500f 6445b9e9
...@@ -352,6 +352,63 @@ object that is returned by the routing methods:: ...@@ -352,6 +352,63 @@ object that is returned by the routing methods::
It only makes sense to name routes if you use providers that make use It only makes sense to name routes if you use providers that make use
of the ``RouteCollection``. of the ``RouteCollection``.
Routes middlewares
~~~~~~~~~~~~~~~~~~
You can define one or more Routes Middlewares, and link them to your routes.
Routes Middlewares are just "PHP callables" (i.e. a Closure or a
"ClassName::methodName" string, like others Silex callbacks), which will
be triggered when their route is matched.
Middlewares are fired just before the route callback,
but after Application ``before`` filters, which have precedence
- see next section about these ``before`` filters.
This mechanism can be used for a lot of use case - for example, a
"anonymous/logged user" simple control::
$mustBeAnonymous = function (Request $request) use ($app) {
if ($app['session']->has('userId')) {
return $app->redirect('/user/logout');
}
};
$mustBeLogged = function (Request $request) use ($app) {
if (!$app['session']->has('userId')) {
return $app->redirect('/user/login');
}
};
$app->get('/user/subscribe', function () {
...
})
->middleware($mustBeAnonymous);
$app->get('/user/login', function () {
...
})
->middleware($mustBeAnonymous);
$app->get('/user/my-profile', function () {
...
})
->middleware($mustBeLogged);
You can call the ``middleware`` function several times for a single route.
The middlewares will be triggered in the order you added them to the route.
For convenience, the routes middlewares functions are triggered with the current
Request as their only argument.
If any of the routes middlewares returns a Symfony Http Response, this response
will short-circuit the whole rendering : the next middlewares won't run, neither
the route callback.
As in route callbacks, you can redirect to another page by from a route middleware
by returning a redirect response, which you can create by calling the
Application ``redirect`` method.
A route Middleware can return a Symfony Http Response or null.
A RuntimeException will be thrown if anything else is returned.
Before and after filters Before and after filters
------------------------ ------------------------
......
...@@ -111,6 +111,18 @@ class Application extends \Pimple implements HttpKernelInterface, EventSubscribe ...@@ -111,6 +111,18 @@ class Application extends \Pimple implements HttpKernelInterface, EventSubscribe
return new RedirectableUrlMatcher($app['routes'], $app['request_context']); return new RedirectableUrlMatcher($app['routes'], $app['request_context']);
}); });
$this['route_middlewares_trigger'] = $this->protect(function (KernelEvent $event) use ($app) {
foreach ($event->getRequest()->attributes->get('_middlewares', array()) as $callback) {
$ret = call_user_func($callback, $event->getRequest());
if ($ret instanceof Response) {
$event->setResponse($ret);
return;
} elseif (null !== $ret) {
throw new \RuntimeException('Middleware for route "'.$event->getRequest()->attributes->get('_route').'" returned an invalid response value. Must return null or an instance of Response.');
}
}
});
$this['request.default_locale'] = 'en'; $this['request.default_locale'] = 'en';
$this['request'] = function () { $this['request'] = function () {
...@@ -409,6 +421,7 @@ class Application extends \Pimple implements HttpKernelInterface, EventSubscribe ...@@ -409,6 +421,7 @@ class Application extends \Pimple implements HttpKernelInterface, EventSubscribe
if (HttpKernelInterface::MASTER_REQUEST === $event->getRequestType()) { if (HttpKernelInterface::MASTER_REQUEST === $event->getRequestType()) {
$this->beforeDispatched = true; $this->beforeDispatched = true;
$this['dispatcher']->dispatch(SilexEvents::BEFORE, $event); $this['dispatcher']->dispatch(SilexEvents::BEFORE, $event);
$this['route_middlewares_trigger']($event);
} }
} }
......
...@@ -154,6 +154,22 @@ class Controller ...@@ -154,6 +154,22 @@ class Controller
return $this; return $this;
} }
/**
* Sets a callback to handle before triggering the route callback.
* (a.k.a. "Route Middleware")
*
* @param mixed $callback A PHP callback to be triggered when the Route is matched, just before the route callback
* @return Controller $this The current Controller instance
*/
public function middleware($callback)
{
$middlewareCallbacks = $this->route->getDefault('_middlewares');
$middlewareCallbacks[] = $callback;
$this->route->setDefault('_middlewares', $middlewareCallbacks);
return $this;
}
/** /**
* Freezes the controller. * Freezes the controller.
* *
......
...@@ -15,6 +15,9 @@ use Silex\Application; ...@@ -15,6 +15,9 @@ use Silex\Application;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\HttpKernelInterface;
/** /**
* Application test cases. * Application test cases.
...@@ -166,6 +169,120 @@ class ApplicationTest extends \PHPUnit_Framework_TestCase ...@@ -166,6 +169,120 @@ class ApplicationTest extends \PHPUnit_Framework_TestCase
$this->assertEquals('text/html; charset=ISO-8859-1', $response->headers->get('Content-Type')); $this->assertEquals('text/html; charset=ISO-8859-1', $response->headers->get('Content-Type'));
} }
public function testRoutesMiddlewares()
{
$app = new Application();
$test = $this;
$middlewareTarget = array();
$middleware1 = function (Request $request) use (&$middlewareTarget, $test) {
$test->assertEquals('/reached', $request->getRequestUri());
$middlewareTarget[] = 'middleware1_triggered';
};
$middleware2 = function (Request $request) use (&$middlewareTarget, $test) {
$test->assertEquals('/reached', $request->getRequestUri());
$middlewareTarget[] = 'middleware2_triggered';
};
$middleware3 = function (Request $request) use (&$middlewareTarget, $test) {
throw new \Exception('This middleware shouldn\'t run!');
};
$app->get('/reached', function () use (&$middlewareTarget) {
$middlewareTarget[] = 'route_triggered';
return 'hello';
})
->middleware($middleware1)
->middleware($middleware2);
$app->get('/never-reached', function () use (&$middlewareTarget) {
throw new \Exception('This route shouldn\'t run!');
})
->middleware($middleware3);
$result = $app->handle(Request::create('/reached'));
$this->assertSame(array('middleware1_triggered', 'middleware2_triggered', 'route_triggered'), $middlewareTarget);
$this->assertEquals('hello', $result->getContent());
}
public function testRoutesMiddlewaresWithResponseObject()
{
$app = new Application();
$app->get('/foo', function () {
throw new \Exception('This route shouldn\'t run!');
})
->middleware(function () {
return new Response('foo');
});
$request = Request::create('/foo');
$result = $app->handle($request);
$this->assertEquals('foo', $result->getContent());
}
public function testRoutesMiddlewaresWithRedirectResponseObject()
{
$app = new Application();
$app->get('/foo', function () {
throw new \Exception('This route shouldn\'t run!');
})
->middleware(function () use ($app) {
return $app->redirect('/bar');
});
$request = Request::create('/foo');
$result = $app->handle($request);
$this->assertInstanceOf('Symfony\Component\HttpFoundation\RedirectResponse', $result);
$this->assertEquals('/bar', $result->getTargetUrl());
}
public function testRoutesMiddlewaresTriggeredAfterSilexBeforeFilters()
{
$app = new Application();
$middlewareTarget = array();
$middleware = function (Request $request) use (&$middlewareTarget) {
$middlewareTarget[] = 'middleware_triggered';
};
$app->get('/foo', function () use (&$middlewareTarget) {
$middlewareTarget[] = 'route_triggered';
})
->middleware($middleware);
$app->before(function () use (&$middlewareTarget) {
$middlewareTarget[] = 'before_triggered';
});
$app->handle(Request::create('/foo'));
$this->assertSame(array('before_triggered', 'middleware_triggered', 'route_triggered'), $middlewareTarget);
}
/**
* @expectedException RuntimeException
*/
public function testNonResponseAndNonNullReturnFromRouteMiddlewareShouldThrowRuntimeException()
{
$app = new Application();
$middleware = function (Request $request) {
return 'string return';
};
$app->get('/', function () {
return 'hello';
})
->middleware($middleware);
$app->handle(Request::create('/'), HttpKernelInterface::MASTER_REQUEST, false);
}
/** /**
* @expectedException RuntimeException * @expectedException RuntimeException
*/ */
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment