diff --git a/core/modules/rest/src/RequestHandler.php b/core/modules/rest/src/RequestHandler.php index 6731c26d0c..2ee1be7dda 100644 --- a/core/modules/rest/src/RequestHandler.php +++ b/core/modules/rest/src/RequestHandler.php @@ -6,6 +6,7 @@ use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\rest\Plugin\ResourceInterface; use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerAwareTrait; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -60,6 +61,57 @@ public static function create(ContainerInterface $container) { * The response object. */ public function handle(RouteMatchInterface $route_match, Request $request) { + $method = $this->getRequestMethod($route_match); + + $resource_config = $this->loadRestResourceConfigFromRouteMatch($route_match); + $resource = $resource_config->getResourcePlugin(); + + // Deserialize incoming data if available. + $unserialized = $this->denormalizeRequestData($request, $resource, $method); + + $parameters = $parameters = $this->prepareRouteParameters($route_match); + + // Invoke the operation on the resource plugin. + $response = call_user_func_array([$resource, $method], array_merge($parameters, [$unserialized, $request])); + + return $this->prepareResponse($response, $resource_config); + } + + /** + * Handles a web API request without deserializing the request content. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match. + * @param \Symfony\Component\HttpFoundation\Request $request + * The HTTP request object. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response object. + */ + public function handleRaw(RouteMatchInterface $route_match, Request $request) { + $method = $this->getRequestMethod($route_match); + + $resource_config = $this->loadRestResourceConfigFromRouteMatch($route_match); + $resource = $resource_config->getResourcePlugin(); + + $parameters = $this->prepareRouteParameters($route_match); + + // Invoke the operation on the resource plugin. + $response = call_user_func_array([$resource, $method], array_merge([$request], $parameters)); + + return $this->prepareResponse($response, $resource_config); + } + + /** + * Gets the request method from the route match. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match interface. + * + * @return string + * The request method. + */ + protected function getRequestMethod(RouteMatchInterface $route_match) { // Symfony is built to transparently map HEAD requests to a GET request. In // the case of the REST module's RequestHandler though, we essentially have // our own light-weight routing system on top of the Drupal/symfony routing @@ -74,18 +126,69 @@ public function handle(RouteMatchInterface $route_match, Request $request) { $method = strtolower($route_match->getRouteObject()->getMethods()[0]); assert(count($route_match->getRouteObject()->getMethods()) === 1); + return $method; + } + /** + * Loads a REST resource plugin from the route match object. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match instance. + * + * @return \Drupal\rest\RestResourceConfigInterface + * The loaded REST config. + */ + protected function loadRestResourceConfigFromRouteMatch(RouteMatchInterface $route_match) { $resource_config_id = $route_match->getRouteObject()->getDefault('_rest_resource_config'); - /** @var \Drupal\rest\RestResourceConfigInterface $resource_config */ - $resource_config = $this->resourceStorage->load($resource_config_id); - $resource = $resource_config->getResourcePlugin(); + return $this->resourceStorage->load($resource_config_id); + } - // Deserialize incoming data if available. - /** @var \Symfony\Component\Serializer\SerializerInterface $serializer */ - $serializer = $this->container->get('serializer'); + /** + * Determines the request parameters to pass to the resource plugin. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match instance. + * + * @return array + * The route parameters to pass to the resource plugin. + */ + protected function prepareRouteParameters(RouteMatchInterface $route_match) { + $route_parameters = $route_match->getParameters(); + $parameters = []; + // Filter out all internal parameters starting with "_". + foreach ($route_parameters as $key => $parameter) { + if ($key{0} !== '_') { + $parameters[] = $parameter; + } + } + + return $parameters; + } + + /** + * Deserializes and denormalizes request content. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * @param \Drupal\rest\Plugin\ResourceInterface $resource + * The REST resource plugin. + * @param string $method + * The request method. + * + * @return mixed|NULL + * The denormalized \Drupal\Core\Entity\EntityInterface object if there is a + * serialization class, an array of decoded data, or NULL if there is no + * request content. + * + */ + protected function denormalizeRequestData(Request $request, ResourceInterface $resource, $method) { $received = $request->getContent(); $unserialized = NULL; + if (!empty($received)) { + /** @var \Symfony\Component\Serializer\SerializerInterface $serializer */ + $serializer = $this->container->get('serializer'); + $format = $request->getContentType(); $definition = $resource->getPluginDefinition(); @@ -106,8 +209,8 @@ public function handle(RouteMatchInterface $route_match, Request $request) { try { $unserialized = $serializer->denormalize($unserialized, $definition['serialization_class'], $format, ['request_method' => $method]); } - // These two serialization exception types mean there was a problem - // with the structure of the decoded data and it's not valid. + // These two serialization exception types mean there was a problem + // with the structure of the decoded data and it's not valid. catch (UnexpectedValueException $e) { throw new UnprocessableEntityHttpException($e->getMessage()); } @@ -117,63 +220,22 @@ public function handle(RouteMatchInterface $route_match, Request $request) { } } - // Determine the request parameters that should be passed to the resource - // plugin. - $route_parameters = $route_match->getParameters(); - $parameters = []; - // 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. - $response = call_user_func_array([$resource, $method], array_merge($parameters, [$unserialized, $request])); - - if ($response instanceof CacheableResponseInterface) { - // Add rest config's cache tags. - $response->addCacheableDependency($resource_config); - } - - return $response; + return $unserialized; } - public function handleRaw(RouteMatchInterface $route_match, Request $request) { - // Symfony is built to transparently map HEAD requests to a GET request. In - // the case of the REST module's RequestHandler though, we essentially have - // our own light-weight routing system on top of the Drupal/symfony routing - // system. So, we have to respect the decision that the routing system made: - // we look not at the request method, but at the route's method. All REST - // routes are guaranteed to have _method set. - // Response::prepare() will transform it to a HEAD response at the very last - // moment. - // @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4 - // @see \Symfony\Component\Routing\Matcher\UrlMatcher::matchCollection() - // @see \Symfony\Component\HttpFoundation\Response::prepare() - $method = strtolower($route_match->getRouteObject()->getMethods()[0]); - assert(count($route_match->getRouteObject()->getMethods()) === 1); - - - $resource_config_id = $route_match->getRouteObject()->getDefault('_rest_resource_config'); - /** @var \Drupal\rest\RestResourceConfigInterface $resource_config */ - $resource_config = $this->resourceStorage->load($resource_config_id); - $resource = $resource_config->getResourcePlugin(); - - // Determine the request parameters that should be passed to the resource - // plugin. - $route_parameters = $route_match->getParameters(); - $parameters = []; - // 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. - $response = call_user_func_array([$resource, $method], array_merge([$request], $parameters)); - + /** + * Prepares the REST response. + * + * @TODO is there are requirement that REST resources MUST return a response + * object? If so, we can type hint that here. + * + * @param $response + * @param \Drupal\rest\RestResourceConfigInterface $resource_config + * The REST resource config entity. + * + * @return $response + */ + protected function prepareResponse($response, RestResourceConfigInterface $resource_config) { if ($response instanceof CacheableResponseInterface) { // Add rest config's cache tags. $response->addCacheableDependency($resource_config); diff --git a/core/modules/rest/tests/src/Unit/EventSubscriber/ResourceResponseSubscriberTest.php b/core/modules/rest/tests/src/Unit/EventSubscriber/ResourceResponseSubscriberTest.php index b24530d33f..7ac71d3b67 100644 --- a/core/modules/rest/tests/src/Unit/EventSubscriber/ResourceResponseSubscriberTest.php +++ b/core/modules/rest/tests/src/Unit/EventSubscriber/ResourceResponseSubscriberTest.php @@ -78,7 +78,7 @@ public function providerTestSerialization() { * * @dataProvider providerTestResponseFormat */ - public function testResponseFormat($methods, array $supported_formats, $request_format, array $request_headers, $request_body, $expected_response_format, $expected_response_content_type, $expected_response_content) { + public function testResponseFormat($methods, array $supported_formats, $request_format, array $request_headers, $request_body, $expected_response_format, $expected_response_content_type, $expected_response_content, array $supported_request_formats = []) { foreach ($request_headers as $key => $value) { unset($request_headers[$key]); $key = strtoupper(str_replace('-', '_', $key)); @@ -93,7 +93,7 @@ public function testResponseFormat($methods, array $supported_formats, $request_ $request->setRequestFormat($request_format); } - $route_requirements = $this->generateRouteRequirements($request, $supported_formats); + $route_requirements = $this->generateRouteRequirements($request, $supported_formats, $supported_request_formats); $route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => $this->randomMachineName()], $route_requirements)); @@ -115,9 +115,7 @@ public function testResponseFormat($methods, array $supported_formats, $request_ * * @dataProvider providerTestResponseFormat */ - public function testOnResponseWithCacheableResponse($methods, array $supported_formats, $request_format, array $request_headers, $request_body, $expected_response_format, $expected_response_content_type, $expected_response_content) { - $rest_config_name = $this->randomMachineName(); - + public function testOnResponseWithCacheableResponse($methods, array $supported_formats, $request_format, array $request_headers, $request_body, $expected_response_format, $expected_response_content_type, $expected_response_content, array $supported_request_formats = []) { foreach ($request_headers as $key => $value) { unset($request_headers[$key]); $key = strtoupper(str_replace('-', '_', $key)); @@ -132,7 +130,7 @@ public function testOnResponseWithCacheableResponse($methods, array $supported_f $request->setRequestFormat($request_format); } - $route_requirements = $this->generateRouteRequirements($request, $supported_formats); + $route_requirements = $this->generateRouteRequirements($request, $supported_formats, $supported_request_formats); $route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => $this->randomMachineName()], $route_requirements)); @@ -167,9 +165,7 @@ public function testOnResponseWithCacheableResponse($methods, array $supported_f * * @dataProvider providerTestResponseFormat */ - public function testOnResponseWithUncacheableResponse($methods, array $supported_formats, $request_format, array $request_headers, $request_body, $expected_response_format, $expected_response_content_type, $expected_response_content) { - $rest_config_name = $this->randomMachineName(); - + public function testOnResponseWithUncacheableResponse($methods, array $supported_formats, $request_format, array $request_headers, $request_body, $expected_response_format, $expected_response_content_type, $expected_response_content, array $supported_request_formats = []) { foreach ($request_headers as $key => $value) { unset($request_headers[$key]); $key = strtoupper(str_replace('-', '_', $key)); @@ -184,7 +180,7 @@ public function testOnResponseWithUncacheableResponse($methods, array $supported $request->setRequestFormat($request_format); } - $route_requirements = $this->generateRouteRequirements($request, $supported_formats); + $route_requirements = $this->generateRouteRequirements($request, $supported_formats, $supported_request_formats); $route_match = new RouteMatch('test', new Route('/rest/test', ['_rest_resource_config' => $this->randomMachineName()], $route_requirements)); @@ -314,6 +310,17 @@ public function providerTestResponseFormat() { 'application/json', $json_encoded, ], + 'unsafe methods with response (POST, PATCH): client requested format other than request body format when only XML is allowed as a content type format' => [ + ['POST', 'PATCH'], + ['xml'], + 'json', + ['Content-Type' => 'text/xml'], + $xml_encoded, + 'json', + 'application/json', + $json_encoded, + ['json'], + ], ]; $unsafe_method_bodyless_test_cases = [ @@ -385,12 +392,22 @@ protected function getFunctioningResourceResponseSubscriber(RouteMatchInterface * @return array * An array of route requirements. */ - protected function generateRouteRequirements(Request $request, array $supported_formats) { + protected function generateRouteRequirements(Request $request, array $supported_formats, array $supported_request_formats = []) { // Add the supported formats as allowed response formats so requests can // receive responses in a different format. $supported_format_string = implode('|', $supported_formats); + + // If there are supported request formats, use those. Otherwise, use the + // supported formats. + if ($supported_request_formats) { + $request_format_string = implode('|', $supported_request_formats); + } + else { + $request_format_string = $supported_format_string; + } + $route_requirements = [ - '_format' => $supported_format_string, + '_format' => $request_format_string, ]; if (!$request->isMethodCacheable()) {