core/modules/rest/rest.services.yml | 5 + .../EventSubscriber/ResourceResponseSubscriber.php | 209 +++++++++++++++++++++ core/modules/rest/src/RequestHandler.php | 111 +---------- 3 files changed, 217 insertions(+), 108 deletions(-) diff --git a/core/modules/rest/rest.services.yml b/core/modules/rest/rest.services.yml index 1cc34c1..385c4bd 100644 --- a/core/modules/rest/rest.services.yml +++ b/core/modules/rest/rest.services.yml @@ -28,3 +28,8 @@ services: logger.channel.rest: parent: logger.channel_base arguments: ['rest'] + resource_response.subscriber: + class: Drupal\rest\EventSubscriber\ResourceResponseSubscriber + tags: + - { name: event_subscriber } + arguments: ['@serializer', '@renderer', '@current_route_match'] diff --git a/core/modules/rest/src/EventSubscriber/ResourceResponseSubscriber.php b/core/modules/rest/src/EventSubscriber/ResourceResponseSubscriber.php new file mode 100644 index 0000000..9c1a8cd --- /dev/null +++ b/core/modules/rest/src/EventSubscriber/ResourceResponseSubscriber.php @@ -0,0 +1,209 @@ +serializer = $serializer; + $this->renderer = $renderer; + $this->routeMatch = $route_match; + } + + /** + * Processes attachments for ResourceResponse responses. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * The event to process. + */ + public function onRespond(FilterResponseEvent $event) { + $response = $event->getResponse(); + if (!$response instanceof ResourceResponseInterface) { + return; + } + + $request = $event->getRequest(); + $format = $this->getResponseFormat($this->routeMatch, $request); + $response = $this->renderResponseBody($request, $response, $this->serializer, $format); + $event->setResponse($this->flattenResponse($response)); + } + + /** + * Determines the format to respond in. + * + * Respects the requested format if one is specified. However, it is common to + * forget to specify a request format in case of a POST or PATCH. Rather than + * simply throwing an error, we apply the robustness principle: when POSTing + * or PATCHing using a certain format, you probably expect a response in that + * same format. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The current route match. + * @param \Symfony\Component\HttpFoundation\Request $request + * The current request. + * + * @return string + * The response format. + */ + protected function getResponseFormat(RouteMatchInterface $route_match, Request $request) { + $route = $route_match->getRouteObject(); + $acceptable_request_formats = $route->hasRequirement('_format') ? explode('|', $route->getRequirement('_format')) : []; + $acceptable_content_type_formats = $route->hasRequirement('_content_type_format') ? explode('|', $route->getRequirement('_content_type_format')) : []; + $acceptable_formats = $request->isMethodSafe() ? $acceptable_request_formats : $acceptable_content_type_formats; + + $requested_format = $request->getRequestFormat(); + $content_type_format = $request->getContentType(); + + // If an acceptable format is requested, then use that. Otherwise, including + // and particularly when the client forgot to specify a format, then use + // heuristics to select the format that is most likely expected. + if (in_array($requested_format, $acceptable_formats)) { + return $requested_format; + } + // If a request body is present, then use the format corresponding to the + // request body's Content-Type for the response, if it's an acceptable + // format for the request. + elseif (!empty($request->getContent()) && in_array($content_type_format, $acceptable_content_type_formats)) { + return $content_type_format; + } + // Otherwise, use the first acceptable format. + elseif (!empty($acceptable_formats)) { + return $acceptable_formats[0]; + } + // Sometimes, there are no acceptable formats, e.g. DELETE routes. + else { + return NULL; + } + } + + /** + * Renders a resource response body. + * + * Serialization can invoke rendering (e.g., generating URLs), but the + * serialization API does not provide a mechanism to collect the + * bubbleable metadata associated with that (e.g., language and other + * contexts), so instead, allow those to "leak" and collect them here in + * a render context. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * @param \Drupal\rest\ResourceResponseInterface $response + * The response from the REST resource. + * @param \Symfony\Component\Serializer\SerializerInterface $serializer + * The serializer to use. + * @param string|null $format + * The response format, or NULL in case the response does not need a format, + * for example for the response to a DELETE request. + * + * @return \Drupal\rest\ResourceResponse + * The altered response. + * + * @todo Add test coverage for language negotiation contexts in + * https://www.drupal.org/node/2135829. + */ + protected function renderResponseBody(Request $request, ResourceResponseInterface $response, SerializerInterface $serializer, $format) { + $data = $response->getResponseData(); + + // If there is data to send, serialize and set it as the response body. + if ($data !== NULL) { + if ($response instanceof CacheableResponseInterface) { + $context = new RenderContext(); + $output = $this->renderer + ->executeInRenderContext($context, function () use ($serializer, $data, $format) { + return $serializer->serialize($data, $format); + }); + + if (!$context->isEmpty()) { + $response->addCacheableDependency($context->pop()); + } + } + else { + $output = $serializer->serialize($data, $format); + } + + $response->setContent($output); + $response->headers->set('Content-Type', $request->getMimeType($format)); + } + + return $response; + } + + /** + * Flattens a fully rendered resource response. + * + * Ensures that complex data structures in ResourceResponse::getResponseData() + * are not serialized. + * + * @param \Drupal\rest\ResourceResponseInterface $response + * A fully rendered resource response. + * + * @return \Drupal\Core\Cache\CacheableResponse|\Symfony\Component\HttpFoundation\Response + * The flattened response. + */ + protected function flattenResponse(ResourceResponseInterface $response) { + assert('!empty($response->getContent())'); + $final_response = ($response instanceof CacheableResponseInterface) ? new CacheableResponse() : new Response(); + $final_response->setContent($response->getContent()); + $final_response->headers->add($response->headers->all()); + if ($final_response instanceof CacheableResponseInterface) { + $final_response->addCacheableDependency($response->getCacheableMetadata()); + } + return $final_response; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events[KernelEvents::RESPONSE][] = ['onRespond']; + return $events; + } + +} diff --git a/core/modules/rest/src/RequestHandler.php b/core/modules/rest/src/RequestHandler.php index 088dca2..4700b2d 100644 --- a/core/modules/rest/src/RequestHandler.php +++ b/core/modules/rest/src/RequestHandler.php @@ -2,10 +2,9 @@ namespace Drupal\rest; +use Drupal\Core\Cache\CacheableResponseInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\EntityStorageInterface; -use Drupal\Core\Cache\CacheableResponseInterface; -use Drupal\Core\Render\RenderContext; use Drupal\Core\Routing\RouteMatchInterface; use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerAwareTrait; @@ -14,10 +13,11 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; -use Symfony\Component\Serializer\SerializerInterface; /** * Acts as intermediate request forwarder for resource plugins. + * + * @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber */ class RequestHandler implements ContainerAwareInterface, ContainerInjectionInterface { @@ -129,118 +129,13 @@ public function handle(RouteMatchInterface $route_match, Request $request) { } // Invoke the operation on the resource plugin. - $format = $this->getResponseFormat($route_match, $request); $response = call_user_func_array(array($resource, $method), array_merge($parameters, array($unserialized, $request))); - return $response instanceof ResourceResponseInterface ? - $this->renderResponse($request, $response, $serializer, $format, $resource_config) : - $response; - } - - /** - * Determines the format to respond in. - * - * Respects the requested format if one is specified. However, it is common to - * forget to specify a request format in case of a POST or PATCH. Rather than - * simply throwing an error, we apply the robustness principle: when POSTing - * or PATCHing using a certain format, you probably expect a response in that - * same format. - * - * @param \Drupal\Core\Routing\RouteMatchInterface $route_match - * The current route match. - * @param \Symfony\Component\HttpFoundation\Request $request - * The current request. - * - * @return string - * The response format. - */ - protected function getResponseFormat(RouteMatchInterface $route_match, Request $request) { - $route = $route_match->getRouteObject(); - $acceptable_request_formats = $route->hasRequirement('_format') ? explode('|', $route->getRequirement('_format')) : []; - $acceptable_content_type_formats = $route->hasRequirement('_content_type_format') ? explode('|', $route->getRequirement('_content_type_format')) : []; - $acceptable_formats = $request->isMethodSafe() ? $acceptable_request_formats : $acceptable_content_type_formats; - - $requested_format = $request->getRequestFormat(); - $content_type_format = $request->getContentType(); - - // If an acceptable format is requested, then use that. Otherwise, including - // and particularly when the client forgot to specify a format, then use - // heuristics to select the format that is most likely expected. - if (in_array($requested_format, $acceptable_formats)) { - return $requested_format; - } - // If a request body is present, then use the format corresponding to the - // request body's Content-Type for the response, if it's an acceptable - // format for the request. - elseif (!empty($request->getContent()) && in_array($content_type_format, $acceptable_content_type_formats)) { - return $content_type_format; - } - // Otherwise, use the first acceptable format. - elseif (!empty($acceptable_formats)) { - return $acceptable_formats[0]; - } - // Sometimes, there are no acceptable formats, e.g. DELETE routes. - else { - return NULL; - } - } - - /** - * Renders a resource response. - * - * Serialization can invoke rendering (e.g., generating URLs), but the - * serialization API does not provide a mechanism to collect the - * bubbleable metadata associated with that (e.g., language and other - * contexts), so instead, allow those to "leak" and collect them here in - * a render context. - * - * @param \Symfony\Component\HttpFoundation\Request $request - * The request object. - * @param \Drupal\rest\ResourceResponseInterface $response - * The response from the REST resource. - * @param \Symfony\Component\Serializer\SerializerInterface $serializer - * The serializer to use. - * @param string|null $format - * The response format, or NULL in case the response does not need a format, - * for example for the response to a DELETE request. - * @param \Drupal\rest\RestResourceConfigInterface $resource_config - * The resource config. - * - * @return \Drupal\rest\ResourceResponse - * The altered response. - * - * @todo Add test coverage for language negotiation contexts in - * https://www.drupal.org/node/2135829. - */ - protected function renderResponse(Request $request, ResourceResponseInterface $response, SerializerInterface $serializer, $format, RestResourceConfigInterface $resource_config) { - $data = $response->getResponseData(); - if ($response instanceof CacheableResponseInterface) { // Add rest config's cache tags. $response->addCacheableDependency($resource_config); } - // If there is data to send, serialize and set it as the response body. - if ($data !== NULL) { - if ($response instanceof CacheableResponseInterface) { - $context = new RenderContext(); - $output = $this->container->get('renderer') - ->executeInRenderContext($context, function () use ($serializer, $data, $format) { - return $serializer->serialize($data, $format); - }); - - if (!$context->isEmpty()) { - $response->addCacheableDependency($context->pop()); - } - } - else { - $output = $serializer->serialize($data, $format); - } - - $response->setContent($output); - $response->headers->set('Content-Type', $request->getMimeType($format)); - } - return $response; }