diff --git a/core/core.services.yml b/core/core.services.yml index b850c9e..8530eb6 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -337,8 +337,13 @@ services: password: class: Drupal\Core\Password\PhpassHashedPassword arguments: [16] - mime_type_matcher: - class: Drupal\Core\Routing\MimeTypeMatcher + accept_header_matcher: + class: Drupal\Core\Routing\AcceptHeaderMatcher + arguments: ['@content_negotiation'] + tags: + - { name: route_filter } + content_type_header_matcher: + class: Drupal\Core\Routing\ContentTypeHeaderMatcher tags: - { name: route_filter } paramconverter_manager: @@ -412,6 +417,10 @@ services: class: Drupal\Core\EventSubscriber\SpecialAttributesRouteSubscriber tags: - { name: event_subscriber } + route_http_method_subscriber: + class: Drupal\Core\EventSubscriber\RouteMethodSubscriber + tags: + - { name: event_subscriber } controller.page: class: Drupal\Core\Controller\HtmlPageController arguments: ['@controller_resolver', '@string_translation', '@title_resolver'] diff --git a/core/lib/Drupal/Core/Controller/ExceptionController.php b/core/lib/Drupal/Core/Controller/ExceptionController.php index df5eb07..915d819 100644 --- a/core/lib/Drupal/Core/Controller/ExceptionController.php +++ b/core/lib/Drupal/Core/Controller/ExceptionController.php @@ -105,7 +105,7 @@ public function execute(FlattenException $exception, Request $request) { return $this->$method($exception, $request); } - return new Response('A fatal error occurred: ' . $exception->getMessage(), $exception->getStatusCode(), $exception->getHeaders()); + return new Response('An error occurred: ' . $exception->getMessage(), $exception->getStatusCode(), $exception->getHeaders()); } /** diff --git a/core/lib/Drupal/Core/EventSubscriber/RouteMethodSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/RouteMethodSubscriber.php new file mode 100644 index 0000000..19b5488 --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/RouteMethodSubscriber.php @@ -0,0 +1,48 @@ +getRouteCollection() as $route) { + $methods = $route->getMethods(); + if (empty($methods)) { + $route->setMethods(array('GET', 'POST')); + } + } + } + + /** + * {@inheritdoc} + */ + static function getSubscribedEvents() { + $events[RoutingEvents::ALTER][] = 'onRouteBuilding'; + return $events; + } + +} diff --git a/core/lib/Drupal/Core/Routing/AcceptHeaderMatcher.php b/core/lib/Drupal/Core/Routing/AcceptHeaderMatcher.php new file mode 100644 index 0000000..34f0ae5 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/AcceptHeaderMatcher.php @@ -0,0 +1,85 @@ +contentNegotiation = $content_negotiation; + } + + /** + * {@inheritdoc} + */ + public function filter(RouteCollection $collection, Request $request) { + // Generates a list of Symfony formats matching the acceptable MIME types. + // @todo replace by proper content negotiation library. + $acceptable_mime_types = $request->getAcceptableContentTypes(); + $acceptable_formats = array_filter(array_map(array($request, 'getFormat'), $acceptable_mime_types)); + $primary_format = $this->contentNegotiation->getContentType($request); + + foreach ($collection as $name => $route) { + // _format could be a |-delimited list of supported formats. + $supported_formats = array_filter(explode('|', $route->getRequirement('_format'))); + + if (empty($supported_formats)) { + // No format restriction on the route, so it always matches. Move it to + // the end of the collection by re-adding it. + $collection->add($name, $route); + } + elseif (in_array($primary_format, $supported_formats)) { + // Perfect match, which will get a higher priority by leaving the route + // on top of the list. + } + // The route partially matches if it doesn't care about format, if it + // explicitly allows any format, or if one of its allowed formats is + // in the request's list of acceptable formats. + elseif (in_array('*/*', $acceptable_mime_types) || array_intersect($acceptable_formats, $supported_formats)) { + // Move it to the end of the list. + $collection->add($name, $route); + } + else { + // Remove the route if it does not match at all. + $collection->remove($name); + } + } + + if (count($collection)) { + return $collection; + } + + // We do not throw a + // \Symfony\Component\Routing\Exception\ResourceNotFoundException here + // because we don't want to return a 404 status code, but rather a 406. + throw new NotAcceptableHttpException(String::format('No route found for the specified formats @formats.', array('@formats' => implode(' ', $acceptable_mime_types)))); + } + +} diff --git a/core/lib/Drupal/Core/Routing/ContentTypeHeaderMatcher.php b/core/lib/Drupal/Core/Routing/ContentTypeHeaderMatcher.php new file mode 100644 index 0000000..eb2751d --- /dev/null +++ b/core/lib/Drupal/Core/Routing/ContentTypeHeaderMatcher.php @@ -0,0 +1,53 @@ +getMethod() == 'GET') { + return $collection; + } + + $format = $request->getContentType(); + + foreach ($collection as $name => $route) { + $supported_formats = array_filter(explode('|', $route->getRequirement('_content_type_format'))); + if (empty($supported_formats)) { + // No restriction on the route, so we move the route to the end of the + // collection by re-adding it. That way generic routes sink down in the + // list and exact matching routes stay on top. + $collection->add($name, $route); + } + elseif (!in_array($format, $supported_formats)) { + $collection->remove($name); + } + } + if (count($collection)) { + return $collection; + } + // We do not throw a + // \Symfony\Component\Routing\Exception\ResourceNotFoundException here + // because we don't want to return a 404 status code, but rather a 415. + throw new UnsupportedMediaTypeHttpException('No route found that matches the Content-Type header.'); + } + +} diff --git a/core/lib/Drupal/Core/Routing/MimeTypeMatcher.php b/core/lib/Drupal/Core/Routing/MimeTypeMatcher.php deleted file mode 100644 index f77e254..0000000 --- a/core/lib/Drupal/Core/Routing/MimeTypeMatcher.php +++ /dev/null @@ -1,50 +0,0 @@ -getAcceptableContentTypes(); - $acceptable_formats = array_map(array($request, 'getFormat'), $acceptable_mime_types); - - $filtered_collection = new RouteCollection(); - - foreach ($collection as $name => $route) { - // _format could be a |-delimited list of supported formats. - $supported_formats = array_filter(explode('|', $route->getRequirement('_format'))); - // The route partially matches if it doesn't care about format, if it - // explicitly allows any format, or if one of its allowed formats is - // in the request's list of acceptable formats. - if (empty($supported_formats) || in_array('*/*', $acceptable_mime_types) || array_intersect($acceptable_formats, $supported_formats)) { - $filtered_collection->add($name, $route); - } - } - - if (!count($filtered_collection)) { - throw new NotAcceptableHttpException(); - } - - return $filtered_collection; - } - -} diff --git a/core/modules/rest/lib/Drupal/rest/Plugin/Derivative/EntityDerivative.php b/core/modules/rest/lib/Drupal/rest/Plugin/Derivative/EntityDerivative.php index 3723287..0ac457b 100644 --- a/core/modules/rest/lib/Drupal/rest/Plugin/Derivative/EntityDerivative.php +++ b/core/modules/rest/lib/Drupal/rest/Plugin/Derivative/EntityDerivative.php @@ -9,6 +9,7 @@ use Drupal\Core\Entity\EntityManagerInterface; use Drupal\Core\Plugin\Discovery\ContainerDerivativeInterface; +use Drupal\Core\Routing\RouteProviderInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -31,13 +32,23 @@ class EntityDerivative implements ContainerDerivativeInterface { protected $entityManager; /** + * The route provider. + * + * @var \Drupal\Core\Routing\RouteProviderInterface + */ + protected $routeProvider; + + /** * Constructs an EntityDerivative object. * * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager * The entity manager. + * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider + * The route provider. */ - public function __construct(EntityManagerInterface $entity_manager) { + public function __construct(EntityManagerInterface $entity_manager, RouteProviderInterface $route_provider) { $this->entityManager = $entity_manager; + $this->routeProvider = $route_provider; } /** @@ -45,7 +56,8 @@ public function __construct(EntityManagerInterface $entity_manager) { */ public static function create(ContainerInterface $container, $base_plugin_id) { return new static( - $container->get('entity.manager') + $container->get('entity.manager'), + $container->get('router.route_provider') ); } @@ -74,6 +86,24 @@ public function getDerivativeDefinitions(array $base_plugin_definition) { 'serialization_class' => $entity_info->getClass(), 'label' => $entity_info->getLabel(), ); + // Use the paths of entity link templates as REST URL patterns if + // available. + if ($canonical_route_name = $entity_info->getLinkTemplate('canonical')) { + $route = $this->routeProvider->getRouteByName($canonical_route_name); + $this->derivatives[$entity_type]['uri_paths']['canonical'] = $route->getPath(); + } + else { + $this->derivatives[$entity_type]['uri_paths']['canonical'] = "/entity/$entity_type/" . '{' . $entity_type . '}'; + } + + if ($create_route_name = $entity_info->getLinkTemplate('http://drupal.org/link-relations/create')) { + $route = $this->routeProvider->getRouteByName($create_route_name); + $this->derivatives[$entity_type]['uri_paths']['http://drupal.org/link-relations/create'] = $route->getPath(); + } + else { + $this->derivatives[$entity_type]['uri_paths']['http://drupal.org/link-relations/create'] = "/entity/$entity_type"; + } + $this->derivatives[$entity_type] += $base_plugin_definition; } } diff --git a/core/modules/rest/lib/Drupal/rest/Plugin/ResourceBase.php b/core/modules/rest/lib/Drupal/rest/Plugin/ResourceBase.php index 9100f45..6d59e54 100644 --- a/core/modules/rest/lib/Drupal/rest/Plugin/ResourceBase.php +++ b/core/modules/rest/lib/Drupal/rest/Plugin/ResourceBase.php @@ -78,13 +78,17 @@ public function permissions() { */ public function routes() { $collection = new RouteCollection(); - $path_prefix = strtr($this->pluginId, ':', '/'); + + $definition = $this->getPluginDefinition(); + $canonical_path = isset($definition['uri_paths']['canonical']) ? $definition['uri_paths']['canonical'] : '/' . strtr($this->pluginId, ':', '/') . '/{id}'; + $create_path = isset($definition['uri_paths']['http://drupal.org/link-relations/create']) ? $definition['uri_paths']['http://drupal.org/link-relations/create'] : '/' . strtr($this->pluginId, ':', '/'); + $route_name = strtr($this->pluginId, ':', '.'); $methods = $this->availableMethods(); foreach ($methods as $method) { $lower_method = strtolower($method); - $route = new Route("/$path_prefix/{id}", array( + $route = new Route($canonical_path, array( '_controller' => 'Drupal\rest\RequestHandler::handle', // Pass the resource plugin ID along as default property. '_plugin' => $this->pluginId, @@ -98,9 +102,17 @@ public function routes() { switch ($method) { case 'POST': - // POST routes do not require an ID in the URL path. - $route->setPattern("/$path_prefix"); - $route->addDefaults(array('id' => NULL)); + $route->setPattern($create_path); + // Restrict the incoming HTTP Content-type header to the known + // serialization formats. + $route->addRequirements(array('_content_type_format' => implode('|', $this->serializerFormats))); + $collection->add("$route_name.$method", $route); + break; + + case 'PATCH': + // Restrict the incoming HTTP Content-type header to the known + // serialization formats. + $route->addRequirements(array('_content_type_format' => implode('|', $this->serializerFormats))); $collection->add("$route_name.$method", $route); break; @@ -110,7 +122,6 @@ public function routes() { // HTTP Accept headers. foreach ($this->serializerFormats as $format_name) { // Expose one route per available format. - //$format_route = new Route($route->getPath(), $route->getDefaults(), $route->getRequirements()); $format_route = clone $route; $format_route->addRequirements(array('_format' => $format_name)); $collection->add("$route_name.$method.$format_name", $format_route); diff --git a/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/DBLogResource.php b/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/DBLogResource.php index 8b7cb99..c8df978 100644 --- a/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/DBLogResource.php +++ b/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/DBLogResource.php @@ -17,7 +17,10 @@ * * @Plugin( * id = "dblog", - * label = @Translation("Watchdog database log") + * label = @Translation("Watchdog database log"), + * uri_paths = { + * "canonical" = "/dblog/{id}" + * } * ) */ class DBLogResource extends ResourceBase { diff --git a/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/EntityResource.php b/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/EntityResource.php index 53281e1..eac78b3 100644 --- a/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/EntityResource.php +++ b/core/modules/rest/lib/Drupal/rest/Plugin/rest/resource/EntityResource.php @@ -14,7 +14,6 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Represents entities as resources. @@ -23,7 +22,11 @@ * id = "entity", * label = @Translation("Entity"), * serialization_class = "Drupal\Core\Entity\Entity", - * derivative = "Drupal\rest\Plugin\Derivative\EntityDerivative" + * derivative = "Drupal\rest\Plugin\Derivative\EntityDerivative", + * uri_paths = { + * "canonical" = "/entity/{entity_type}/{entity}", + * "http://drupal.org/link-relations/create" = "/entity/{entity_type}" + * } * ) */ class EntityResource extends ResourceBase { @@ -31,36 +34,29 @@ class EntityResource extends ResourceBase { /** * Responds to entity GET requests. * - * @param mixed $id - * The entity ID. + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity object. * * @return \Drupal\rest\ResourceResponse - * The response containing the loaded entity. + * The response containing the entity with its accessible fields. * * @throws \Symfony\Component\HttpKernel\Exception\HttpException */ - public function get($id) { - $definition = $this->getPluginDefinition(); - $entity = entity_load($definition['entity_type'], $id); - if ($entity) { - if (!$entity->access('view')) { - throw new AccessDeniedHttpException(); - } - foreach ($entity as $field_name => $field) { - if (!$field->access('view')) { - unset($entity->{$field_name}); - } + public function get(EntityInterface $entity) { + if (!$entity->access('view')) { + throw new AccessDeniedHttpException(); + } + foreach ($entity as $field_name => $field) { + if (!$field->access('view')) { + unset($entity->{$field_name}); } - return new ResourceResponse($entity); } - throw new NotFoundHttpException(t('Entity with ID @id not found', array('@id' => $id))); + return new ResourceResponse($entity); } /** * Responds to entity POST requests and saves the new entity. * - * @param mixed $id - * Ignored. A new entity is created with a new ID. * @param \Drupal\Core\Entity\EntityInterface $entity * The entity. * @@ -69,7 +65,7 @@ public function get($id) { * * @throws \Symfony\Component\HttpKernel\Exception\HttpException */ - public function post($id, EntityInterface $entity = NULL) { + public function post(EntityInterface $entity = NULL) { if ($entity == NULL) { throw new BadRequestHttpException(t('No entity content received.')); } @@ -112,8 +108,8 @@ public function post($id, EntityInterface $entity = NULL) { /** * Responds to entity PATCH requests. * - * @param mixed $id - * The entity ID. + * @param \Drupal\Core\Entity\EntityInterface $original_entity + * The original entity object. * @param \Drupal\Core\Entity\EntityInterface $entity * The entity. * @@ -122,24 +118,14 @@ public function post($id, EntityInterface $entity = NULL) { * * @throws \Symfony\Component\HttpKernel\Exception\HttpException */ - public function patch($id, EntityInterface $entity = NULL) { + public function patch(EntityInterface $original_entity, EntityInterface $entity = NULL) { if ($entity == NULL) { throw new BadRequestHttpException(t('No entity content received.')); } - - if (empty($id)) { - throw new NotFoundHttpException(); - } $definition = $this->getPluginDefinition(); if ($entity->getEntityTypeId() != $definition['entity_type']) { throw new BadRequestHttpException(t('Invalid entity type')); } - $original_entity = entity_load($definition['entity_type'], $id); - // We don't support creating entities with PATCH, so we throw an error if - // there is no existing entity. - if ($original_entity == FALSE) { - throw new NotFoundHttpException(); - } if (!$original_entity->access('update')) { throw new AccessDeniedHttpException(); } @@ -174,33 +160,28 @@ public function patch($id, EntityInterface $entity = NULL) { /** * Responds to entity DELETE requests. * - * @param mixed $id - * The entity ID. + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity object. * * @return \Drupal\rest\ResourceResponse * The HTTP response object. * * @throws \Symfony\Component\HttpKernel\Exception\HttpException */ - public function delete($id) { - $definition = $this->getPluginDefinition(); - $entity = entity_load($definition['entity_type'], $id); - if ($entity) { - if (!$entity->access('delete')) { - throw new AccessDeniedHttpException(); - } - try { - $entity->delete(); - watchdog('rest', 'Deleted entity %type with ID %id.', array('%type' => $entity->getEntityTypeId(), '%id' => $entity->id())); + public function delete(EntityInterface $entity) { + if (!$entity->access('delete')) { + throw new AccessDeniedHttpException(); + } + try { + $entity->delete(); + watchdog('rest', 'Deleted entity %type with ID %id.', array('%type' => $entity->getEntityTypeId(), '%id' => $entity->id())); - // Delete responses have an empty body. - return new ResourceResponse(NULL, 204); - } - catch (EntityStorageException $e) { - throw new HttpException(500, t('Internal Server Error'), $e); - } + // Delete responses have an empty body. + return new ResourceResponse(NULL, 204); + } + catch (EntityStorageException $e) { + throw new HttpException(500, t('Internal Server Error'), $e); } - throw new NotFoundHttpException(t('Entity with ID @id not found', array('@id' => $id))); } /** diff --git a/core/modules/rest/lib/Drupal/rest/RequestHandler.php b/core/modules/rest/lib/Drupal/rest/RequestHandler.php index 605a1f8..120ccfa 100644 --- a/core/modules/rest/lib/Drupal/rest/RequestHandler.php +++ b/core/modules/rest/lib/Drupal/rest/RequestHandler.php @@ -25,13 +25,12 @@ class RequestHandler extends ContainerAware { * * @param Symfony\Component\HttpFoundation\Request $request * The HTTP request object. - * @param mixed $id - * The resource ID. * * @return \Symfony\Component\HttpFoundation\Response * The response object. */ - public function handle(Request $request, $id = NULL) { + public function handle(Request $request) { + $plugin = $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT)->getDefault('_plugin'); $method = strtolower($request->getMethod()); @@ -69,13 +68,24 @@ public function handle(Request $request, $id = NULL) { } } + // Determine the request parameters that should be passed to the resource + // plugin. + $route_parameters = $request->attributes->get('_route_params'); + $parameters = array(); + // Filter out all internal parameters starting with "_". + foreach ($route_parameters as $key => $parameter) { + if ($key{0} !== '_') { + $parameters[] = $parameter; + } + } + // Invoke the operation on the resource plugin. // All REST routes are restricted to exactly one format, so instead of // parsing it out of the Accept headers again, we can simply retrieve the // format requirement. If there is no format associated, just pick JSON. $format = $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT)->getRequirement('_format') ?: 'json'; try { - $response = $resource->{$method}($id, $unserialized, $request); + $response = call_user_func_array(array($resource, $method), array_merge($parameters, array($unserialized, $request))); } catch (HttpException $e) { $error['error'] = $e->getMessage(); diff --git a/core/modules/rest/lib/Drupal/rest/Tests/AuthTest.php b/core/modules/rest/lib/Drupal/rest/Tests/AuthTest.php index 41d4d48..6ab85bf 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/AuthTest.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/AuthTest.php @@ -46,9 +46,9 @@ public function testRead() { $entity->save(); // Try to read the resource as an anonymous user, which should not work. - $response = $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'GET', NULL, $this->defaultMimeType); + $this->httpRequest($entity_type . '/' . $entity->id(), 'GET', NULL, $this->defaultMimeType); $this->assertResponse('401', 'HTTP response code is 401 when the request is not authenticated and the user is anonymous.'); - $this->assertText('A fatal error occurred: No authentication credentials provided.'); + $this->assertText('An error occurred: No authentication credentials provided.'); // Ensure that cURL settings/headers aren't carried over to next request. unset($this->curlHandle); @@ -63,7 +63,7 @@ public function testRead() { // Try to read the resource with session cookie authentication, which is // not enabled and should not work. - $response = $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'GET', NULL, $this->defaultMimeType); + $this->httpRequest($entity_type . '/' . $entity->id(), 'GET', NULL, $this->defaultMimeType); $this->assertResponse('401', 'HTTP response code is 401 when the request is authenticated but not authorized.'); // Ensure that cURL settings/headers aren't carried over to next request. @@ -71,7 +71,7 @@ public function testRead() { // Now read it with the Basic authentication which is enabled and should // work. - $response = $this->basicAuthGet('entity/' . $entity_type . '/' . $entity->id(), $account->getUsername(), $account->pass_raw); + $this->basicAuthGet($entity_type . '/' . $entity->id(), $account->getUsername(), $account->pass_raw); $this->assertResponse('200', 'HTTP response code is 200 for successfully authorized requests.'); $this->curlClose(); } diff --git a/core/modules/rest/lib/Drupal/rest/Tests/DeleteTest.php b/core/modules/rest/lib/Drupal/rest/Tests/DeleteTest.php index 10695c4..95b1dfd 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/DeleteTest.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/DeleteTest.php @@ -50,7 +50,7 @@ public function testDelete() { $entity = $this->entityCreate($entity_type); $entity->save(); // Delete it over the REST API. - $response = $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'DELETE'); + $response = $this->httpRequest($this->entityBasePath($entity_type) . '/' . $entity->id(), 'DELETE'); // Clear the static cache with entity_load(), otherwise we won't see the // update. $entity = entity_load($entity_type, $entity->id(), TRUE); @@ -59,17 +59,16 @@ public function testDelete() { $this->assertEqual($response, '', 'Response body is empty.'); // Try to delete an entity that does not exist. - $response = $this->httpRequest('entity/' . $entity_type . '/9999', 'DELETE'); + $response = $this->httpRequest($this->entityBasePath($entity_type) . '/9999', 'DELETE'); $this->assertResponse(404); - $decoded = drupal_json_decode($response); - $this->assertEqual($decoded['error'], 'Entity with ID 9999 not found', 'Response message is correct.'); + $this->assertText('The requested page "/' . $this->entityBasePath($entity_type) . '/9999" could not be found.'); // Try to delete an entity without proper permissions. $this->drupalLogout(); // Re-save entity to the database. $entity = $this->entityCreate($entity_type); $entity->save(); - $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'DELETE'); + $this->httpRequest($this->entityBasePath($entity_type) . '/' . $entity->id(), 'DELETE'); $this->assertResponse(403); $this->assertNotIdentical(FALSE, entity_load($entity_type, $entity->id(), TRUE), 'The ' . $entity_type . ' entity is still in the database.'); } diff --git a/core/modules/rest/lib/Drupal/rest/Tests/NodeTest.php b/core/modules/rest/lib/Drupal/rest/Tests/NodeTest.php index 86df206..e199eb4 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/NodeTest.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/NodeTest.php @@ -55,8 +55,16 @@ public function testNodes() { $node = $this->entityCreate('node'); $node->save(); - $this->httpRequest('entity/node/' . $node->id(), 'GET', NULL, $this->defaultMimeType); + $this->httpRequest('node/' . $node->id(), 'GET', NULL, $this->defaultMimeType); $this->assertResponse(200); + $this->assertHeader('Content-type', $this->defaultMimeType); + + // Also check that JSON works and the routing system selects the correct + // REST route. + $this->enableService('entity:node', 'GET', 'json'); + $this->httpRequest('node/' . $node->id(), 'GET', NULL, 'application/json'); + $this->assertResponse(200); + $this->assertHeader('Content-type', 'application/json'); // Check that a simple PATCH update to the node title works as expected. $this->enableNodeConfiguration('PATCH', 'update'); @@ -76,7 +84,7 @@ public function testNodes() { ), ); $serialized = $this->container->get('serializer')->serialize($data, $this->defaultFormat); - $this->httpRequest('entity/node/' . $node->id(), 'PATCH', $serialized, $this->defaultMimeType); + $this->httpRequest('node/' . $node->id(), 'PATCH', $serialized, $this->defaultMimeType); $this->assertResponse(204); // Reload the node from the DB and check if the title was correctly updated. diff --git a/core/modules/rest/lib/Drupal/rest/Tests/RESTTestBase.php b/core/modules/rest/lib/Drupal/rest/Tests/RESTTestBase.php index 021e1f9..89028b9ef 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/RESTTestBase.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/RESTTestBase.php @@ -269,16 +269,22 @@ protected function assertHeader($header, $value, $message = '', $group = 'Browse } /** - * Overrides WebTestBase::drupalLogin(). + * {@inheritdoc} + * + * This method is overridden to deal with a cURL quirk: the usage of + * CURLOPT_CUSTOMREQUEST cannot be unset on the cURL handle, so we need to + * override it every time it is omitted. */ - protected function drupalLogin(AccountInterface $user) { - if (isset($this->curlHandle)) { - // cURL quirk: when setting CURLOPT_CUSTOMREQUEST to anything other than - // POST in httpRequest() it has to be restored to POST here. Otherwise the - // POST request to login a user will not work. - curl_setopt($this->curlHandle, CURLOPT_CUSTOMREQUEST, 'POST'); + protected function curlExec($curl_options, $redirect = FALSE) { + if (!isset($curl_options[CURLOPT_CUSTOMREQUEST])) { + if (!empty($curl_options[CURLOPT_HTTPGET])) { + $curl_options[CURLOPT_CUSTOMREQUEST] = 'GET'; + } + if (!empty($curl_options[CURLOPT_POST])) { + $curl_options[CURLOPT_CUSTOMREQUEST] = 'POST'; + } } - parent::drupalLogin($user); + return parent::curlExec($curl_options, $redirect); } /** @@ -331,4 +337,22 @@ protected function loadEntityFromLocationHeader($location_url) { $id = end($url_parts); return entity_load($this->testEntityType, $id); } + + /** + * Returns the base URI path of an entity type. + * + * @param string $entity_type + * The entity type. + * + * @return string + * The URI path. + */ + protected function entityBasePath($entity_type) { + switch ($entity_type) { + case 'entity_test': + return 'entity_test'; + case 'node': + return 'node'; + } + } } diff --git a/core/modules/rest/lib/Drupal/rest/Tests/ReadTest.php b/core/modules/rest/lib/Drupal/rest/Tests/ReadTest.php index 8160f74..066ed2a 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/ReadTest.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/ReadTest.php @@ -50,7 +50,7 @@ public function testRead() { $entity = $this->entityCreate($entity_type); $entity->save(); // Read it over the REST API. - $response = $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'GET', NULL, $this->defaultMimeType); + $response = $this->httpRequest($this->entityBasePath($entity_type) . '/' . $entity->id(), 'GET', NULL, $this->defaultMimeType); $this->assertResponse('200', 'HTTP response code is correct.'); $this->assertHeader('content-type', $this->defaultMimeType); $data = drupal_json_decode($response); @@ -59,14 +59,13 @@ public function testRead() { $this->assertEqual($data['uuid'][0]['value'], $entity->uuid(), 'Entity UUID is correct'); // Try to read the entity with an unsupported mime format. - $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'GET', NULL, 'application/wrongformat'); - $this->assertResponse(406); + $response = $this->httpRequest($this->entityBasePath($entity_type) . '/' . $entity->id(), 'GET', NULL, 'application/wrongformat'); + $this->assertResponse(200); + $this->assertHeader('Content-type', 'text/html; charset=UTF-8'); // Try to read an entity that does not exist. - $response = $this->httpRequest('entity/' . $entity_type . '/9999', 'GET', NULL, $this->defaultMimeType); + $response = $this->httpRequest($this->entityBasePath($entity_type) . '/9999', 'GET', NULL, $this->defaultMimeType); $this->assertResponse(404); - $decoded = drupal_json_decode($response); - $this->assertEqual($decoded['error'], 'Entity with ID 9999 not found', 'Response message is correct.'); // Make sure that field level access works and that the according field is // not available in the response. Only applies to entity_test. @@ -74,7 +73,7 @@ public function testRead() { if ($entity_type == 'entity_test') { $entity->field_test_text->value = 'no access value'; $entity->save(); - $response = $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'GET', NULL, $this->defaultMimeType); + $response = $this->httpRequest($this->entityBasePath($entity_type) . '/' . $entity->id(), 'GET', NULL, $this->defaultMimeType); $this->assertResponse(200); $this->assertHeader('content-type', $this->defaultMimeType); $data = drupal_json_decode($response); @@ -83,7 +82,7 @@ public function testRead() { // Try to read an entity without proper permissions. $this->drupalLogout(); - $response = $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'GET', NULL, $this->defaultMimeType); + $response = $this->httpRequest($this->entityBasePath($entity_type) . '/' . $entity->id(), 'GET', NULL, $this->defaultMimeType); $this->assertResponse(403); $this->assertNull(drupal_json_decode($response), 'No valid JSON found.'); } @@ -113,7 +112,7 @@ public function testResourceStructure() { $entity->save(); // Read it over the REST API. - $response = $this->httpRequest('entity/node/' . $entity->id(), 'GET', NULL, 'application/json'); + $response = $this->httpRequest('node/' . $entity->id(), 'GET', NULL, 'application/json'); $this->assertResponse('200', 'HTTP response code is correct.'); } diff --git a/core/modules/rest/lib/Drupal/rest/Tests/UpdateTest.php b/core/modules/rest/lib/Drupal/rest/Tests/UpdateTest.php index 9f8d413..7c82cec 100644 --- a/core/modules/rest/lib/Drupal/rest/Tests/UpdateTest.php +++ b/core/modules/rest/lib/Drupal/rest/Tests/UpdateTest.php @@ -61,7 +61,7 @@ public function testPatchUpdate() { $serialized = $serializer->serialize($patch_entity, $this->defaultFormat); // Update the entity over the REST API. - $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); + $this->httpRequest($entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); $this->assertResponse(204); // Re-load updated entity from the database. @@ -73,7 +73,7 @@ public function testPatchUpdate() { $normalized = $serializer->normalize($patch_entity, $this->defaultFormat); unset($normalized['field_test_text']); $serialized = $serializer->encode($normalized, $this->defaultFormat); - $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); + $this->httpRequest($entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); $this->assertResponse(204); $entity = entity_load($entity_type, $entity->id(), TRUE); @@ -84,7 +84,7 @@ public function testPatchUpdate() { $serialized = $serializer->encode($normalized, $this->defaultFormat); // Update the entity over the REST API. - $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); + $this->httpRequest($entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); $this->assertResponse(204); // Re-load updated entity from the database. @@ -98,7 +98,7 @@ public function testPatchUpdate() { $entity->save(); // Try to empty a field that is access protected. - $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); + $this->httpRequest($entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); $this->assertResponse(403); // Re-load the entity from the database. @@ -108,7 +108,7 @@ public function testPatchUpdate() { // Try to update an access protected field. $patch_entity->get('field_test_text')->value = 'no access value'; $serialized = $serializer->serialize($patch_entity, $this->defaultFormat); - $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); + $this->httpRequest($entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); $this->assertResponse(403); // Re-load the entity from the database. @@ -121,7 +121,7 @@ public function testPatchUpdate() { 'format' => 'full_html', )); $serialized = $serializer->serialize($patch_entity, $this->defaultFormat); - $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); + $this->httpRequest($entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); $this->assertResponse(422); // Re-load the entity from the database. @@ -133,11 +133,11 @@ public function testPatchUpdate() { $entity->save(); // Try to send no data at all, which does not make sense on PATCH requests. - $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'PATCH', NULL, $this->defaultMimeType); + $this->httpRequest($entity_type . '/' . $entity->id(), 'PATCH', NULL, $this->defaultMimeType); $this->assertResponse(400); // Try to update a non-existing entity with ID 9999. - $this->httpRequest('entity/' . $entity_type . '/9999', 'PATCH', $serialized, $this->defaultMimeType); + $this->httpRequest($entity_type . '/9999', 'PATCH', $serialized, $this->defaultMimeType); $this->assertResponse(404); $loaded_entity = entity_load($entity_type, 9999, TRUE); $this->assertFalse($loaded_entity, 'Entity 9999 was not created.'); @@ -146,20 +146,20 @@ public function testPatchUpdate() { // Send a UUID that is too long. $entity->set('uuid', $this->randomName(129)); $invalid_serialized = $serializer->serialize($entity, $this->defaultFormat); - $response = $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'PATCH', $invalid_serialized, $this->defaultMimeType); + $response = $this->httpRequest($entity_type . '/' . $entity->id(), 'PATCH', $invalid_serialized, $this->defaultMimeType); $this->assertResponse(422); $error = drupal_json_decode($response); $this->assertEqual($error['error'], "Unprocessable Entity: validation failed.\nuuid.0.value: This value is too long. It should have 128 characters or less.\n"); // Try to update an entity without proper permissions. $this->drupalLogout(); - $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); + $this->httpRequest($entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); $this->assertResponse(403); // Try to update a resource which is not REST API enabled. $this->enableService(FALSE); $this->drupalLogin($account); - $this->httpRequest('entity/' . $entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); - $this->assertResponse(404); + $this->httpRequest($entity_type . '/' . $entity->id(), 'PATCH', $serialized, $this->defaultMimeType); + $this->assertResponse(405); } } diff --git a/core/modules/system/lib/Drupal/system/PathBasedBreadcrumbBuilder.php b/core/modules/system/lib/Drupal/system/PathBasedBreadcrumbBuilder.php index f39a1a5..802c1b7 100644 --- a/core/modules/system/lib/Drupal/system/PathBasedBreadcrumbBuilder.php +++ b/core/modules/system/lib/Drupal/system/PathBasedBreadcrumbBuilder.php @@ -180,6 +180,9 @@ protected function getRequestForPath($path, array $exclude) { // @todo Use the RequestHelper once https://drupal.org/node/2090293 is // fixed. $request = Request::create($this->request->getBaseUrl() . '/' . $path); + // Performance optimization: set a short accept header to reduce overhead in + // AcceptHeaderMatcher when matching the request. + $request->headers->set('Accept', 'text/html'); // Find the system path by resolving aliases, language prefix, etc. $processed = $this->pathProcessor->processInbound($path, $request); if (empty($processed) || !empty($exclude[$processed])) { diff --git a/core/tests/Drupal/Tests/Core/Routing/AcceptHeaderMatcherTest.php b/core/tests/Drupal/Tests/Core/Routing/AcceptHeaderMatcherTest.php new file mode 100644 index 0000000..6080ed6 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Routing/AcceptHeaderMatcherTest.php @@ -0,0 +1,125 @@ + 'Partial matcher MIME types tests', + 'description' => 'Confirm that the mime types partial matcher is functioning properly.', + 'group' => 'Routing', + ); + } + + public function setUp() { + parent::setUp(); + + $this->fixtures = new RoutingFixtures(); + $this->matcher = new AcceptHeaderMatcher(new ContentNegotiation()); + } + + /** + * Check that JSON routes get filtered and prioritized correctly. + */ + public function testJsonFilterRoutes() { + $collection = $this->fixtures->sampleRouteCollection(); + + // Tests basic JSON request. + $request = Request::create('path/two', 'GET'); + $request->headers->set('Accept', 'application/json, text/xml;q=0.9'); + $routes = $this->matcher->filter($collection, $request); + $this->assertEquals(count($routes), 4, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('route_c'), 'The json route was found.'); + $this->assertNull($routes->get('route_e'), 'The html route was not found.'); + foreach ($routes as $name => $route) { + $this->assertEquals($name, 'route_c', 'The json route is the first one in the collection.'); + break; + } + } + + /** + * Tests a JSON request with alternative JSON MIME type Accept header. + */ + public function testAlternativeJson() { + $collection = $this->fixtures->sampleRouteCollection(); + + $request = Request::create('path/two', 'GET'); + $request->headers->set('Accept', 'application/x-json, text/xml;q=0.9'); + $routes = $this->matcher->filter($collection, $request); + $this->assertEquals(count($routes), 4, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('route_c'), 'The json route was found.'); + $this->assertNull($routes->get('route_e'), 'The html route was not found.'); + foreach ($routes as $name => $route) { + $this->assertEquals($name, 'route_c', 'The json route is the first one in the collection.'); + break; + } + } + + /** + * Tests a standard HTML request. + */ + public function teststandardHtml() { + $collection = $this->fixtures->sampleRouteCollection(); + + $request = Request::create('path/two', 'GET'); + $request->headers->set('Accept', 'text/html, text/xml;q=0.9'); + $routes = $this->matcher->filter($collection, $request); + $this->assertEquals(count($routes), 4, 'The correct number of routes was found.'); + $this->assertNull($routes->get('route_c'), 'The json route was not found.'); + $this->assertNotNull($routes->get('route_e'), 'The html route was found.'); + foreach ($routes as $name => $route) { + $this->assertEquals($name, 'route_e', 'The html route is the first one in the collection.'); + break; + } + } + + /** + * Confirms that the AcceptHeaderMatcher throws an exception for no-route. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException + * @expectedExceptionMessage No route found for the specified formats application/json text/xml. + */ + public function testNoRouteFound() { + // Remove the sample routes that would match any method. + $routes = $this->fixtures->sampleRouteCollection(); + $routes->remove('route_a'); + $routes->remove('route_b'); + $routes->remove('route_c'); + $routes->remove('route_d'); + + $request = Request::create('path/two', 'GET'); + $request->headers->set('Accept', 'application/json, text/xml;q=0.9'); + $this->matcher->filter($routes, $request); + $this->fail('No exception was thrown.'); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Routing/ContentTypeHeaderMatcherTest.php b/core/tests/Drupal/Tests/Core/Routing/ContentTypeHeaderMatcherTest.php new file mode 100644 index 0000000..9e8c8f5 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Routing/ContentTypeHeaderMatcherTest.php @@ -0,0 +1,113 @@ + 'Content Type header matcher test', + 'description' => 'Confirm that the content types partial matcher is functioning properly.', + 'group' => 'Routing', + ); + } + + public function setUp() { + parent::setUp(); + + $this->fixtures = new RoutingFixtures(); + $this->matcher = new ContentTypeHeaderMatcher(); + } + + /** + * Tests that routes are not filtered on GET requests. + */ + public function testGetRequestFilter() { + $collection = $this->fixtures->sampleRouteCollection(); + $collection->addCollection($this->fixtures->contentRouteCollection()); + + $request = Request::create('path/two', 'GET'); + $routes = $this->matcher->filter($collection, $request); + $this->assertEquals(count($routes), 7, 'The correct number of routes was found.'); + } + + /** + * Tests that XML-restricted routes get filtered out on JSON requests. + */ + public function testJsonRequest() { + $collection = $this->fixtures->sampleRouteCollection(); + $collection->addCollection($this->fixtures->contentRouteCollection()); + + $request = Request::create('path/two', 'POST'); + $request->headers->set('Content-type', 'application/json'); + $routes = $this->matcher->filter($collection, $request); + $this->assertEquals(count($routes), 6, 'The correct number of routes was found.'); + $this->assertNotNull($routes->get('route_f'), 'The json route was found.'); + $this->assertNull($routes->get('route_g'), 'The xml route was not found.'); + foreach ($routes as $name => $route) { + $this->assertEquals($name, 'route_f', 'The json route is the first one in the collection.'); + break; + } + } + + /** + * Tests route filtering on POST form submission requests. + */ + public function testPostForm() { + $collection = $this->fixtures->sampleRouteCollection(); + $collection->addCollection($this->fixtures->contentRouteCollection()); + + // Test that all XML and JSON restricted routes get filtered out on a POST + // form submission. + $request = Request::create('path/two', 'POST'); + $request->headers->set('Content-type', 'application/www-form-urlencoded'); + $routes = $this->matcher->filter($collection, $request); + $this->assertEquals(count($routes), 5, 'The correct number of routes was found.'); + $this->assertNull($routes->get('route_f'), 'The json route was found.'); + $this->assertNull($routes->get('route_g'), 'The xml route was not found.'); + } + + /** + * Confirms that the matcher throws an exception for no-route. + * + * @expectedException \Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException + * @expectedExceptionMessage No route found that matches the Content-Type header. + */ + public function testNoRouteFound() { + $matcher = new ContentTypeHeaderMatcher(); + + $routes = $this->fixtures->contentRouteCollection(); + $request = Request::create('path/two', 'POST'); + $request->headers->set('Content-type', 'application/hal+json'); + $matcher->filter($routes, $request); + $this->fail('No exception was thrown.'); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Routing/MimeTypeMatcherTest.php b/core/tests/Drupal/Tests/Core/Routing/MimeTypeMatcherTest.php deleted file mode 100644 index b407fe8..0000000 --- a/core/tests/Drupal/Tests/Core/Routing/MimeTypeMatcherTest.php +++ /dev/null @@ -1,112 +0,0 @@ - 'Partial matcher MIME types tests', - 'description' => 'Confirm that the mime types partial matcher is functioning properly.', - 'group' => 'Routing', - ); - } - - public function setUp() { - $this->fixtures = new RoutingFixtures(); - } - - /** - * Confirms that the MimeType matcher matches properly. - * - * @param string $accept_header - * The 'Accept` header to test. - * @param integer $routes_count - * The number of expected routes. - * @param string $null_route - * The route that is expected to be null. - * @param string $not_null_route - * The route that is expected to not be null. - * - * @dataProvider providerTestFilterRoutes - */ - public function testFilterRoutes($accept_header, $routes_count, $null_route, $not_null_route) { - - $matcher = new MimeTypeMatcher(); - $collection = $this->fixtures->sampleRouteCollection(); - - // Tests basic JSON request. - $request = Request::create('path/two', 'GET'); - $request->headers->set('Accept', $accept_header); - $routes = $matcher->filter($collection, $request); - $this->assertEquals($routes_count, count($routes), 'An incorrect number of routes was found.'); - $this->assertNull($routes->get($null_route), 'A route was found where it should be null.'); - $this->assertNotNull($routes->get($not_null_route), 'The expected route was not found.'); - } - - /** - * Provides test routes for testFilterRoutes. - * - * @return array - * An array of arrays, each containing the parameters necessary for the - * testFilterRoutes method. - */ - public function providerTestFilterRoutes() { - return array( - // Tests basic JSON request. - array('application/json, text/xml;q=0.9', 4, 'route_e', 'route_c'), - - // Tests JSON request with alternative JSON MIME type Accept header. - array('application/x-json, text/xml;q=0.9', 4, 'route_e', 'route_c'), - - // Tests basic HTML request. - array('text/html, text/xml;q=0.9', 4, 'route_c', 'route_e'), - ); - } - - /** - * Confirms that the MimeTypeMatcher matcher throws an exception for no-route. - * - * @expectedException Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException - */ - public function testNoRouteFound() { - $matcher = new MimeTypeMatcher(); - - // Remove the sample routes that would match any method. - $routes = $this->fixtures->sampleRouteCollection(); - $routes->remove('route_a'); - $routes->remove('route_b'); - $routes->remove('route_c'); - $routes->remove('route_d'); - - // This should throw NotAcceptableHttpException. - $request = Request::create('path/two', 'GET'); - $request->headers->set('Accept', 'application/json, text/xml;q=0.9'); - $routes = $matcher->filter($routes, $request); - } - -} diff --git a/core/tests/Drupal/Tests/Core/Routing/RoutingFixtures.php b/core/tests/Drupal/Tests/Core/Routing/RoutingFixtures.php index 94a36db..48071ae 100644 --- a/core/tests/Drupal/Tests/Core/Routing/RoutingFixtures.php +++ b/core/tests/Drupal/Tests/Core/Routing/RoutingFixtures.php @@ -144,6 +144,26 @@ public function complexRouteCollection() { } /** + * Returns a Content-type restricted set of routes for testing. + * + * @return \Symfony\Component\Routing\RouteCollection + */ + public function contentRouteCollection() { + $collection = new RouteCollection(); + + $route = new Route('path/three'); + $route->setRequirement('_method', 'POST'); + $route->setRequirement('_content_type_format', 'json'); + $collection->add('route_f', $route); + + $route = new Route('path/three'); + $route->setRequirement('_method', 'PATCH'); + $route->setRequirement('_content_type_format', 'xml'); + $collection->add('route_g', $route); + return $collection; + } + + /** * Returns the table definition for the routing fixtures. * * @return array