diff -u b/jsonapi.services.yml b/jsonapi.services.yml --- b/jsonapi.services.yml +++ b/jsonapi.services.yml @@ -131,12 +131,12 @@ # Event subscribers. jsonapi.resource_response.subscriber: class: Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber - tags: - - { name: event_subscriber } arguments: ['@serializer', '@renderer', '@logger.channel.jsonapi'] calls: - [setValidator, []] - - [setSchemaFactory, ['@?schemata.schema_factory']] + - [setSchemaFactory, ['@?schemata.schema_factory']] # This is only injected when the service is available. + tags: + - { name: event_subscriber } # Deprecated services. serializer.normalizer.htt_exception.jsonapi: diff -u b/src/EventSubscriber/ResourceResponseSubscriber.php b/src/EventSubscriber/ResourceResponseSubscriber.php --- b/src/EventSubscriber/ResourceResponseSubscriber.php +++ b/src/EventSubscriber/ResourceResponseSubscriber.php @@ -2,21 +2,21 @@ namespace Drupal\jsonapi\EventSubscriber; -use Drupal\schemata\SchemaFactory; -use JsonSchema\Validator; use Drupal\Component\Serialization\Json; use Drupal\Core\Cache\CacheableResponse; use Drupal\Core\Cache\CacheableResponseInterface; use Drupal\Core\Render\RenderContext; use Drupal\Core\Render\RendererInterface; use Drupal\jsonapi\ResourceResponse; +use Drupal\schemata\SchemaFactory; +use JsonSchema\Validator; use Psr\Log\LoggerInterface; use Symfony\Cmf\Component\Routing\RouteObjectInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; -use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\Serializer\SerializerInterface; /** @@ -97,6 +97,15 @@ } /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + // Run before \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber. + $events[KernelEvents::RESPONSE][] = ['onResponse', 110]; + return $events; + } + + /** * Sets the validator service if available. */ public function setValidator(Validator $validator = NULL) { @@ -174,14 +183,17 @@ // If there is data to send, serialize and set it as the response body. if ($data !== NULL) { $context = new RenderContext(); - $output = $this->renderer - ->executeInRenderContext($context, function () use ($serializer, $data, $format, $request, $response) { - // The serializer receives the response's cacheability metadata object - // as serialization context. Normalizers called by the serializer then - // refine this cacheability metadata, and thus they are effectively - // updating the response object's cacheability. - return $serializer->serialize($data, $format, ['request' => $request, 'cacheable_metadata' => $response->getCacheableMetadata()]); - }); + $render_function = function () use ($serializer, $data, $format, $request, $response) { + // The serializer receives the response's cacheability metadata object + // as serialization context. Normalizers called by the serializer then + // refine this cacheability metadata, and thus they are effectively + // updating the response object's cacheability. + return $serializer->serialize($data, $format, [ + 'request' => $request, + 'cacheable_metadata' => $response->getCacheableMetadata(), + ]); + }; + $output = $this->renderer->executeInRenderContext($context, $render_function); if ($response instanceof CacheableResponseInterface && !$context->isEmpty()) { $response->addCacheableDependency($context->pop()); @@ -197,7 +209,7 @@ * * Ensures that complex data structures in ResourceResponse::getResponseData() * are not serialized. Not doing this means that caching this response object - * requires unserializing the PHP data when reading this response object from + * requires deserializing the PHP data when reading this response object from * cache, which can be very costly, and is unnecessary. * * @param \Drupal\jsonapi\ResourceResponse $response @@ -231,9 +243,11 @@ * FALSE if the response failed validation, otherwise TRUE. */ protected function validateResponse(Response $response, Request $request) { + // If the validator isn't set, then the validation library is not installed. if (!$this->validator) { return TRUE; } + // Do not use Json::decode here since it coerces the response into an // associative array, which creates validation errors. $response_data = json_decode($response->getContent()); @@ -248,37 +262,43 @@ return FALSE; } + // This will be set if the schemata module is present. if (!$this->schemaFactory) { - return TRUE; + // We're done because we're missing schemata. + return $is_valid; } + // Get the schema for the current resource. For that we will need to // introspect the request to find the entity type and bundle matched by the // router. $route = $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT); $route_name = $request->attributes->get(RouteObjectInterface::ROUTE_NAME); - // Check if the response is a collection, related, and/or relationship. We need to validate each - // collection resource object against the schema and skip related/relationships. + + // We shouldn't validate related/relationships. $is_related = strpos($route_name, '.related') !== FALSE; $is_relationship = strpos($route_name, '.relationship') !== FALSE; - $is_collection = strpos($route_name, '.collection') !== FALSE; if ($is_related || $is_relationship) { - return TRUE; + return $is_valid; } + $entity_type_id = $route->getRequirement('_entity_type'); $bundle = $route->getRequirement('_bundle'); $output_format = 'schema_json'; $described_format = 'api_json'; - $schema_factory = \Drupal::service('schemata.schema_factory'); - $generic_jsonapi_schema = $schema_factory->create($entity_type_id, $bundle); + $generic_jsonapi_schema = $this->schemaFactory->create($entity_type_id, $bundle); $format = $output_format . ':' . $described_format; $output = $this->serializer->serialize($generic_jsonapi_schema, $format); $specific_schema = json_decode($output); + // We need to individually validate each collection resource object. + $is_collection = strpos($route_name, '.collection') !== FALSE; + // Iterate over each resource object and check the schema. return array_reduce( $is_collection ? $response_data->data : [$response_data->data], function ($valid, $resource_object) use ($specific_schema) { + // Validating the schema first ensures that every object is processed. return $this->validateSchema($specific_schema, $resource_object) && $valid; }, TRUE @@ -302,21 +322,10 @@ if (!$is_valid) { - $this->logger->debug('Response failed validation: @data', [ + $this->logger->debug("Response failed validation.\nResponse:\n@data\n\nErrors:\n@errors", [ '@data' => Json::encode($response_data), - ]); - $this->logger->debug('Validation errors: @errors', [ '@errors' => Json::encode($this->validator->getErrors()), ]); } return $is_valid; } - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents() { - // Run before \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber. - $events[KernelEvents::RESPONSE][] = ['onResponse', 110]; - return $events; - } - } diff -u b/tests/src/Unit/EventSubscriber/ResourceResponseSubscriberTest.php b/tests/src/Unit/EventSubscriber/ResourceResponseSubscriberTest.php --- b/tests/src/Unit/EventSubscriber/ResourceResponseSubscriberTest.php +++ b/tests/src/Unit/EventSubscriber/ResourceResponseSubscriberTest.php @@ -5,6 +5,8 @@ use Drupal\Core\Render\RendererInterface; use Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber; use Drupal\rest\ResourceResponse; +use Drupal\schemata\SchemaFactory; +use Drupal\schemata\Encoder\JsonSchemaEncoder; use Drupal\Tests\UnitTestCase; use Prophecy\Argument; use Psr\Log\LoggerInterface; @@ -36,7 +38,7 @@ } $subscriber = new ResourceResponseSubscriber( - $this->prophesize(Serializer::class)->reveal(), + new Serializer([], [new JsonSchemaEncoder()]), $this->prophesize(RendererInterface::class)->reveal(), $this->prophesize(LoggerInterface::class)->reveal() ); @@ -45,9 +47,9 @@ } /** - * @covers ::onResponse + * @covers ::doValidateResponse */ - public function testOnResponse() { + public function testDoValidateResponse() { // In PHP 7 and above, we can test that validation is skipped in production. Below 7, these tests would always fail. if (PHP_MAJOR_VERSION < 7) { $this->markTestSkipped('Skipped because \'zend.assertions = Off\' is only possible in PHP 7 and above.'); @@ -89,6 +91,74 @@ } /** + * @covers ::onResponse + */ + public function testValidateResponse_schemata() { + $request = $this->createRequest( + 'jsonapi.node--article.individual', + '/jsonapi/node/article/{node}', + ['_entity_type' => 'node', '_bundle' => 'article'] + ); + + $response = $this->createResponse('{"data":null}'); + + // The validator should be called *once* if schemata is *not* installed. + $validator = $this->prophesize(\JsonSchema\Validator::class); + $validator->check(Argument::any(), Argument::any())->shouldBeCalledTimes(1); + $validator->isValid()->willReturn(TRUE); + $this->subscriber->setValidator($validator->reveal()); + + // Run validations. + $this->subscriber->doValidateResponse($response, $request); + + // The validator should be called *twice* if schemata is installed. + $validator = $this->prophesize(\JsonSchema\Validator::class); + $validator->check(Argument::any(), Argument::any())->shouldBeCalledTimes(2); + $validator->isValid()->willReturn(TRUE); + $this->subscriber->setValidator($validator->reveal()); + + // Make the schemata factory available. + $schema_factory = $this->prophesize(SchemaFactory::class); + $schema_factory->create('node', 'article')->willReturn('{}'); + $this->subscriber->setSchemaFactory($schema_factory->reveal()); + + // Run validations. + $this->subscriber->doValidateResponse($response, $request); + + // The validator resource specific schema should *not* be validated on 'related' routes. + $request = $this->createRequest( + 'jsonapi.node--article.related', + '/jsonapi/node/article/{node}/foo', + ['_entity_type' => 'node', '_bundle' => 'article'] + ); + + // Since only the generic schema should be validated, the validator should only be called once. + $validator = $this->prophesize(\JsonSchema\Validator::class); + $validator->check(Argument::any(), Argument::any())->shouldBeCalledTimes(1); + $validator->isValid()->willReturn(TRUE); + $this->subscriber->setValidator($validator->reveal()); + + // Run validations. + $this->subscriber->doValidateResponse($response, $request); + + // The validator resource specific schema should *not* be validated on 'relationship' routes. + $request = $this->createRequest( + 'jsonapi.node--article.relationship', + '/jsonapi/node/article/{node}/relationships/foo', + ['_entity_type' => 'node', '_bundle' => 'article'] + ); + + // Since only the generic schema should be validated, the validator should only be called once. + $validator = $this->prophesize(\JsonSchema\Validator::class); + $validator->check(Argument::any(), Argument::any())->shouldBeCalledTimes(1); + $validator->isValid()->willReturn(TRUE); + $this->subscriber->setValidator($validator->reveal()); + + // Run validations. + $this->subscriber->doValidateResponse($response, $request); + } + + /** * @covers ::validateResponse * @dataProvider validateResponseProvider */