diff --git a/composer.json b/composer.json index 970aa03..6978862 100644 --- a/composer.json +++ b/composer.json @@ -2,9 +2,5 @@ "name": "drupal/jsonapi", "description": "Provides a JSON API standards-compliant API for accessing and manipulating Drupal content and configuration entities.", "type": "drupal-module", - "license": "GPL-2.0+", - "require-dev": { - "justinrainbow/json-schema": "^5.2", - "drupal/schemata": "1.x-dev#8325d172e1d6880aa24073f8f751ef089282cf9a" - } + "license": "GPL-2.0+" } diff --git a/jsonapi.info.yml b/jsonapi.info.yml index 9e77ac2..a333090 100644 --- a/jsonapi.info.yml +++ b/jsonapi.info.yml @@ -5,5 +5,3 @@ core: 8.x package: Web services dependencies: - drupal:serialization -test_dependencies: - - schemata:schemata_json_schema diff --git a/jsonapi.services.yml b/jsonapi.services.yml index 44309dc..8f8ac08 100644 --- a/jsonapi.services.yml +++ b/jsonapi.services.yml @@ -139,10 +139,6 @@ services: - { name: event_subscriber } arguments: ['@jsonapi.serializer_do_not_use_removal_imminent', '%serializer.formats%'] - logger.channel.jsonapi: - parent: logger.channel_base - arguments: ['jsonapi'] - # Cache. cache.jsonapi_resource_types: class: Drupal\Core\Cache\MemoryCache\MemoryCache @@ -205,14 +201,6 @@ services: arguments: ['@jsonapi.serializer_do_not_use_removal_imminent'] tags: - { name: event_subscriber } - jsonapi.resource_response_validator.subscriber: - class: Drupal\jsonapi\EventSubscriber\ResourceResponseValidator - arguments: ['@jsonapi.serializer_do_not_use_removal_imminent', '@logger.channel.jsonapi', '@module_handler', '@app.root'] - calls: - - [setValidator, []] - - [setSchemaFactory, ['@?schemata.schema_factory']] # This is only injected when the service is available. - tags: - - { name: event_subscriber, priority: 1000 } # Revision management. jsonapi.version_negotiator: diff --git a/schema.json b/schema.json deleted file mode 100644 index 902a39d..0000000 --- a/schema.json +++ /dev/null @@ -1,375 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "title": "JSON API Schema", - "description": "This is a schema for responses in the JSON API format. For more, see http://jsonapi.org", - "oneOf": [ - { - "$ref": "#/definitions/success" - }, - { - "$ref": "#/definitions/failure" - }, - { - "$ref": "#/definitions/info" - } - ], - - "definitions": { - "success": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "$ref": "#/definitions/data" - }, - "included": { - "description": "To reduce the number of HTTP requests, servers **MAY** allow responses that include related resources along with the requested primary resources. Such responses are called \"compound documents\".", - "type": "array", - "items": { - "$ref": "#/definitions/resource" - }, - "uniqueItems": true - }, - "meta": { - "$ref": "#/definitions/meta" - }, - "links": { - "description": "Link members related to the primary data.", - "allOf": [ - { - "$ref": "#/definitions/links" - }, - { - "$ref": "#/definitions/pagination" - } - ] - }, - "jsonapi": { - "$ref": "#/definitions/jsonapi" - } - }, - "additionalProperties": false - }, - "failure": { - "type": "object", - "required": [ - "errors" - ], - "properties": { - "errors": { - "type": "array", - "items": { - "$ref": "#/definitions/error" - }, - "uniqueItems": true - }, - "meta": { - "$ref": "#/definitions/meta" - }, - "jsonapi": { - "$ref": "#/definitions/jsonapi" - } - }, - "additionalProperties": false - }, - "info": { - "type": "object", - "required": [ - "meta" - ], - "properties": { - "meta": { - "$ref": "#/definitions/meta" - }, - "links": { - "$ref": "#/definitions/links" - }, - "jsonapi": { - "$ref": "#/definitions/jsonapi" - } - }, - "additionalProperties": false - }, - - "meta": { - "description": "Non-standard meta-information that can not be represented as an attribute or relationship.", - "type": "object", - "additionalProperties": true - }, - "data": { - "description": "The document's \"primary data\" is a representation of the resource or collection of resources targeted by a request.", - "oneOf": [ - { - "$ref": "#/definitions/resource" - }, - { - "description": "An array of resource objects, an array of resource identifier objects, or an empty array ([]), for requests that target resource collections.", - "type": "array", - "items": { - "$ref": "#/definitions/resource" - }, - "uniqueItems": true - }, - { - "description": "null if the request is one that might correspond to a single resource, but doesn't currently.", - "type": "null" - } - ] - }, - "resource": { - "description": "\"Resource objects\" appear in a JSON API document to represent resources.", - "type": "object", - "required": [ - "type", - "id" - ], - "properties": { - "type": { - "type": "string" - }, - "id": { - "type": "string" - }, - "attributes": { - "$ref": "#/definitions/attributes" - }, - "relationships": { - "$ref": "#/definitions/relationships" - }, - "links": { - "$ref": "#/definitions/links" - }, - "meta": { - "$ref": "#/definitions/meta" - } - }, - "additionalProperties": false - }, - - "links": { - "description": "A resource object **MAY** contain references to other resource objects (\"relationships\"). Relationships may be to-one or to-many. Relationships can be specified by including a member in a resource's links object.", - "type": "object", - "properties": { - "self": { - "description": "A `self` member, whose value is a URL for the relationship itself (a \"relationship URL\"). This URL allows the client to directly manipulate the relationship. For example, it would allow a client to remove an `author` from an `article` without deleting the people resource itself.", - "type": "string", - "format": "uri" - }, - "related": { - "$ref": "#/definitions/link" - } - }, - "additionalProperties": true - }, - "link": { - "description": "A link **MUST** be represented as either: a string containing the link's URL or a link object.", - "oneOf": [ - { - "description": "A string containing the link's URL.", - "type": "string", - "format": "uri" - }, - { - "type": "object", - "required": [ - "href" - ], - "properties": { - "href": { - "description": "A string containing the link's URL.", - "type": "string", - "format": "uri" - }, - "meta": { - "$ref": "#/definitions/meta" - } - } - } - ] - }, - - "attributes": { - "description": "Members of the attributes object (\"attributes\") represent information about the resource object in which it's defined.", - "type": "object", - "patternProperties": { - "^(?!relationships$|links$)\\w[-\\w_]*$": { - "description": "Attributes may contain any valid JSON value." - } - }, - "additionalProperties": false - }, - - "relationships": { - "description": "Members of the relationships object (\"relationships\") represent references from the resource object in which it's defined to other resource objects.", - "type": "object", - "patternProperties": { - "^\\w[-\\w_]*$": { - "properties": { - "links": { - "$ref": "#/definitions/links" - }, - "data": { - "description": "Member, whose value represents \"resource linkage\".", - "oneOf": [ - { - "$ref": "#/definitions/relationshipToOne" - }, - { - "$ref": "#/definitions/relationshipToMany" - } - ] - }, - "meta": { - "$ref": "#/definitions/meta" - } - }, - "anyOf": [ - {"required": ["data"]}, - {"required": ["meta"]}, - {"required": ["links"]} - ], - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "relationshipToOne": { - "description": "References to other resource objects in a to-one (\"relationship\"). Relationships can be specified by including a member in a resource's links object.", - "anyOf": [ - { - "$ref": "#/definitions/empty" - }, - { - "$ref": "#/definitions/linkage" - } - ] - }, - "relationshipToMany": { - "description": "An array of objects each containing \"type\" and \"id\" members for to-many relationships.", - "type": "array", - "items": { - "$ref": "#/definitions/linkage" - }, - "uniqueItems": true - }, - "empty": { - "description": "Describes an empty to-one relationship.", - "type": "null" - }, - "linkage": { - "description": "The \"type\" and \"id\" to non-empty members.", - "type": "object", - "required": [ - "type", - "id" - ], - "properties": { - "type": { - "type": "string" - }, - "id": { - "type": "string" - }, - "meta": { - "$ref": "#/definitions/meta" - } - }, - "additionalProperties": false - }, - "pagination": { - "type": "object", - "properties": { - "first": { - "description": "The first page of data", - "oneOf": [ - { "type": "string", "format": "uri" }, - { "type": "null" } - ] - }, - "last": { - "description": "The last page of data", - "oneOf": [ - { "type": "string", "format": "uri" }, - { "type": "null" } - ] - }, - "prev": { - "description": "The previous page of data", - "oneOf": [ - { "type": "string", "format": "uri" }, - { "type": "null" } - ] - }, - "next": { - "description": "The next page of data", - "oneOf": [ - { "type": "string", "format": "uri" }, - { "type": "null" } - ] - } - } - }, - - "jsonapi": { - "description": "An object describing the server's implementation", - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "meta": { - "$ref": "#/definitions/meta" - } - }, - "additionalProperties": false - }, - - "error": { - "type": "object", - "properties": { - "id": { - "description": "A unique identifier for this particular occurrence of the problem.", - "type": "string" - }, - "links": { - "$ref": "#/definitions/links" - }, - "status": { - "description": "The HTTP status code applicable to this problem, expressed as a string value.", - "type": "string" - }, - "code": { - "description": "An application-specific error code, expressed as a string value.", - "type": "string" - }, - "title": { - "description": "A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.", - "type": "string" - }, - "detail": { - "description": "A human-readable explanation specific to this occurrence of the problem.", - "type": "string" - }, - "source": { - "type": "object", - "properties": { - "pointer": { - "description": "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].", - "type": "string" - }, - "parameter": { - "description": "A string indicating which query parameter caused the error.", - "type": "string" - } - } - }, - "meta": { - "$ref": "#/definitions/meta" - } - }, - "additionalProperties": false - } - } -} diff --git a/src/EventSubscriber/ResourceResponseValidator.php b/src/EventSubscriber/ResourceResponseValidator.php deleted file mode 100644 index 6823498..0000000 --- a/src/EventSubscriber/ResourceResponseValidator.php +++ /dev/null @@ -1,258 +0,0 @@ -serializer = $serializer; - $this->logger = $logger; - $this->moduleHandler = $module_handler; - $this->appRoot = $app_root; - } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents() { - $events[KernelEvents::RESPONSE][] = ['onResponse']; - return $events; - } - - /** - * 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 $schema_factory - * The schema factory service. - */ - public function setSchemaFactory(SchemaFactory $schema_factory) { - $this->schemaFactory = $schema_factory; - } - - /** - * Validates JSON:API responses. - * - * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event - * The event to process. - */ - public function onResponse(FilterResponseEvent $event) { - $response = $event->getResponse(); - if (!$response instanceof ResourceResponse) { - return; - } - - $this->doValidateResponse($response, $event->getRequest()); - } - - /** - * Wraps validation in an assert to prevent execution in production. - * - * @see self::validateResponse - */ - public function doValidateResponse(Response $response, Request $request) { - if (PHP_MAJOR_VERSION >= 7 || assert_options(ASSERT_ACTIVE)) { - 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'); - } - } - - /** - * Validates a response against the JSON:API specification. - * - * @param \Symfony\Component\HttpFoundation\Response $response - * The response to validate. - * @param \Symfony\Component\HttpFoundation\Request $request - * The request containing info about what to validate. - * - * @return bool - * 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()); - if (empty($response_data)) { - return TRUE; - } - - $schema_ref = sprintf( - 'file://%s/schema.json', - implode('/', [ - $this->appRoot, - $this->moduleHandler->getModule('jsonapi')->getPath(), - ]) - ); - $generic_jsonapi_schema = (object) ['$ref' => $schema_ref]; - $is_valid = $this->validateSchema($generic_jsonapi_schema, $response_data); - if (!$is_valid) { - return FALSE; - } - - // This will be set if the schemata module is present. - if (!$this->schemaFactory) { - // Fall back the valid generic result since schemata is absent. - 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. - $resource_type = $request->get(Routes::RESOURCE_TYPE_KEY); - $route_name = $request->attributes->get(RouteObjectInterface::ROUTE_NAME); - - // We shouldn't validate related/relationships. - $is_related = strpos($route_name, '.related') !== FALSE; - $is_relationship = strpos($route_name, '.relationship') !== FALSE; - if ($is_related || $is_relationship) { - // Fall back the valid generic result since schemata is absent. - return TRUE; - } - - $entity_type_id = $resource_type->getEntityTypeId(); - $bundle = $resource_type->getBundle(); - $output_format = 'schema_json'; - $described_format = 'api_json'; - - $schema_object = $this->schemaFactory->create($entity_type_id, $bundle); - $format = $output_format . ':' . $described_format; - $output = $this->serializer->serialize($schema_object, $format); - $specific_schema = Json::decode($output); - if (!$specific_schema) { - return $is_valid; - } - - // 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 - ); - } - - /** - * Validates a string against a JSON Schema. It logs any possible errors. - * - * @param object $schema - * The JSON Schema object. - * @param string $response_data - * The JSON string to validate. - * - * @return bool - * TRUE if the string is a valid instance of the schema. FALSE otherwise. - */ - protected function validateSchema($schema, $response_data) { - $this->validator->check($response_data, $schema); - $is_valid = $this->validator->isValid(); - if (!$is_valid) { - $this->logger->debug("Response failed validation.\nResponse:\n@data\n\nErrors:\n@errors", [ - '@data' => Json::encode($response_data), - '@errors' => Json::encode($this->validator->getErrors()), - ]); - } - return $is_valid; - } - -} diff --git a/tests/src/Unit/EventSubscriber/ResourceResponseValidatorTest.php b/tests/src/Unit/EventSubscriber/ResourceResponseValidatorTest.php deleted file mode 100644 index e3dfba6..0000000 --- a/tests/src/Unit/EventSubscriber/ResourceResponseValidatorTest.php +++ /dev/null @@ -1,319 +0,0 @@ -fail('The JSON Schema validator is missing. You can install it with `composer require justinrainbow/json-schema`.'); - } - - $module_handler = $this->prophesize(ModuleHandlerInterface::class); - $module = $this->prophesize(Extension::class); - $module_path = dirname(dirname(dirname(dirname(__DIR__)))); - $module->getPath()->willReturn($module_path); - $module_handler->getModule('jsonapi')->willReturn($module->reveal()); - $encoders = [new JsonEncoder()]; - if (class_exists(JsonSchemaEncoder::class)) { - $encoders[] = new JsonSchemaEncoder(); - } - $subscriber = new ResourceResponseValidator( - new Serializer([], $encoders), - $this->prophesize(LoggerInterface::class)->reveal(), - $module_handler->reveal(), - '' - ); - $subscriber->setValidator(); - $this->subscriber = $subscriber; - } - - /** - * @covers ::doValidateResponse - */ - public function testDoValidateResponse() { - $request = $this->createRequest( - 'jsonapi.node--article.individual', - new ResourceType('node', 'article', NULL) - ); - - $response = $this->createResponse('{"data":null}'); - - // Capture the default assert settings. - $zend_assertions_default = ini_get('zend.assertions'); - $assert_active_default = assert_options(ASSERT_ACTIVE); - - // The validator *should* be called when asserts are active. - $validator = $this->prophesize(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); - assert_options(ASSERT_ACTIVE, 1); - $this->subscriber->doValidateResponse($response, $request); - - // The validator should *not* be called when asserts are inactive. - $validator = $this->prophesize(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); - assert_options(ASSERT_ACTIVE, 0); - $this->subscriber->doValidateResponse($response, $request); - - // Reset the original assert values. - ini_set('zend.assertions', $zend_assertions_default); - assert_options(ASSERT_ACTIVE, $assert_active_default); - } - - /** - * @covers ::onResponse - * @requires function Drupal\schemata\SchemaFactory::__construct - */ - public function testValidateResponseSchemata() { - $request = $this->createRequest( - 'jsonapi.node--article.individual', - new ResourceType('node', 'article', NULL) - ); - - $response = $this->createResponse('{"data":null}'); - - // The validator should be called *once* if schemata is *not* installed. - $validator = $this->prophesize(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(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', - new ResourceType('node', 'article', NULL) - ); - - // Since only the generic schema should be validated, the validator should - // only be called once. - $validator = $this->prophesize(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', - new ResourceType('node', 'article', NULL) - ); - - // Since only the generic schema should be validated, the validator should - // only be called once. - $validator = $this->prophesize(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 - */ - public function testValidateResponse($request, $response, $expected, $description) { - // Expose protected ResourceResponseSubscriber::validateResponse() method. - $object = new \ReflectionObject($this->subscriber); - $method = $object->getMethod('validateResponse'); - $method->setAccessible(TRUE); - - $this->assertSame($expected, $method->invoke($this->subscriber, $response, $request), $description); - } - - /** - * Provides test cases for testValidateResponse. - * - * @return array - * An array of test cases. - */ - public function validateResponseProvider() { - $defaults = [ - 'route_name' => 'jsonapi.node--article.individual', - 'resource_type' => new ResourceType('node', 'article', NULL), - ]; - - $test_data = [ - // Test validation success. - [ - 'json' => <<<'EOD' -{ - "data": { - "type": "node--article", - "id": "4f342419-e668-4b76-9f87-7ce20c436169", - "attributes": { - "nid": "1", - "uuid": "4f342419-e668-4b76-9f87-7ce20c436169" - } - } -} -EOD - , - 'expected' => TRUE, - 'description' => 'Response validation flagged a valid response.', - ], - // Test validation failure: no "type" in "data". - [ - 'json' => <<<'EOD' -{ - "data": { - "id": "4f342419-e668-4b76-9f87-7ce20c436169", - "attributes": { - "nid": "1", - "uuid": "4f342419-e668-4b76-9f87-7ce20c436169" - } - } -} -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", - "id": "4f342419-e668-4b76-9f87-7ce20c436169", - "attributes": { - "nid": "1", - "uuid": "4f342419-e668-4b76-9f87-7ce20c436169" - } - }, - "errors": [{}] -} -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, $resource_type) = array_values($input + $defaults); - return [ - $this->createRequest($route_name, $resource_type), - $this->createResponse($json), - $expected, - $description, - ]; - }, $test_data); - - return $test_cases; - } - - /** - * Helper method to create a request object. - * - * @param string $route_name - * The route name with which to construct a request. - * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type - * The resource type for the requested route. - * - * @return \Symfony\Component\HttpFoundation\Request - * The mock request object. - */ - protected function createRequest($route_name, ResourceType $resource_type) { - $request = new Request(); - $request->attributes->set(RouteObjectInterface::ROUTE_NAME, $route_name); - $request->attributes->set(Routes::RESOURCE_TYPE_KEY, $resource_type); - return $request; - } - - /** - * Helper method to create a resource response from arbitrary JSON. - * - * @param string|null $json - * The JSON with which to create a mock response. - * - * @return \Drupal\rest\ResourceResponse - * The mock response object. - */ - protected function createResponse($json = NULL) { - $response = new ResourceResponse(); - if ($json) { - $response->setContent($json); - } - return $response; - } - -}