diff --git a/src/Controller/MiddlewareController.php b/src/Controller/MiddlewareController.php new file mode 100644 index 000000000..e052ae639 --- /dev/null +++ b/src/Controller/MiddlewareController.php @@ -0,0 +1,122 @@ +eventIdentifier = __CLASS__; + $this->pipe = $pipe; + $this->responsePrototype = $responsePrototype; + + $this->setEventManager($eventManager); + $this->setEvent($event); + } + + /** + * {@inheritDoc} + * + * @throws RuntimeException + */ + public function onDispatch(MvcEvent $e) + { + $routeMatch = $e->getRouteMatch(); + $psr7Request = $this->populateRequestParametersFromRoute( + $this->loadRequest()->withAttribute(RouteMatch::class, $routeMatch), + $routeMatch + ); + + $result = $this->pipe->process($psr7Request, new CallableDelegateDecorator( + function () { + throw ReachedFinalHandlerException::create(); + }, + $this->responsePrototype + )); + + $e->setResult($result); + + return $result; + } + + /** + * @return \Zend\Diactoros\ServerRequest + * + * @throws RuntimeException + */ + private function loadRequest() + { + $request = $this->request; + + if (! $request instanceof Request) { + throw new RuntimeException(sprintf( + 'Expected request to be a %s, %s given', + Request::class, + get_class($request) + )); + } + + return Psr7ServerRequest::fromZend($request); + } + + /** + * @param ServerRequestInterface $request + * @param RouteMatch|null $routeMatch + * + * @return ServerRequestInterface + */ + private function populateRequestParametersFromRoute(ServerRequestInterface $request, RouteMatch $routeMatch = null) + { + if (! $routeMatch) { + return $request; + } + + foreach ($routeMatch->getParams() as $key => $value) { + $request = $request->withAttribute($key, $value); + } + + return $request; + } +} diff --git a/src/DispatchListener.php b/src/DispatchListener.php index d79881529..f6b1dab69 100644 --- a/src/DispatchListener.php +++ b/src/DispatchListener.php @@ -76,6 +76,10 @@ public function attach(EventManagerInterface $events, $priority = 1) */ public function onDispatch(MvcEvent $e) { + if (null !== $e->getResult()) { + return; + } + $routeMatch = $e->getRouteMatch(); $controllerName = $routeMatch instanceof RouteMatch ? $routeMatch->getParam('controller', 'not-found') diff --git a/src/MiddlewareListener.php b/src/MiddlewareListener.php index 3c3aaab62..f8c0f1285 100644 --- a/src/MiddlewareListener.php +++ b/src/MiddlewareListener.php @@ -18,7 +18,7 @@ use Zend\EventManager\EventManagerInterface; use Zend\Mvc\Exception\InvalidMiddlewareException; use Zend\Mvc\Exception\ReachedFinalHandlerException; -use Zend\Psr7Bridge\Psr7ServerRequest as Psr7Request; +use Zend\Mvc\Controller\MiddlewareController; use Zend\Psr7Bridge\Psr7Response; use Zend\Router\RouteMatch; use Zend\Stratigility\Delegate\CallableDelegateDecorator; @@ -45,6 +45,10 @@ public function attach(EventManagerInterface $events, $priority = 1) */ public function onDispatch(MvcEvent $event) { + if (null !== $event->getResult()) { + return; + } + $routeMatch = $event->getRouteMatch(); $middleware = $routeMatch->getParam('middleware', false); if (false === $middleware) { @@ -78,16 +82,12 @@ public function onDispatch(MvcEvent $event) $caughtException = null; try { - $psr7Request = Psr7Request::fromZend($request)->withAttribute(RouteMatch::class, $routeMatch); - foreach ($routeMatch->getParams() as $key => $value) { - $psr7Request = $psr7Request->withAttribute($key, $value); - } - $return = $pipe->process($psr7Request, new CallableDelegateDecorator( - function (PsrServerRequestInterface $request, PsrResponseInterface $response) { - throw ReachedFinalHandlerException::create(); - }, - $psr7ResponsePrototype - )); + $return = (new MiddlewareController( + $pipe, + $psr7ResponsePrototype, + $application->getServiceManager()->get('EventManager'), + $event + ))->dispatch($request, $response); } catch (\Throwable $ex) { $caughtException = $ex; } catch (\Exception $ex) { // @TODO clean up once PHP 7 requirement is enforced @@ -107,6 +107,8 @@ function (PsrServerRequestInterface $request, PsrResponseInterface $response) { } } + $event->setError(''); + if (! $return instanceof PsrResponseInterface) { $event->setResult($return); return $return; diff --git a/test/Controller/MiddlewareControllerTest.php b/test/Controller/MiddlewareControllerTest.php new file mode 100644 index 000000000..6ea27ca86 --- /dev/null +++ b/test/Controller/MiddlewareControllerTest.php @@ -0,0 +1,147 @@ +pipe = $this->createMock(MiddlewarePipe::class); + $this->responsePrototype = $this->createMock(ResponseInterface::class); + $this->eventManager = $this->createMock(EventManagerInterface::class); + $this->event = new MvcEvent(); + $this->eventManager = new EventManager(); + + $this->controller = new MiddlewareController( + $this->pipe, + $this->responsePrototype, + $this->eventManager, + $this->event + ); + } + + public function testWillAssignCorrectEventManagerIdentifiers() + { + $identifiers = $this->eventManager->getIdentifiers(); + + self::assertContains(MiddlewareController::class, $identifiers); + self::assertContains(AbstractController::class, $identifiers); + self::assertContains(DispatchableInterface::class, $identifiers); + } + + public function testWillDispatchARequestAndResponseWithAGivenPipe() + { + $request = new Request(); + $response = new Response(); + $result = $this->createMock(ResponseInterface::class); + /* @var $dispatchListener callable|\PHPUnit_Framework_MockObject_MockObject */ + $dispatchListener = $this->getMockBuilder(\stdClass::class)->setMethods(['__invoke'])->getMock(); + + $this->eventManager->attach(MvcEvent::EVENT_DISPATCH, $dispatchListener, 100); + $this->eventManager->attach(MvcEvent::EVENT_DISPATCH_ERROR, function () { + self::fail('No dispatch error expected'); + }, 100); + + $dispatchListener + ->expects(self::once()) + ->method('__invoke') + ->with(self::callback(function (MvcEvent $event) use ($request, $response) { + self::assertSame($this->event, $event); + self::assertSame(MvcEvent::EVENT_DISPATCH, $event->getName()); + self::assertSame($this->controller, $event->getTarget()); + self::assertSame($request, $event->getRequest()); + self::assertSame($response, $event->getResponse()); + + return true; + })); + + $this->pipe->expects(self::once())->method('process')->willReturn($result); + + $controllerResult = $this->controller->dispatch($request, $response); + + self::assertSame($result, $controllerResult); + self::assertSame($result, $this->event->getResult()); + } + + public function testWillRefuseDispatchingInvalidRequestTypes() + { + /* @var $request RequestInterface */ + $request = $this->createMock(RequestInterface::class); + $response = new Response(); + /* @var $dispatchListener callable|\PHPUnit_Framework_MockObject_MockObject */ + $dispatchListener = $this->getMockBuilder(\stdClass::class)->setMethods(['__invoke'])->getMock(); + + $this->eventManager->attach(MvcEvent::EVENT_DISPATCH, $dispatchListener, 100); + + $dispatchListener + ->expects(self::once()) + ->method('__invoke') + ->with(self::callback(function (MvcEvent $event) use ($request, $response) { + self::assertSame($this->event, $event); + self::assertSame(MvcEvent::EVENT_DISPATCH, $event->getName()); + self::assertSame($this->controller, $event->getTarget()); + self::assertSame($request, $event->getRequest()); + self::assertSame($response, $event->getResponse()); + + return true; + })); + + $this->pipe->expects(self::never())->method('process'); + + $this->expectException(RuntimeException::class); + + $this->controller->dispatch($request, $response); + } +} diff --git a/test/DispatchListenerTest.php b/test/DispatchListenerTest.php index cc93d7ddc..590917e5c 100644 --- a/test/DispatchListenerTest.php +++ b/test/DispatchListenerTest.php @@ -19,6 +19,8 @@ use Zend\Mvc\MvcEvent; use Zend\Router\RouteMatch; use Zend\ServiceManager\ServiceManager; +use Zend\Stdlib\ResponseInterface; +use Zend\View\Model\ModelInterface; class DispatchListenerTest extends TestCase { @@ -83,4 +85,49 @@ public function testUnlocatableControllerViaAbstractFactory() $this->assertArrayHasKey('error', $log); $this->assertSame('error-controller-not-found', $log['error']); } + + /** + * @dataProvider alreadySetMvcEventResultProvider + * + * @param mixed $alreadySetResult + */ + public function testWillNotDispatchWhenAnMvcEventResultIsAlreadySet($alreadySetResult) + { + $event = $this->createMvcEvent('path'); + + $event->setResult($alreadySetResult); + + $listener = new DispatchListener(new ControllerManager(new ServiceManager(), ['abstract_factories' => [ + Controller\TestAsset\UnlocatableControllerLoaderAbstractFactory::class, + ]])); + + $event->getApplication()->getEventManager()->attach(MvcEvent::EVENT_DISPATCH_ERROR, function () { + self::fail('No dispatch failures should be raised - dispatch should be skipped'); + }); + + $listener->onDispatch($event); + + self::assertSame($alreadySetResult, $event->getResult(), 'The event result was not replaced'); + } + + /** + * @return mixed[][] + */ + public function alreadySetMvcEventResultProvider() + { + return [ + [123], + [true], + [false], + [[]], + [new \stdClass()], + [$this], + [$this->createMock(ModelInterface::class)], + [$this->createMock(ResponseInterface::class)], + [$this->createMock(Response::class)], + [['view model data' => 'as an array']], + [['foo' => new \stdClass()]], + ['a response string'], + ]; + } } diff --git a/test/MiddlewareListenerTest.php b/test/MiddlewareListenerTest.php index 9a70cc31e..dc643f8e7 100644 --- a/test/MiddlewareListenerTest.php +++ b/test/MiddlewareListenerTest.php @@ -10,13 +10,14 @@ namespace ZendTest\Mvc; use Interop\Container\ContainerInterface; -use Interop\Http\ServerMiddleware\DelegateInterface; use Interop\Http\ServerMiddleware\MiddlewareInterface; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Zend\Diactoros\Response\HtmlResponse; +use Zend\Diactoros\Response as DiactorosResponse; use Zend\EventManager\EventManager; +use Zend\EventManager\SharedEventManager; use Zend\Http\Request; use Zend\Http\Response; use Zend\Mvc\Application; @@ -26,6 +27,8 @@ use Zend\Mvc\MvcEvent; use Zend\Router\RouteMatch; use Zend\ServiceManager\ServiceManager; +use Zend\Stdlib\DispatchableInterface; +use Zend\View\Model\ModelInterface; class MiddlewareListenerTest extends TestCase { @@ -48,17 +51,21 @@ public function createMvcEvent($middlewareMatched, $middleware = null) $this->routeMatch->getParam('middleware', false)->willReturn($middlewareMatched); $this->routeMatch->getParams()->willReturn([]); - $eventManager = new EventManager(); - - $serviceManager = $this->prophesize(ContainerInterface::class); - $serviceManager->has($middlewareMatched)->willReturn(true); - $serviceManager->get($middlewareMatched)->willReturn($middleware); + $eventManager = new EventManager(); + $serviceManager = new ServiceManager([ + 'factories' => [ + 'EventManager' => function () { + return new EventManager(); + }, + ], + 'services' => [ + $middlewareMatched => $middleware, + ], + ]); $application = $this->prophesize(Application::class); $application->getEventManager()->willReturn($eventManager); - $application->getServiceManager()->will(function () use ($serviceManager) { - return $serviceManager->reveal(); - }); + $application->getServiceManager()->willReturn($serviceManager); $application->getResponse()->willReturn($response); $event = new MvcEvent(); @@ -154,6 +161,7 @@ public function testSuccessfullyDispatchesPipeOfCallableAndHttpInteropStyleMiddl $eventManager = new EventManager(); $serviceManager = $this->prophesize(ContainerInterface::class); + $serviceManager->get('EventManager')->willReturn($eventManager); $serviceManager->has('firstMiddleware')->willReturn(true); $serviceManager->get('firstMiddleware')->willReturn(function ($request, $response, $next) { $this->assertInstanceOf(ServerRequestInterface::class, $request); @@ -247,6 +255,12 @@ public function testCanLoadFromAbstractFactory() $serviceManager = new ServiceManager(); $serviceManager->addAbstractFactory(TestAsset\MiddlewareAbstractFactory::class); + $serviceManager->setFactory( + 'EventManager', + function () { + return new EventManager(); + } + ); $application = $this->prophesize(Application::class); $application->getEventManager()->willReturn($eventManager); @@ -265,7 +279,6 @@ public function testCanLoadFromAbstractFactory() $listener = new MiddlewareListener(); $return = $listener->onDispatch($event); - $this->assertInstanceOf(Response::class, $return); $this->assertInstanceOf(Response::class, $return); $this->assertSame(200, $return->getStatusCode()); @@ -289,6 +302,8 @@ public function testMiddlewareWithNothingPipedReachesFinalHandlerException() }); $application->getResponse()->willReturn($response); + $serviceManager->get('EventManager')->willReturn($eventManager); + $event = new MvcEvent(); $event->setRequest(new Request()); $event->setResponse($response); @@ -340,4 +355,158 @@ public function testNullMiddlewareThrowsInvalidMiddlewareException() $return = $listener->onDispatch($event); $this->assertEquals('FAILED', $return); } + + public function testValidMiddlewareDispatchCancelsPreviousDispatchFailures() + { + $middlewareName = uniqid('middleware', true); + $routeMatch = new RouteMatch(['middleware' => $middlewareName]); + $response = new DiactorosResponse(); + /* @var $application Application|\PHPUnit_Framework_MockObject_MockObject */ + $application = $this->createMock(Application::class); + $eventManager = new EventManager(); + $middleware = $this->getMockBuilder(\stdClass::class)->setMethods(['__invoke'])->getMock(); + $serviceManager = new ServiceManager([ + 'factories' => [ + 'EventManager' => function () { + return new EventManager(); + }, + ], + 'services' => [ + $middlewareName => $middleware, + ], + ]); + + $application->expects(self::any())->method('getRequest')->willReturn(new Request()); + $application->expects(self::any())->method('getEventManager')->willReturn($eventManager); + $application->expects(self::any())->method('getServiceManager')->willReturn($serviceManager); + $application->expects(self::any())->method('getResponse')->willReturn(new Response()); + $middleware->expects(self::once())->method('__invoke')->willReturn($response); + + $event = new MvcEvent(); + + $event->setRequest(new Request()); + $event->setApplication($application); + $event->setError(Application::ERROR_CONTROLLER_CANNOT_DISPATCH); + $event->setRouteMatch($routeMatch); + + $listener = new MiddlewareListener(); + $result = $listener->onDispatch($event); + + self::assertInstanceOf(Response::class, $result); + self::assertInstanceOf(Response::class, $event->getResult()); + self::assertEmpty($event->getError(), 'Previously set MVC errors are canceled by a successful dispatch'); + } + + public function testValidMiddlewareFiresDispatchableInterfaceEventListeners() + { + $middlewareName = uniqid('middleware', true); + $routeMatch = new RouteMatch(['middleware' => $middlewareName]); + $response = new DiactorosResponse(); + /* @var $application Application|\PHPUnit_Framework_MockObject_MockObject */ + $application = $this->createMock(Application::class); + $sharedManager = new SharedEventManager(); + /* @var $sharedListener callable|\PHPUnit_Framework_MockObject_MockObject */ + $sharedListener = $this->getMockBuilder(\stdClass::class)->setMethods(['__invoke'])->getMock(); + $eventManager = new EventManager(); + $middleware = $this->getMockBuilder(\stdClass::class)->setMethods(['__invoke'])->getMock(); + $serviceManager = new ServiceManager([ + 'factories' => [ + 'EventManager' => function () use ($sharedManager) { + return new EventManager($sharedManager); + }, + ], + 'services' => [ + $middlewareName => $middleware, + ], + ]); + + $application->expects(self::any())->method('getRequest')->willReturn(new Request()); + $application->expects(self::any())->method('getEventManager')->willReturn($eventManager); + $application->expects(self::any())->method('getServiceManager')->willReturn($serviceManager); + $application->expects(self::any())->method('getResponse')->willReturn(new Response()); + $middleware->expects(self::once())->method('__invoke')->willReturn($response); + + $event = new MvcEvent(); + + $event->setRequest(new Request()); + $event->setApplication($application); + $event->setError(Application::ERROR_CONTROLLER_CANNOT_DISPATCH); + $event->setRouteMatch($routeMatch); + + $listener = new MiddlewareListener(); + + $sharedManager->attach(DispatchableInterface::class, MvcEvent::EVENT_DISPATCH, $sharedListener); + $sharedListener->expects(self::once())->method('__invoke')->with($event); + + $listener->onDispatch($event); + } + + /** + * @dataProvider alreadySetMvcEventResultProvider + * + * @param mixed $alreadySetResult + */ + public function testWillNotDispatchWhenAnMvcEventResultIsAlreadySet($alreadySetResult) + { + $middlewareName = uniqid('middleware', true); + $routeMatch = new RouteMatch(['middleware' => $middlewareName]); + /* @var $application Application|\PHPUnit_Framework_MockObject_MockObject */ + $application = $this->createMock(Application::class); + $eventManager = new EventManager(); + $middleware = $this->getMockBuilder(\stdClass::class)->setMethods(['__invoke'])->getMock(); + $serviceManager = new ServiceManager([ + 'factories' => [ + 'EventManager' => function () { + return new EventManager(); + }, + ], + 'services' => [ + $middlewareName => $middleware, + ], + ]); + + $application->expects(self::any())->method('getRequest')->willReturn(new Request()); + $application->expects(self::any())->method('getEventManager')->willReturn($eventManager); + $application->expects(self::any())->method('getServiceManager')->willReturn($serviceManager); + $application->expects(self::any())->method('getResponse')->willReturn(new Response()); + $middleware->expects(self::never())->method('__invoke'); + + $event = new MvcEvent(); + + $event->setResult($alreadySetResult); // a result is already there - listener should bail out early + $event->setRequest(new Request()); + $event->setApplication($application); + $event->setError(Application::ERROR_CONTROLLER_CANNOT_DISPATCH); + $event->setRouteMatch($routeMatch); + + $listener = new MiddlewareListener(); + + $eventManager->attach(MvcEvent::EVENT_DISPATCH_ERROR, function () { + self::fail('No dispatch failures should be raised - dispatch should be skipped'); + }); + + $listener->onDispatch($event); + + self::assertSame($alreadySetResult, $event->getResult(), 'The event result was not replaced'); + } + + /** + * @return mixed[][] + */ + public function alreadySetMvcEventResultProvider() + { + return [ + [123], + [true], + [false], + [[]], + [new \stdClass()], + [$this], + [$this->createMock(ModelInterface::class)], + [$this->createMock(Response::class)], + [['view model data' => 'as an array']], + [['foo' => new \stdClass()]], + ['a response string'], + ]; + } } diff --git a/test/View/RouteNotFoundStrategyTest.php b/test/View/RouteNotFoundStrategyTest.php index c83a85600..4073002ac 100644 --- a/test/View/RouteNotFoundStrategyTest.php +++ b/test/View/RouteNotFoundStrategyTest.php @@ -23,6 +23,11 @@ class RouteNotFoundStrategyTest extends TestCase { use EventListenerIntrospectionTrait; + /** + * @var RouteNotFoundStrategy + */ + private $strategy; + public function setUp() { $this->strategy = new RouteNotFoundStrategy();