core/core.services.yml | 4 + .../EventSubscriber/ExceptionJsonSubscriber.php | 11 ++ core/lib/Drupal/Core/Routing/MethodFilter.php | 55 +++++++++ core/modules/rest/src/Tests/RESTTestBase.php | 4 + core/modules/rest/src/Tests/UpdateTest.php | 7 ++ .../Core/Routing/ExceptionHandlingTest.php | 13 +++ .../Drupal/Tests/Core/Routing/MethodFilterTest.php | 127 +++++++++++++++++++++ 7 files changed, 221 insertions(+) diff --git a/core/core.services.yml b/core/core.services.yml index 2f7da56..e6285cb 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -902,6 +902,10 @@ services: class: Drupal\Core\Routing\RequestFormatRouteFilter tags: - { name: route_filter } + method_filter: + class: Drupal\Core\Routing\MethodFilter + tags: + - { name: route_filter, priority: 1 } content_type_header_matcher: class: Drupal\Core\Routing\ContentTypeHeaderMatcher tags: diff --git a/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php index 01f3620..0824c7e 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ExceptionJsonSubscriber.php @@ -71,4 +71,15 @@ public function on406(GetResponseForExceptionEvent $event) { $event->setResponse($response); } + /** + * Handles a 415 error for JSON. + * + * @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $event + * The event to process. + */ + public function on415(GetResponseForExceptionEvent $event) { + $response = new JsonResponse(['message' => $event->getException()->getMessage()], Response::HTTP_UNSUPPORTED_MEDIA_TYPE); + $event->setResponse($response); + } + } diff --git a/core/lib/Drupal/Core/Routing/MethodFilter.php b/core/lib/Drupal/Core/Routing/MethodFilter.php new file mode 100644 index 0000000..77c9fa5 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/MethodFilter.php @@ -0,0 +1,55 @@ +getMethod(); + + $all_supported_methods = []; + + foreach ($collection->all() as $name => $route) { + $supported_methods = $route->getMethods(); + // If the GET method is allowed we also need to allow the HEAD method + // since HEAD is a GET method that doesn't return the body. + if (in_array('GET', $supported_methods)) { + $supported_methods[] = 'HEAD'; + } + + // A route not restricted to specific methods allows any method. If this + // is the case, we'll also have at least one route left in the collection, + // hence we don't need to calculate the set of all supported methods. + if (empty($supported_methods)) { + continue; + } + elseif (!in_array($method, $supported_methods)) { + $all_supported_methods = array_merge($supported_methods, $all_supported_methods); + $collection->remove($name); + } + } + if (count($collection)) { + return $collection; + } + throw new MethodNotAllowedException(array_unique($all_supported_methods)); + } + + /** + * {@inheritdoc} + */ + public function applies(Route $route) { + return !empty($route->getMethods()); + } + +} diff --git a/core/modules/rest/src/Tests/RESTTestBase.php b/core/modules/rest/src/Tests/RESTTestBase.php index 4779b6b..bd3dfac 100644 --- a/core/modules/rest/src/Tests/RESTTestBase.php +++ b/core/modules/rest/src/Tests/RESTTestBase.php @@ -176,6 +176,10 @@ protected function httpRequest($url, $method, $body = NULL, $mime_type = NULL) { break; } + if ($mime_type === 'none') { + unset($curl_options[CURLOPT_HTTPHEADER]['Content-Type']); + } + $this->responseBody = $this->curlExec($curl_options); // Ensure that any changes to variables in the other thread are picked up. diff --git a/core/modules/rest/src/Tests/UpdateTest.php b/core/modules/rest/src/Tests/UpdateTest.php index d503386..b8f1b54 100644 --- a/core/modules/rest/src/Tests/UpdateTest.php +++ b/core/modules/rest/src/Tests/UpdateTest.php @@ -8,6 +8,7 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Session\AccountInterface; use Drupal\entity_test\Entity\EntityTest; +use Symfony\Component\HttpFoundation\Response; /** * Tests the update of resources. @@ -67,6 +68,12 @@ public function testPatchUpdate() { $patch_entity->set('uuid', NULL); $serialized = $serializer->serialize($patch_entity, $this->defaultFormat, $context); + // Update the entity over the REST API but forget to specify a Content-Type + // header, this should throw the proper exception. + $this->httpRequest($entity->toUrl(), 'PATCH', $serialized, 'none'); + $this->assertResponse(Response::HTTP_UNSUPPORTED_MEDIA_TYPE); + $this->assertRaw('No route found that matches "Content-Type: none"'); + // Update the entity over the REST API. $response = $this->httpRequest($entity->urlInfo(), 'PATCH', $serialized, $this->defaultMimeType); $this->assertResponse(200); diff --git a/core/tests/Drupal/KernelTests/Core/Routing/ExceptionHandlingTest.php b/core/tests/Drupal/KernelTests/Core/Routing/ExceptionHandlingTest.php index ab9cd92..7d11b81 100644 --- a/core/tests/Drupal/KernelTests/Core/Routing/ExceptionHandlingTest.php +++ b/core/tests/Drupal/KernelTests/Core/Routing/ExceptionHandlingTest.php @@ -28,6 +28,19 @@ protected function setUp() { $this->installEntitySchema('date_format'); } + /** + * Tests on a route with a non-supported HTTP method. + */ + public function test405() { + $request = Request::create('/router_test/test15', 'PATCH'); + + /** @var \Symfony\Component\HttpKernel\HttpKernelInterface $kernel */ + $kernel = \Drupal::getContainer()->get('http_kernel'); + $response = $kernel->handle($request); + + $this->assertEqual(Response::HTTP_METHOD_NOT_ALLOWED, $response->getStatusCode()); + } + /** * Tests the exception handling for json and 403 status code. */ diff --git a/core/tests/Drupal/Tests/Core/Routing/MethodFilterTest.php b/core/tests/Drupal/Tests/Core/Routing/MethodFilterTest.php new file mode 100644 index 0000000..1b72b75 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Routing/MethodFilterTest.php @@ -0,0 +1,127 @@ +assertSame($expected_applies, $method_filter->applies($route)); + } + + /** + * Data provider for testApplies(). + * + * @return array + */ + public function providerApplies() { + return [ + 'only GET' => [['GET'], TRUE], + 'only PATCH' => [['PATCH'], TRUE], + 'only POST' => [['POST'], TRUE], + 'only DELETE' => [['DELETE'], TRUE], + 'only HEAD' => [['HEAD'], TRUE], + 'all' => [['GET', 'PATCH', 'POST', 'DELETE', 'HEAD'], TRUE], + 'none' => [[], FALSE], + ]; + } + + /** + * @covers ::filter + */ + public function testWithAllowedMethod() { + $request = Request::create('/test', 'GET'); + $collection = new RouteCollection(); + $collection->add('test_route.get', new Route('/test', [], [], [], '', [], ['GET'])); + $collection_before = clone $collection; + + $method_filter = new MethodFilter(); + $result_collection = $method_filter->filter($collection, $request); + + $this->assertEquals($collection_before, $result_collection); + } + + /** + * @covers ::filter + */ + public function testWithAllowedMethodAndMultipleMatchingRoutes() { + $request = Request::create('/test', 'GET'); + $collection = new RouteCollection(); + $collection->add('test_route.get', new Route('/test', [], [], [], '', [], ['GET'])); + $collection->add('test_route2.get', new Route('/test', [], [], [], '', [], ['GET'])); + $collection->add('test_route3.get', new Route('/test', [], [], [], '', [], ['GET'])); + + $collection_before = clone $collection; + + $method_filter = new MethodFilter(); + $result_collection = $method_filter->filter($collection, $request); + + $this->assertEquals($collection_before, $result_collection); + } + + /** + * @covers ::filter + */ + public function testMethodNotAllowedException() { + $request = Request::create('/test', 'PATCH'); + $collection = new RouteCollection(); + $collection->add('test_route.get', new Route('/test', [], [], [], '', [], ['GET'])); + + $this->setExpectedException(MethodNotAllowedException::class); + + $method_filter = new MethodFilter(); + $method_filter->filter($collection, $request); + } + + /** + * @covers ::filter + */ + public function testMethodNotAllowedExceptionWithMultipleRoutes() { + $request = Request::create('/test', 'PATCH'); + $collection = new RouteCollection(); + $collection->add('test_route.get', new Route('/test', [], [], [], '', [], ['GET'])); + $collection->add('test_route2.get', new Route('/test', [], [], [], '', [], ['GET'])); + $collection->add('test_route3.get', new Route('/test', [], [], [], '', [], ['GET'])); + + $this->setExpectedException(MethodNotAllowedException::class); + + $method_filter = new MethodFilter(); + $method_filter->filter($collection, $request); + } + + /** + * @covers ::filter + */ + public function testFilteredMethods() { + $request = Request::create('/test', 'PATCH'); + $collection = new RouteCollection(); + $collection->add('test_route.get', new Route('/test', [], [], [], '', [], ['GET'])); + $collection->add('test_route2.get', new Route('/test', [], [], [], '', [], ['PATCH'])); + $collection->add('test_route3.get', new Route('/test', [], [], [], '', [], ['POST'])); + + $expected_collection = new RouteCollection(); + $expected_collection->add('test_route2.get', new Route('/test', [], [], [], '', [], ['PATCH'])); + + $method_filter = new MethodFilter(); + $result_collection = $method_filter->filter($collection, $request); + + $this->assertEquals($expected_collection, $result_collection); + } + +}