diff --git a/config/module.config.php b/config/module.config.php index ab4ba3d..651f1ec 100644 --- a/config/module.config.php +++ b/config/module.config.php @@ -11,6 +11,7 @@ use ZF\ContentNegotiation\AcceptListener; use ZF\ContentNegotiation\ContentNegotiationOptions; use ZF\ContentNegotiation\ContentTypeFilterListener; +use ZF\ContentNegotiation\HttpMethodOverrideListener; use ZF\ContentNegotiation\ContentTypeListener; use ZF\ContentNegotiation\ControllerPlugin; use ZF\ContentNegotiation\Factory; @@ -45,6 +46,7 @@ AcceptFilterListener::class => Factory\AcceptFilterListenerFactory::class, ContentTypeFilterListener::class => Factory\ContentTypeFilterListenerFactory::class, ContentNegotiationOptions::class => Factory\ContentNegotiationOptionsFactory::class, + HttpMethodOverrideListener::class => Factory\HttpMethodOverrideListenerFactory::class, ], ], @@ -76,6 +78,11 @@ // Array of controller service name => allowed content type pairs. // The allowed content type may be a string, or an array of strings. 'content_type_whitelist' => [], + + // Enable x-http method override feature + // When set to 'true' the http method in the request will be overridden + // by the method inside the 'X-HTTP-Method-Override' header (if present) + 'x_http_method_override_enabled' => false, ], 'controller_plugins' => [ diff --git a/src/ContentNegotiationOptions.php b/src/ContentNegotiationOptions.php index dfca9de..295bd8a 100644 --- a/src/ContentNegotiationOptions.php +++ b/src/ContentNegotiationOptions.php @@ -30,6 +30,16 @@ class ContentNegotiationOptions extends AbstractOptions */ protected $contentTypeWhitelist = []; + /** + * @var boolean + */ + protected $xHttpMethodOverrideEnabled = false; + + /** + * @var array + */ + protected $httpOverrideMethods = []; + /** * {@inheritDoc} * @@ -128,4 +138,36 @@ public function getContentTypeWhitelist() { return $this->contentTypeWhitelist; } + + /** + * @param boolean $xHttpMethodOverrideEnabled + */ + public function setXHttpMethodOverrideEnabled($xHttpMethodOverrideEnabled) + { + $this->xHttpMethodOverrideEnabled = $xHttpMethodOverrideEnabled; + } + + /** + * @return boolean + */ + public function getXHttpMethodOverrideEnabled() + { + return $this->xHttpMethodOverrideEnabled; + } + + /** + * @param array $httpOverrideMethods + */ + public function setHttpOverrideMethods(array $httpOverrideMethods) + { + $this->httpOverrideMethods = $httpOverrideMethods; + } + + /** + * @return array + */ + public function getHttpOverrideMethods() + { + return $this->httpOverrideMethods; + } } diff --git a/src/Factory/HttpMethodOverrideListenerFactory.php b/src/Factory/HttpMethodOverrideListenerFactory.php new file mode 100644 index 0000000..a437f0a --- /dev/null +++ b/src/Factory/HttpMethodOverrideListenerFactory.php @@ -0,0 +1,27 @@ +<?php +/** + * @license http://opensource.org/licenses/BSD-3-Clause BSD-3-Clause + * @copyright Copyright (c) 2016 Zend Technologies USA Inc. (http://www.zend.com) + */ + +namespace ZF\ContentNegotiation\Factory; + +use Interop\Container\ContainerInterface; +use ZF\ContentNegotiation\ContentNegotiationOptions; +use ZF\ContentNegotiation\HttpMethodOverrideListener; + +class HttpMethodOverrideListenerFactory +{ + /** + * @param ContainerInterface $container + * @return HttpMethodOverrideListener + */ + public function __invoke(ContainerInterface $container) + { + $options = $container->get(ContentNegotiationOptions::class); + $httpOverrideMethods = $options->getHttpOverrideMethods(); + $listener = new HttpMethodOverrideListener($httpOverrideMethods); + + return $listener; + } +} diff --git a/src/HttpMethodOverrideListener.php b/src/HttpMethodOverrideListener.php new file mode 100644 index 0000000..301ea31 --- /dev/null +++ b/src/HttpMethodOverrideListener.php @@ -0,0 +1,85 @@ +<?php +/** + * @license http://opensource.org/licenses/BSD-3-Clause BSD-3-Clause + * @copyright Copyright (c) 2016 Zend Technologies USA Inc. (http://www.zend.com) + */ + +namespace ZF\ContentNegotiation; + +use Zend\EventManager\AbstractListenerAggregate; +use Zend\EventManager\EventManagerInterface; +use Zend\Mvc\MvcEvent; +use ZF\ApiProblem\ApiProblem; +use ZF\ApiProblem\ApiProblemResponse; +use Zend\Http\Request as HttpRequest; + +class HttpMethodOverrideListener extends AbstractListenerAggregate +{ + /** + * @var array + */ + protected $httpMethodOverride = []; + + /** + * HttpMethodOverrideListener constructor. + * + * @param array $httpMethodOverride + */ + public function __construct(array $httpMethodOverride) + { + $this->httpMethodOverride = $httpMethodOverride; + } + + /** + * Priority is set very high (should be executed before all other listeners that rely on the request method value). + * TODO: Check priority value, maybe value should be even higher?? + * + * @param EventManagerInterface $events + * @param int $priority + */ + public function attach(EventManagerInterface $events, $priority = 1) + { + $this->listeners[] = $events->attach(MvcEvent::EVENT_ROUTE, [$this, 'onRoute'], -40); + } + + /** + * Checks for X-HTTP-Method-Override header and sets header inside request object. + * + * @param MvcEvent $event + * @return void|ApiProblemResponse + */ + public function onRoute(MvcEvent $event) + { + $request = $event->getRequest(); + + if (! $request instanceof HttpRequest) { + return; + } + + if (! $request->getHeaders()->has('X-HTTP-Method-Override')) { + return; + } + + $method = $request->getMethod(); + + if (! array_key_exists($method, $this->httpMethodOverride)) { + return new ApiProblemResponse(new ApiProblem( + 400, + sprintf('Overriding %s method with X-HTTP-Method-Override header is not allowed', $method) + )); + } + + $header = $request->getHeader('X-HTTP-Method-Override'); + $overrideMethod = $header->getFieldValue(); + $allowedMethods = $this->httpMethodOverride[$method]; + + if (! in_array($overrideMethod, $allowedMethods)) { + return new ApiProblemResponse(new ApiProblem( + 400, + sprintf('Illegal override method %s in X-HTTP-Method-Override header', $overrideMethod) + )); + } + + $request->setMethod($overrideMethod); + } +} diff --git a/src/Module.php b/src/Module.php index a593d00..54d2a46 100644 --- a/src/Module.php +++ b/src/Module.php @@ -48,6 +48,11 @@ public function onBootstrap(MvcEvent $e) $services->get(AcceptFilterListener::class)->attach($eventManager); $services->get(ContentTypeFilterListener::class)->attach($eventManager); + $contentNegotiationOptions = $services->get(ContentNegotiationOptions::class); + if ($contentNegotiationOptions->getXHttpMethodOverrideEnabled()) { + $services->get(HttpMethodOverrideListener::class)->attach($eventManager); + } + $sharedEventManager = $eventManager->getSharedManager(); $sharedEventManager->attach( DispatchableInterface::class, diff --git a/test/ContentNegotiationOptionsTest.php b/test/ContentNegotiationOptionsTest.php index 70fc1a5..f6f7f55 100644 --- a/test/ContentNegotiationOptionsTest.php +++ b/test/ContentNegotiationOptionsTest.php @@ -16,6 +16,8 @@ public function dashSeparatedOptions() return [ 'accept-whitelist' => ['accept-whitelist', 'accept_whitelist'], 'content-type-whitelist' => ['content-type-whitelist', 'content_type_whitelist'], + 'x-http-method-override-enabled' => ['x-http-method-override-enabled', 'x_http_method_override_enabled'], + 'http-override-methods' => ['http-override-methods', 'http_override_methods'], ]; } diff --git a/test/Factory/HttpMethodOverrideListenerFactoryTest.php b/test/Factory/HttpMethodOverrideListenerFactoryTest.php new file mode 100644 index 0000000..5bf9517 --- /dev/null +++ b/test/Factory/HttpMethodOverrideListenerFactoryTest.php @@ -0,0 +1,35 @@ +<?php +/** + * @license http://opensource.org/licenses/BSD-3-Clause BSD-3-Clause + * @copyright Copyright (c) 2016 Zend Technologies USA Inc. (http://www.zend.com) + */ + +namespace ZFTest\ContentNegotiation\Factory; + +use PHPUnit_Framework_TestCase as TestCase; +use Prophecy\Prophecy\ObjectProphecy; +use Zend\ServiceManager\ServiceLocatorInterface; +use Zend\ServiceManager\ServiceManager; +use ZF\ContentNegotiation\ContentNegotiationOptions; +use ZF\ContentNegotiation\Factory\HttpMethodOverrideListenerFactory; +use ZF\ContentNegotiation\HttpMethodOverrideListener; + +class HttpMethodOverrideListenerFactoryTest extends TestCase +{ + public function testCreateServiceShouldReturnContentTypeFilterListenerInstance() + { + /** @var ContentNegotiationOptions|ObjectProphecy $options */ + $options = $this->prophesize(ContentNegotiationOptions::class); + $options->getHttpOverrideMethods()->willReturn([]); + + /** @var ServiceManager|ObjectProphecy $container */ + $container = $this->prophesize(ServiceManager::class); + $container->willImplement(ServiceLocatorInterface::class); + $container->get(ContentNegotiationOptions::class)->willReturn($options); + + $factory = new HttpMethodOverrideListenerFactory(); + $service = $factory($container->reveal(), HttpMethodOverrideListener::class); + + $this->assertInstanceOf(HttpMethodOverrideListener::class, $service); + } +} diff --git a/test/HttpMethodOverrideListenerTest.php b/test/HttpMethodOverrideListenerTest.php new file mode 100644 index 0000000..653385a --- /dev/null +++ b/test/HttpMethodOverrideListenerTest.php @@ -0,0 +1,127 @@ +<?php +/** + * @license http://opensource.org/licenses/BSD-3-Clause BSD-3-Clause + * @copyright Copyright (c) 2016 Zend Technologies USA Inc. (http://www.zend.com) + */ + +namespace ZFTest\ContentNegotiation; + +use PHPUnit_Framework_TestCase as TestCase; +use Zend\Http\Request as HttpRequest; +use Zend\Mvc\MvcEvent; +use ZF\ApiProblem\ApiProblemResponse; +use ZF\ContentNegotiation\HttpMethodOverrideListener; + +class HttpMethodOverrideListenerTest extends TestCase +{ + use RouteMatchFactoryTrait; + + /** + * @var HttpMethodOverrideListener + */ + protected $listener; + + /** + * @var array + */ + protected $httpMethodOverride = [ + HttpRequest::METHOD_GET => [ + HttpRequest::METHOD_HEAD, + HttpRequest::METHOD_POST, + HttpRequest::METHOD_PUT, + HttpRequest::METHOD_DELETE, + HttpRequest::METHOD_PATCH, + ], + HttpRequest::METHOD_POST => [ + ], + ]; + + /** + * Set up test + */ + public function setUp() + { + $this->listener = new HttpMethodOverrideListener($this->httpMethodOverride); + } + + /** + * @return array + */ + public function httpMethods() + { + return [ + 'head' => [HttpRequest::METHOD_HEAD], + 'post' => [HttpRequest::METHOD_POST], + 'put' => [HttpRequest::METHOD_PUT], + 'delete' => [HttpRequest::METHOD_DELETE], + 'patch' => [HttpRequest::METHOD_PATCH], + ]; + } + + /** + * @dataProvider httpMethods + */ + public function testHttpMethodOverrideListener($method) + { + $listener = $this->listener; + + $request = new HttpRequest(); + $request->setMethod('GET'); + $request->getHeaders()->addHeaderLine('X-HTTP-Method-Override', $method); + + $event = new MvcEvent(); + $event->setRequest($request); + $event->setRouteMatch($this->createRouteMatch([])); + + $result = $listener->onRoute($event); + $this->assertEquals($method, $request->getMethod()); + } + + /** + * @dataProvider httpMethods + */ + public function testHttpMethodOverrideListenerReturnsProblemResponseForMethodNotInConfig($method) + { + $listener = $this->listener; + + $request = new HttpRequest(); + $request->setMethod('PATCH'); + $request->getHeaders()->addHeaderLine('X-HTTP-Method-Override', $method); + + $event = new MvcEvent(); + $event->setRequest($request); + + $result = $listener->onRoute($event); + $this->assertInstanceOf(ApiProblemResponse::class, $result); + $problem = $result->getApiProblem(); + $this->assertEquals(400, $problem->status); + $this->assertContains( + 'Overriding PATCH method with X-HTTP-Method-Override header is not allowed', + $problem->detail + ); + } + + /** + * @dataProvider httpMethods + */ + public function testHttpMethodOverrideListenerReturnsProblemResponseForIllegalOverrideValue($method) + { + $listener = $this->listener; + + $request = new HttpRequest(); + $request->setMethod('POST'); + $request->getHeaders()->addHeaderLine('X-HTTP-Method-Override', $method); + + $event = new MvcEvent(); + $event->setRequest($request); + + $result = $listener->onRoute($event); + $this->assertInstanceOf(ApiProblemResponse::class, $result); + $problem = $result->getApiProblem(); + $this->assertEquals(400, $problem->status); + $this->assertContains( + sprintf('Illegal override method %s in X-HTTP-Method-Override header', $method), + $problem->detail + ); + } +}