diff -u b/jsonapi.services.yml b/jsonapi.services.yml --- b/jsonapi.services.yml +++ b/jsonapi.services.yml @@ -134,6 +134,9 @@ tags: - { name: event_subscriber } - arguments: ['@serializer', '@renderer', '@logger.channel.jsonapi', '@module_handler'] + arguments: ['@serializer', '@renderer', '@logger.channel.jsonapi'] + calls: + - [setValidator, []] + - [setSchemaFactory, ['@?schemata.schema_factory']] # 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,7 +2,7 @@ namespace Drupal\jsonapi\EventSubscriber; -use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\schemata\SchemaFactory; use JsonSchema\Validator; use Drupal\Component\Serialization\Json; use Drupal\Core\Cache\CacheableResponse; @@ -23,6 +23,7 @@ * Response subscriber that serializes and removes ResourceResponses' data. * * @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber + * @internal * * This is 99% identical to \Drupal\rest\EventSubscriber\ResourceResponseSubscriber * but with a few differences: @@ -60,11 +61,22 @@ protected $logger; /** - * The module handler. + * The schema validator. * - * @var \Drupal\Core\Extension\ModuleHandlerInterface + * This property will only be set if the validator library is available. + * + * @var \JsonSchema\Validator|NULL */ - protected $moduleHandler; + protected $validator; + + /** + * The schemata schema factory. + * + * This property will only be set if the schemata module is installed. + * + * @var \Drupal\schemata\SchemaFactory|NULL + */ + protected $schemaFactory; /** * Constructs a ResourceResponseSubscriber object. @@ -78,11 +90,31 @@ * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler * The module handler. */ - public function __construct(SerializerInterface $serializer, RendererInterface $renderer, LoggerInterface $logger, ModuleHandlerInterface $module_handler) { + public function __construct(SerializerInterface $serializer, RendererInterface $renderer, LoggerInterface $logger) { $this->serializer = $serializer; $this->renderer = $renderer; $this->logger = $logger; - $this->moduleHandler = $module_handler; + } + + /** + * Sets the validator service if available. + */ + public function setValidator(Validator $validator = NULL) { + if ($validator) { + $this->validator = $validator; + } + elseif (class_exists(Validator::class)) { + $this->validator = new Validator(); + } + } + + /** + * Injects the schema factory. + * + * @param \Drupal\schemata\SchemaFactory + */ + public function setSchemaFactory(SchemaFactory $schema_factory) { + $this->schemaFactory = $schema_factory; } /** @@ -102,6 +134,15 @@ $this->renderResponseBody($request, $response, $this->serializer, $format); $event->setResponse($this->flattenResponse($response)); + $this->doValidateResponse($response, $request); + } + + /** + * Wraps validation in an assert to prevent execution in production. + * + * @see self::validateResponse + */ + public function doValidateResponse(Response $response, Request $request) { assert($this->validateResponse($response, $request), 'A JSON API response failed validation (see the logs for details). Please report this in the issue queue on drupal.org'); } @@ -190,7 +231,7 @@ * FALSE if the response failed validation, otherwise TRUE. */ protected function validateResponse(Response $response, Request $request) { - if (!class_exists(Validator::class)) { + if (!$this->validator) { return TRUE; } // Do not use Json::decode here since it coerces the response into an @@ -207,29 +248,31 @@ return FALSE; } - if (!$this->moduleHandler->moduleExists('schemata_json_schema')) { + if (!$this->schemaFactory) { return TRUE; } // 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); - // Check if the response is for a collection. We need to validate each - // resource object against the schema. - $is_collection = strpos( - $request->attributes->get(RouteObjectInterface::ROUTE_NAME), - '.collection' - ) !== FALSE; + $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. + $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; + } $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'); - $serializer = \Drupal::service('serializer'); $generic_jsonapi_schema = $schema_factory->create($entity_type_id, $bundle); $format = $output_format . ':' . $described_format; - $output = $serializer->serialize($generic_jsonapi_schema, $format); + $output = $this->serializer->serialize($generic_jsonapi_schema, $format); $specific_schema = json_decode($output); // Iterate over each resource object and check the schema. @@ -254,16 +297,14 @@ * TRUE if the string is a valid instance of the schema. FALSE otherwise. */ protected function validateSchema($schema, $response_data) { - $validator = new Validator(); - - $validator->check($response_data, $schema); - $is_valid = $validator->isValid(); + $this->validator->check($response_data, $schema); + $is_valid = $this->validator->isValid(); if (!$is_valid) { $this->logger->debug('Response failed validation: @data', [ '@data' => Json::encode($response_data), ]); $this->logger->debug('Validation errors: @errors', [ - '@errors' => Json::encode($validator->getErrors()), + '@errors' => Json::encode($this->validator->getErrors()), ]); } return $is_valid; 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 @@ -2,11 +2,11 @@ namespace Drupal\Tests\jsonapi\Unit\EventSubscriber; -use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Render\RendererInterface; use Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber; use Drupal\rest\ResourceResponse; use Drupal\Tests\UnitTestCase; +use Prophecy\Argument; use Psr\Log\LoggerInterface; use Symfony\Cmf\Component\Routing\RouteObjectInterface; use Symfony\Component\HttpFoundation\Request; @@ -20,34 +20,95 @@ class ResourceResponseSubscriberTest extends UnitTestCase { /** - * @covers ::validateResponse + * The subscriber under test. + * + * @var \Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber + */ + protected $subscriber; + + /** + * {@inheritdoc} */ - public function testValidateResponse() { - $module_handler = $this->prophesize(ModuleHandlerInterface::class); - $module_handler->moduleExists('schemata_json_schema') - ->willReturn(FALSE); - $resource_response_subscriber = new ResourceResponseSubscriber( + public function setUp() { + // Check that the validation class is available. + if (!class_exists("\\JsonSchema\\Validator")) { + $this->fail('The JSON Schema validator is missing. You can install it with `composer require justinrainbow/json-schema`.'); + } + + $subscriber = new ResourceResponseSubscriber( $this->prophesize(Serializer::class)->reveal(), $this->prophesize(RendererInterface::class)->reveal(), - $this->prophesize(LoggerInterface::class)->reveal(), - $module_handler->reveal() + $this->prophesize(LoggerInterface::class)->reveal() ); + $subscriber->setValidator(); + $this->subscriber = $subscriber; + } - // Check that the validation class is enabled. - $this->assertTrue( - class_exists("\\JsonSchema\\Validator"), - 'The JSON Schema validator is not present. Please make sure to install it using composer.' - ); + /** + * @covers ::onResponse + */ + public function testOnResponse() { + $request = $this->createRequest( + 'jsonapi.node--article.individual', + '/jsonapi/node/article/{node}', + ['_entity_type' => 'node', '_bundle' => 'article'] + ); + + $response = $this->createResponse('{"data":null}'); + + // Capture the default assert settings. + $assert_default = ini_get('zend.assertions'); + + // The validator *should* be called when asserts are active. + $validator = $this->prophesize(\JsonSchema\Validator::class); + $validator->check(Argument::any(), Argument::any())->shouldBeCalled('Validation should be run when asserts are active.'); + $validator->isValid()->willReturn(TRUE); + $this->subscriber->setValidator($validator->reveal()); + + // Ensure asset is active. + ini_set('zend.assertions', 1); + $this->subscriber->doValidateResponse($response, $request); + + // The validator should *not* be called when asserts are inactive. + $validator = $this->prophesize(\JsonSchema\Validator::class); + $validator->check(Argument::any(), Argument::any())->shouldNotBeCalled('Validation should not be run when asserts are not active.'); + $this->subscriber->setValidator($validator->reveal()); + + // Ensure asset is inactive. + ini_set('zend.assertions', 0); + $this->subscriber->doValidateResponse($response, $request); + + // Reset the original assert values. + ini_set('zend.assertions', $assert_default); + } + /** + * @covers ::validateResponse + * @dataProvider validateResponseProvider + */ + public function testValidateResponse($request, $response, $expected, $description) { // Expose protected ResourceResponseSubscriber::validateResponse() method. - $object = new \ReflectionObject($resource_response_subscriber); - $validate_response = $object->getMethod('validateResponse'); - $validate_response->setAccessible(TRUE); + $object = new \ReflectionObject($this->subscriber); + $method = $object->getMethod('validateResponse'); + $method->setAccessible(TRUE); - // Test validation failure: no "type" in "data". - $json = <<<'EOD' + $this->assertSame($expected, $method->invoke($this->subscriber, $response, $request), $description); + } + + public function validateResponseProvider() { + $defaults = [ + 'route_name' => 'jsonapi.node--article.individual', + 'route' => '/jsonapi/node/article/{node}', + 'requirements' => ['_entity_type' => 'node', '_bundle' => 'article'], + ]; + + $test_data = [ + // Test validation success. + [ + 'json' => <<<'EOD' { "data": { + "type": "node--article", "id": "4f342419-e668-4b76-9f87-7ce20c436169", "attributes": { "nid": "1", @@ -55,67 +116,102 @@ } } } -EOD; - $request = new Request(); - $request->attributes->set(RouteObjectInterface::ROUTE_NAME, 'jsonapi.node--article.individual'); - $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, (new Route('/jsonapi/node/article/{node}'))->setRequirements([ - '_entity_type' => 'node', - '_bundle' => 'article', - ])); - $response = new ResourceResponse(); - $response->setContent($json); - $this->assertFalse( - $validate_response->invoke($resource_response_subscriber, $response, $request), - 'Response validation failed to flag an invalid response.' - ); - - // Test validation failure: no "data" and "errors" at the root level. - $json = <<<'EOD' +EOD + , + 'expected' => TRUE, + 'description' => 'Response validation flagged a valid response.', + ], + // Test validation failure: no "type" in "data". + [ + 'json' => <<<'EOD' { "data": { - "type": "node--article", "id": "4f342419-e668-4b76-9f87-7ce20c436169", "attributes": { "nid": "1", "uuid": "4f342419-e668-4b76-9f87-7ce20c436169" } - }, - "errors": [{}] + } } -EOD; - $response = new ResourceResponse(); - $response->setContent($json); - $this->assertFalse( - $validate_response->invoke($resource_response_subscriber, $response, $request), - 'Response validation failed to flag an invalid response.' - ); - - // Test validation success. - $json = <<<'EOD' +EOD + , + 'expected' => FALSE, + 'description' => 'Response validation failed to flag an invalid response.', + ], + // Test validation failure: "errors" at the root level. + [ + 'json' => <<<'EOD' { "data": { - "type": "node--article", + "type": "node--article", "id": "4f342419-e668-4b76-9f87-7ce20c436169", "attributes": { - "nid": "1", + "nid": "1", "uuid": "4f342419-e668-4b76-9f87-7ce20c436169" } - } + }, + "errors": [{}] } -EOD; - $response->setContent($json); - $this->assertTrue( - $validate_response->invoke($resource_response_subscriber, $response, $request), - 'Response validation flagged a valid response.' - ); +EOD + , + 'expected' => FALSE, + 'description' => 'Response validation failed to flag an invalid response.', + ], + // Test validation of an empty response passes. + [ + 'json' => NULL, + 'expected' => TRUE, + 'description' => 'Response validation flagged a valid empty response.', + ], + // Test validation fails on empty object. + [ + 'json' => '{}', + 'expected' => FALSE, + 'description' => 'Response validation flags empty array as invalid.', + ], + ]; + + $test_cases = array_map(function ($input) use ($defaults) { + list($json, $expected, $description, $route_name, $route, $requirements) = array_values($input + $defaults); + return [ + $this->createRequest($route_name, $route, $requirements), + $this->createResponse($json), + $expected, + $description, + ]; + }, $test_data); - // Test validation of an empty response passes. - $response = new ResourceResponse(); - $this->assertTrue( - $validate_response->invoke($resource_response_subscriber, $response, $request), - 'Response validation flagged a valid empty response.' - ); + return $test_cases; + } + /** + * Helper method to create a request object. + * + * @param $route_name string + * @param $route string + * @param $requirements array + * + * @return \Symfony\Component\HttpFoundation\Request + */ + protected function createRequest($route_name, $route, array $requirements = []) { + $request = new Request(); + $request->attributes->set(RouteObjectInterface::ROUTE_NAME, $route_name); + $request->attributes->set(RouteObjectInterface::ROUTE_OBJECT, (new Route($route))->setRequirements($requirements)); + return $request; + } + + /** + * Helper method to create a resource response from arbitrary JSON. + * + * @param $json string|NULL + * @return \Drupal\rest\ResourceResponse + */ + protected function createResponse($json = NULL) { + $response = new ResourceResponse(); + if ($json) { + $response->setContent($json); + } + return $response; } }