diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt index 0739fb0..b831c19 100644 --- a/core/MAINTAINERS.txt +++ b/core/MAINTAINERS.txt @@ -243,6 +243,9 @@ Installer Interface Translation (locale) - Gábor Hojtsy 'Gábor Hojtsy' https://www.drupal.org/u/gábor-hojtsy +JSON-API +- Mateu Aguiló Bosch 'e0ipso' https://www.drupal.org/u/e0ipso + JavaScript - Théodore Biadala 'nod_' https://www.drupal.org/u/nod_ - Kay Leung 'droplet' https://www.drupal.org/u/droplet diff --git a/core/composer.json b/core/composer.json index 71f07d0..4efe97e 100644 --- a/core/composer.json +++ b/core/composer.json @@ -104,6 +104,7 @@ "drupal/history": "self.version", "drupal/image": "self.version", "drupal/inline_form_errors": "self.version", + "drupal/jsonapi": "self.version", "drupal/language": "self.version", "drupal/layout_discovery": "self.version", "drupal/link": "self.version", diff --git a/core/modules/jsonapi/README.md b/core/modules/jsonapi/README.md new file mode 100644 index 0000000..ab01a11 --- /dev/null +++ b/core/modules/jsonapi/README.md @@ -0,0 +1,24 @@ +# JSON API +The jsonapi module exposes a [JSON API](http://jsonapi.org/) implementation for data stored in Drupal. + +## Installation + +Install the module as every other module. + +## Compatibility + +This module is compatible with Drupal core 8.2.x and higher. + +## Configuration + +Unlike the core REST module JSON API doesn't really require any kind of configuration by default. + +## Usage + +The jsonapi module exposes both config and content entity resources. On top of that it exposes one resource per bundle per entity. The default format appears like: `/jsonapi/{entity_type}/{bundle}/{uuid}?_format=api_json` + +The list of endpoints then looks like the following: +* `/jsonapi/node/article?_format=api_json`: Exposes a collection of article content +* `/jsonapi/node/article/{UUID}?_format=api_json`: Exposes an individual article +* `/jsonapi/block?_format=api_json`: Exposes a collection of blocks +* `/jsonapi/block/{block}?_format=api_json`: Exposes an individual block diff --git a/core/modules/jsonapi/jsonapi.info.yml b/core/modules/jsonapi/jsonapi.info.yml new file mode 100644 index 0000000..36bb15c --- /dev/null +++ b/core/modules/jsonapi/jsonapi.info.yml @@ -0,0 +1,8 @@ +name: JSON API +type: module +description: Provides a JSON API format for the REST resources. +core: 8.x +package: Web services +dependencies: + - drupal:system (>=8.2) + - serialization diff --git a/core/modules/jsonapi/jsonapi.module b/core/modules/jsonapi/jsonapi.module new file mode 100644 index 0000000..70648d2 --- /dev/null +++ b/core/modules/jsonapi/jsonapi.module @@ -0,0 +1,65 @@ +' . t('About') . ''; + $youtube_url = Url::fromUri('https://www.youtube.com/playlist?list=PLZOQ_ZMpYrZsyO-3IstImK1okrpfAjuMZ', [ + 'external' => TRUE, + ]); + $youtube_link = Link::fromTextAndUrl(t('online documentation for the JSON API module'), $youtube_url); + $spec_url = Url::fromUri('http://jsonapi.org', ['external' => TRUE]); + $spec_link = Link::fromTextAndUrl(t('JSON API Specification'), $spec_url); + $output .= '

' . t('The JSON API module is a fully compliant implementation of the @spec. By following shared conventions, you can increase productivity, take advantage of generalized tooling, and focus on what matters: your application. Clients built around JSON API are able to take advantage of its features such as efficiently caching responses, sometimes eliminating network requests entirely. For more information, see the @online.', [ + '@spec' => $spec_link->toString(), + '@online' => $youtube_link->toString(), + ]) . '

'; + $output .= '
'; + $output .= '
' . t('General') . '
'; + $output .= '
' . t('JSON API is a particular implementation of REST that provides conventions for resource relationships, collections, filters, pagination, and sorting, in addition to error handling and full test coverage. These conventions help developers build clients faster and encourages reuse of code.') . '
'; + $output .= '
'; + + return $output; + } + return NULL; +} + +/** + * Implements hook_entity_base_field_info(). + * + * @todo This should probably live in core, but for now we will keep it as a + * temporary solution. There are similar unresolved efforts already happening + * there. + * + * @see https://www.drupal.org/node/2825487 + */ +function jsonapi_entity_base_field_info(EntityTypeInterface $entity_type) { + $fields = []; + if ($entity_type->id() == 'file') { + $fields['url'] = BaseFieldDefinition::create('uri') + ->setLabel(t('Download URL')) + ->setDescription(t('The download URL of the file.')) + ->setComputed(TRUE) + ->setQueryable(FALSE) + ->setClass('\Drupal\jsonapi\Field\FileDownloadUrl') + ->setDisplayOptions('view', array( + 'label' => 'above', + 'weight' => -5, + )); + } + return $fields; +} diff --git a/core/modules/jsonapi/jsonapi.routing.yml b/core/modules/jsonapi/jsonapi.routing.yml new file mode 100644 index 0000000..9a9ab7d --- /dev/null +++ b/core/modules/jsonapi/jsonapi.routing.yml @@ -0,0 +1,2 @@ +route_callbacks: + - '\Drupal\jsonapi\Routing\Routes::routes' \ No newline at end of file diff --git a/core/modules/jsonapi/jsonapi.services.yml b/core/modules/jsonapi/jsonapi.services.yml new file mode 100644 index 0000000..2804940 --- /dev/null +++ b/core/modules/jsonapi/jsonapi.services.yml @@ -0,0 +1,98 @@ +services: + serializer.normalizer.entity_reference_item.jsonapi: + class: Drupal\jsonapi\Normalizer\RelationshipItemNormalizer + arguments: ['@jsonapi.resource_type.repository', '@serializer.normalizer.jsonapi_document_toplevel.jsonapi',] + tags: + - { name: normalizer, priority: 21 } + serializer.normalizer.field_item.jsonapi: + class: Drupal\jsonapi\Normalizer\FieldItemNormalizer + tags: + - { name: normalizer, priority: 21 } + serializer.normalizer.scalar.jsonapi: + class: Drupal\jsonapi\Normalizer\ScalarNormalizer + tags: + - { name: normalizer, priority: 5 } + serializer.normalizer.field.jsonapi: + class: Drupal\jsonapi\Normalizer\FieldNormalizer + tags: + - { name: normalizer, priority: 21 } + serializer.normalizer.entity_reference_field.jsonapi: + class: Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer + arguments: ['@jsonapi.link_manager', '@entity_field.manager', '@plugin.manager.field.field_type', '@jsonapi.resource_type.repository', '@entity.repository'] + tags: + - { name: normalizer, priority: 31 } + serializer.normalizer.relationship.jsonapi: + class: Drupal\jsonapi\Normalizer\RelationshipNormalizer + arguments: ['@jsonapi.resource_type.repository', '@jsonapi.link_manager'] + tags: + - { name: normalizer, priority: 21 } + serializer.normalizer.entity.jsonapi: + class: Drupal\jsonapi\Normalizer\ContentEntityNormalizer + arguments: ['@jsonapi.link_manager', '@jsonapi.resource_type.repository', '@entity_type.manager'] + tags: + - { name: normalizer, priority: 21 } + serializer.normalizer.config_entity.jsonapi: + class: Drupal\jsonapi\Normalizer\ConfigEntityNormalizer + arguments: ['@jsonapi.link_manager', '@jsonapi.resource_type.repository', '@entity_type.manager'] + tags: + - { name: normalizer, priority: 21 } + serializer.normalizer.jsonapi_document_toplevel.jsonapi: + class: Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer + arguments: ['@jsonapi.link_manager', '@jsonapi.current_context', '@entity_type.manager'] + tags: + - { name: normalizer, priority: 22 } + serializer.normalizer.htt_exception.jsonapi: + class: Drupal\jsonapi\Normalizer\HttpExceptionNormalizer + arguments: ['@current_user'] + tags: + - { name: normalizer, priority: 1 } + serializer.normalizer.unprocessable_entity_exception.jsonapi: + class: Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer + arguments: ['@current_user'] + tags: + - { name: normalizer, priority: 2 } + serializer.encoder.jsonapi: + class: Drupal\jsonapi\Encoder\JsonEncoder + tags: + - { name: encoder, priority: 21, format: 'api_json' } + jsonapi.resource_type.repository: + class: Drupal\jsonapi\ResourceType\ResourceTypeRepository + arguments: ['@entity_type.manager', '@entity_type.bundle.info'] + jsonapi.route_enhancer: + class: Drupal\jsonapi\Routing\RouteEnhancer + tags: + - { name: route_enhancer } + jsonapi.params.enhancer: + class: Drupal\jsonapi\Routing\JsonApiParamEnhancer + arguments: ['@entity_field.manager'] + tags: + - { name: route_enhancer } + jsonapi.query_builder: + class: Drupal\jsonapi\Query\QueryBuilder + arguments: ['@entity_type.manager', '@jsonapi.current_context', '@jsonapi.field_resolver'] + jsonapi.link_manager: + class: Drupal\jsonapi\LinkManager\LinkManager + arguments: ['@router.no_access_checks', '@url_generator'] + jsonapi.current_context: + class: Drupal\jsonapi\Context\CurrentContext + arguments: ['@jsonapi.resource_type.repository', '@request_stack', '@current_route_match'] + jsonapi.field_resolver: + class: Drupal\jsonapi\Context\FieldResolver + arguments: ['@jsonapi.current_context', '@entity_field.manager'] + access_check.jsonapi.custom_parameter_names: + class: Drupal\jsonapi\Access\CustomParameterNames + tags: + - { name: access_check, applies_to: _custom_parameter_names } + paramconverter.jsonapi.entity_uuid: + class: Drupal\jsonapi\ParamConverter\EntityUuidConverter + tags: + # Priority 10, to ensure it runs before @paramconverter.entity. + - { name: paramconverter, priority: 10 } + arguments: ['@entity.manager'] + jsonapi.error_handler: + class: Drupal\jsonapi\Error\ErrorHandler + jsonapi.exception_subscriber: + class: Drupal\jsonapi\EventSubscriber\DefaultExceptionSubscriber + tags: + - { name: event_subscriber } + arguments: ['@serializer', '%serializer.formats%'] diff --git a/core/modules/jsonapi/phpcs.xml b/core/modules/jsonapi/phpcs.xml new file mode 100644 index 0000000..686fca8 --- /dev/null +++ b/core/modules/jsonapi/phpcs.xml @@ -0,0 +1,14 @@ + + + Default PHP CodeSniffer configuration for RESTful. + . + + + + + + + + + + diff --git a/core/modules/jsonapi/src/Access/CustomParameterNames.php b/core/modules/jsonapi/src/Access/CustomParameterNames.php new file mode 100644 index 0000000..7a89c56 --- /dev/null +++ b/core/modules/jsonapi/src/Access/CustomParameterNames.php @@ -0,0 +1,59 @@ +attributes->get('_json_api_params', []); + if (!$this->validate($json_api_params)) { + return AccessResult::forbidden(); + } + return AccessResult::allowed(); + } + + /** + * Validates the JSONAPI parameters. + * + * @param string[] $json_api_params + * The JSONAPI parameters. + * + * @return bool + */ + protected function validate(array $json_api_params) { + $valid = TRUE; + + foreach (array_keys($json_api_params) as $name) { + if (strpbrk($name, '+,.[]!”#$%&’()*/:;<=>?@^`{}~|')) { + $valid = FALSE; + break; + } + + if (strpbrk($name[0], '-_ ') || strpbrk($name[strlen($name) - 1], '-_ ')) { + $valid = FALSE; + break; + } + } + + return $valid; + } + +} diff --git a/core/modules/jsonapi/src/Context/CurrentContext.php b/core/modules/jsonapi/src/Context/CurrentContext.php new file mode 100644 index 0000000..bc3370d --- /dev/null +++ b/core/modules/jsonapi/src/Context/CurrentContext.php @@ -0,0 +1,142 @@ +resourceTypeRepository = $resource_type_repository; + $this->requestStack = $request_stack; + $this->routeMatch = $route_match; + } + + /** + * Gets the JSON API resource type for the current request. + * + * @return \Drupal\jsonapi\ResourceType\ResourceType + * The JSON API resource type for the current request. + */ + public function getResourceType() { + if (!isset($this->resourceType)) { + $route = $this->routeMatch->getRouteObject(); + $entity_type_id = $route->getRequirement('_entity_type'); + $bundle = $route->getRequirement('_bundle'); + $this->resourceType = $this->resourceTypeRepository + ->get($entity_type_id, $bundle); + } + + return $this->resourceType; + } + + /** + * Checks if the request is on a relationship. + * + * @return bool + * TRUE if the request is on a relationship. FALSE otherwise. + */ + public function isOnRelationship() { + return (bool) $this->routeMatch + ->getRouteObject() + ->getDefault('_on_relationship'); + } + + /** + * Get a value by key from the _json_api_params route parameter. + * + * @param string $parameter_key + * The key by which to retrieve a route parameter. + * + * @return mixed + * The JSON API provided parameter. + */ + public function getJsonApiParameter($parameter_key) { + $params = $this + ->requestStack + ->getCurrentRequest() + ->attributes + ->get('_json_api_params'); + return (isset($params[$parameter_key])) ? $params[$parameter_key] : NULL; + } + + /** + * Determines, whether the JSONAPI extension was requested. + * + * @todo Find a better place for such a JSONAPI derived information. + * + * @param string $extension_name + * The extension name. + * + * @return bool + * Returns TRUE, if the extension has been found. + */ + public function hasExtension($extension_name) { + return in_array($extension_name, $this->getExtensions()); + } + + /** + * Returns a list of requested extensions. + * + * @return string[] + * The extension names. + */ + public function getExtensions() { + $content_type_header = $this + ->requestStack + ->getCurrentRequest() + ->headers + ->get('Content-Type'); + if (preg_match('/ext="([^"]+)"/i', $content_type_header, $match)) { + $extensions = array_map('trim', explode(',', $match[1])); + return $extensions; + } + return []; + } + +} diff --git a/core/modules/jsonapi/src/Context/FieldResolver.php b/core/modules/jsonapi/src/Context/FieldResolver.php new file mode 100644 index 0000000..b2adcd3 --- /dev/null +++ b/core/modules/jsonapi/src/Context/FieldResolver.php @@ -0,0 +1,113 @@ +currentContext = $current_context; + $this->fieldManager = $field_manager; + } + + /** + * Maps a Drupal field name to a public field name. + * + * Example: + * 'field_author.entity.field_first_name' -> 'author.firstName'. + * + * @param string $field_name + * The Drupal field name to map to a public field name. + * + * @return string + * The mapped field name. + */ + public function resolveExternal($internal_field_name) { + // Yet to be implemented. + return $internal_field_name; + } + + /** + * Maps a public field name to a Drupal field name. + * + * Example: + * 'author.firstName' -> 'field_author.entity.field_first_name'. + * + * @param string $field_name + * The public field name to map to a Drupal field name. + * + * @return string + * The mapped field name. + */ + public function resolveInternal($external_field_name) { + if (empty($external_field_name)) { + throw new SerializableHttpException(400, 'No field name was provided for the filter.'); + } + // Right now we are exposing all the fields with the name they have in + // the Drupal backend. But this may change in the future. + if (strpos($external_field_name, '.') === FALSE) { + return $external_field_name; + } + // Turns 'uid.field_category.name' into + // 'uid.entity.field_category.entity.name'. This may be too simple, but it + // works for the time being. + $parts = explode('.', $external_field_name); + $entity_type_id = $this->currentContext->getResourceType()->getEntityTypeId(); + $reference_breadcrumbs = []; + while ($field_name = array_shift($parts)) { + if (!$definitions = $this->fieldManager->getFieldStorageDefinitions($entity_type_id)) { + throw new SerializableHttpException(400, sprintf('Invalid nested filtering. There is no entity type "%s".', $entity_type_id)); + } + if (empty($definitions[$field_name])) { + throw new SerializableHttpException(400, sprintf('Invalid nested filtering. Invalid entity reference "%s".', $field_name)); + } + array_push($reference_breadcrumbs, $field_name); + // Update the entity type with the referenced type. + $entity_type_id = $definitions[$field_name]->getSetting('target_type'); + // $field_name may not be a reference field. In that case we should treat + //the rest of the parts as complex fields. + if (empty($entity_type_id)) { + // This is the path from the initial entity type to the entity type that + // contains $field_name. This path is a set of entity references. + $entity_path = implode('.entity.', $reference_breadcrumbs); + // This is the path from the final entity type to the selected field + //column. + $field_path = implode('.', $parts); + + return implode('.', array_filter([$entity_path, $field_path])); + } + } + + return implode('.entity.', $reference_breadcrumbs); + } + +} diff --git a/core/modules/jsonapi/src/Controller/EntityResource.php b/core/modules/jsonapi/src/Controller/EntityResource.php new file mode 100644 index 0000000..a553acd --- /dev/null +++ b/core/modules/jsonapi/src/Controller/EntityResource.php @@ -0,0 +1,735 @@ +resourceType = $resource_type; + $this->entityTypeManager = $entity_type_manager; + $this->queryBuilder = $query_builder; + $this->fieldManager = $field_manager; + $this->currentContext = $current_context; + $this->pluginManager = $plugin_manager; + } + + /** + * Gets the individual entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The loaded entity. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * @param int $response_code + * The response code. Defaults to 200. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response. + */ + public function getIndividual(EntityInterface $entity, Request $request, $response_code = 200) { + $entity_access = $entity->access('view', NULL, TRUE); + if (!$entity_access->isAllowed()) { + throw new SerializableHttpException(403, 'The current user is not allowed to GET the selected resource.'); + } + $response = $this->buildWrappedResponse($entity, $response_code); + return $response; + } + + /** + * Verifies that the whole entity does not violate any validation constraints. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity object. + * + * @throws \Drupal\jsonapi\Error\SerializableHttpException + * If validation errors are found. + */ + protected function validate(EntityInterface $entity) { + if (!$entity instanceof FieldableEntityInterface) { + return; + } + + $violations = $entity->validate(); + + // Remove violations of inaccessible fields as they cannot stem from our + // changes. + $violations->filterByFieldAccess(); + + if (count($violations) > 0) { + // Instead of returning a generic 400 response we use the more specific + // 422 Unprocessable Entity code from RFC 4918. That way clients can + // distinguish between general syntax errors in bad serializations (code + // 400) and semantic errors in well-formed requests (code 422). + $exception = new UnprocessableHttpEntityException(); + $exception->setViolations($violations); + throw $exception; + } + } + + /** + * Creates an individual entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The loaded entity. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response. + */ + public function createIndividual(EntityInterface $entity, Request $request) { + $entity_access = $entity->access('create', NULL, TRUE); + + if (!$entity_access->isAllowed()) { + throw new SerializableHttpException(403, 'The current user is not allowed to POST the selected resource.'); + } + $this->validate($entity); + $entity->save(); + return $this->getIndividual($entity, $request, 201); + } + + /** + * Patches an individual entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The loaded entity. + * @param \Drupal\Core\Entity\EntityInterface $parsed_entity + * The entity with the new data. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response. + */ + public function patchIndividual(EntityInterface $entity, EntityInterface $parsed_entity, Request $request) { + $entity_access = $entity->access('update', NULL, TRUE); + if (!$entity_access->isAllowed()) { + throw new SerializableHttpException(403, 'The current user is not allowed to GET the selected resource.'); + } + $body = Json::decode($request->getContent()); + $data = $body['data']; + if ($data['id'] != $entity->uuid()) { + throw new SerializableHttpException(400, sprintf( + 'The selected entity (%s) does not match the ID in the payload (%s).', + $entity->uuid(), + $data['id'] + )); + } + $data += ['attributes' => [], 'relationships' => []]; + $field_names = array_merge(array_keys($data['attributes']), array_keys($data['relationships'])); + array_reduce($field_names, function (EntityInterface $destination, $field_name) use ($parsed_entity) { + $this->updateEntityField($parsed_entity, $destination, $field_name); + return $destination; + }, $entity); + + $this->validate($entity); + $entity->save(); + return $this->getIndividual($entity, $request); + } + + /** + * Deletes an individual entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The loaded entity. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response. + */ + public function deleteIndividual(EntityInterface $entity, Request $request) { + $entity_access = $entity->access('delete', NULL, TRUE); + if (!$entity_access->isAllowed()) { + throw new SerializableHttpException(403, 'The current user is not allowed to DELETE the selected resource.'); + } + $entity->delete(); + return new ResourceResponse(NULL, 204); + } + + /** + * Gets the collection of entities. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response. + */ + public function getCollection(Request $request) { + // Instantiate the query for the filtering. + $entity_type_id = $this->resourceType->getEntityTypeId(); + + $params = $request->attributes->get('_route_params'); + $query = $this->getCollectionQuery($entity_type_id, $params['_json_api_params']); + + $results = $query->execute(); + + $storage = $this->entityTypeManager->getStorage($entity_type_id); + // We request N+1 items to find out if there is a next page for the pager. We may need to remove that extra item + // before loading the entities. + $pager_size = $query->getMetaData('pager_size'); + if ($has_next_page = $pager_size < count($results)) { + // Drop the last result. + array_pop($results); + } + // Each item of the collection data contains an array with 'entity' and + // 'access' elements. + $collection_data = $this->loadEntitiesWithAccess($storage, $results); + $entity_collection = new EntityCollection(array_column($collection_data, 'entity')); + $entity_collection->setHasNextPage($has_next_page); + $response = $this->respondWithCollection($entity_collection, $entity_type_id); + + // Add cacheable metadata for the access result. + $access_info = array_column($collection_data, 'access'); + array_walk($access_info, function ($access) use ($response) { + $response->addCacheableDependency($access); + }); + + return $response; + } + + /** + * Gets the related resource. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The requested entity. + * @param string $related_field + * The related field name. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response. + */ + public function getRelated(EntityInterface $entity, $related_field, Request $request) { + /* @var $field_list \Drupal\Core\Field\FieldItemListInterface */ + if (!($field_list = $entity->get($related_field)) || !$this->isRelationshipField($field_list)) { + throw new SerializableHttpException(404, sprintf('The relationship %s is not present in this resource.', $related_field)); + } + $is_multiple = $field_list + ->getDataDefinition() + ->getFieldStorageDefinition() + ->isMultiple(); + if (!$is_multiple) { + return $this->getIndividual($field_list->entity, $request); + } + $collection_data = []; + $cacheable_metadata = new CacheableMetadata(); + // Add the cacheable metadata from the host entity. + $cacheable_metadata->addCacheableDependency($entity); + foreach ($field_list as $field_item) { + /* @var \Drupal\Core\Entity\EntityInterface $entity_item */ + $entity_item = $field_item->entity; + $collection_data[$entity_item->id()] = static::getEntityAndAccess($entity_item); + $cacheable_metadata->addCacheableDependency($entity_item); + } + $entity_collection = new EntityCollection(array_column($collection_data, 'entity')); + $response = $this->buildWrappedResponse($entity_collection); + + $access_info = array_column($collection_data, 'access'); + array_walk($access_info, function ($access) use ($response) { + $response->addCacheableDependency($access); + }); + // $response does not contain the entity list cache tag. We add the + // cacheable metadata for the finite list of entities in the relationship. + $response->addCacheableDependency($cacheable_metadata); + + return $response; + } + + /** + * Gets the relationship of an entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The requested entity. + * @param string $related_field + * The related field name. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * @param int $response_code + * The response code. Defaults to 200. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response. + */ + public function getRelationship(EntityInterface $entity, $related_field, Request $request, $response_code = 200) { + if (!($field_list = $entity->get($related_field)) || !$this->isRelationshipField($field_list)) { + throw new SerializableHttpException(404, sprintf('The relationship %s is not present in this resource.', $related_field)); + } + $response = $this->buildWrappedResponse($field_list, $response_code); + return $response; + } + + /** + * Adds a relationship to a to-many relationship. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The requested entity. + * @param string $related_field + * The related field name. + * @param mixed $parsed_field_list + * The entity reference field list of items to add, or a response object in + * case of error. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response. + */ + public function createRelationship(EntityInterface $entity, $related_field, $parsed_field_list, Request $request) { + if ($parsed_field_list instanceof Response) { + // This usually means that there was an error, so there is no point on + // processing further. + return $parsed_field_list; + } + /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list */ + $this->relationshipAccess($entity, $related_field); + // According to the specification, you are only allowed to POST to a + // relationship if it is a to-many relationship. + /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */ + $field_list = $entity->{$related_field}; + $is_multiple = $field_list->getFieldDefinition() + ->getFieldStorageDefinition() + ->isMultiple(); + if (!$is_multiple) { + throw new SerializableHttpException(409, sprintf('You can only POST to to-many relationships. %s is a to-one relationship.', $related_field)); + } + + $field_access = $field_list->access('edit', NULL, TRUE); + if (!$field_access->isAllowed()) { + throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_list->getName())); + } + // Time to save the relationship. + foreach ($parsed_field_list as $field_item) { + $field_list->appendItem($field_item->getValue()); + } + $this->validate($entity); + $entity->save(); + return $this->getRelationship($entity, $related_field, $request, 201); + } + + /** + * Updates the relationship of an entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The requested entity. + * @param string $related_field + * The related field name. + * @param mixed $parsed_field_list + * The entity reference field list of items to add, or a response object in + * case of error. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response. + */ + public function patchRelationship(EntityInterface $entity, $related_field, $parsed_field_list, Request $request) { + if ($parsed_field_list instanceof Response) { + // This usually means that there was an error, so there is no point on + // processing further. + return $parsed_field_list; + } + /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list */ + $this->relationshipAccess($entity, $related_field); + // According to the specification, PATCH works a little bit different if the + // relationship is to-one or to-many. + /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */ + $field_list = $entity->{$related_field}; + $is_multiple = $field_list->getFieldDefinition() + ->getFieldStorageDefinition() + ->isMultiple(); + $method = $is_multiple ? 'doPatchMultipleRelationship' : 'doPatchIndividualRelationship'; + $this->{$method}($entity, $parsed_field_list); + $this->validate($entity); + $entity->save(); + return $this->getRelationship($entity, $related_field, $request); + } + + /** + * Update a to-one relationship. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The requested entity. + * @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list + * The entity reference field list of items to add, or a response object in + * case of error. + */ + protected function doPatchIndividualRelationship(EntityInterface $entity, EntityReferenceFieldItemListInterface $parsed_field_list) { + if ($parsed_field_list->count() > 1) { + throw new SerializableHttpException(400, sprintf('Provide a single relationship so to-one relationship fields (%s).', $parsed_field_list->getName())); + } + $this->doPatchMultipleRelationship($entity, $parsed_field_list); + } + + /** + * Update a to-many relationship. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The requested entity. + * @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list + * The entity reference field list of items to add, or a response object in + * case of error. + */ + protected function doPatchMultipleRelationship(EntityInterface $entity, EntityReferenceFieldItemListInterface $parsed_field_list) { + $field_name = $parsed_field_list->getName(); + $field_access = $parsed_field_list->access('edit', NULL, TRUE); + if (!$field_access->isAllowed()) { + throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name)); + } + $entity->{$field_name} = $parsed_field_list; + } + + /** + * Deletes the relationship of an entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The requested entity. + * @param string $related_field + * The related field name. + * @param mixed $parsed_field_list + * The entity reference field list of items to add, or a response object in + * case of error. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response. + */ + public function deleteRelationship(EntityInterface $entity, $related_field, $parsed_field_list, Request $request = NULL) { + if ($parsed_field_list instanceof Response) { + // This usually means that there was an error, so there is no point on + // processing further. + return $parsed_field_list; + } + if ($parsed_field_list instanceof Request) { + // This usually means that there was not body provided. + throw new SerializableHttpException(400, sprintf('You need to provide a body for DELETE operations on a relationship (%s).', $related_field)); + } + /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list */ + $this->relationshipAccess($entity, $related_field); + + $field_name = $parsed_field_list->getName(); + $field_access = $parsed_field_list->access('edit', NULL, TRUE); + if (!$field_access->isAllowed()) { + throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name)); + } + /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */ + $field_list = $entity->{$related_field}; + $is_multiple = $field_list->getFieldDefinition() + ->getFieldStorageDefinition() + ->isMultiple(); + if (!$is_multiple) { + throw new SerializableHttpException(409, sprintf('You can only DELETE from to-many relationships. %s is a to-one relationship.', $related_field)); + } + + // Compute the list of current values and remove the ones in the payload. + $current_values = $field_list->getValue(); + $deleted_values = $parsed_field_list->getValue(); + $keep_values = array_udiff($current_values, $deleted_values, function ($first, $second) { + return reset($first) - reset($second); + }); + // Replace the existing field with one containing the relationships to keep. + $entity->{$related_field} = $this->pluginManager + ->createFieldItemList($entity, $related_field, $keep_values); + + // Save the entity and return the response object. + $this->validate($entity); + $entity->save(); + return $this->getRelationship($entity, $related_field, $request, 201); + } + + /** + * Gets a basic query for a collection. + * + * @param string $entity_type_id + * The entity type for the entity query. + * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params + * The parameters for the query. + * + * @return \Drupal\Core\Entity\Query\QueryInterface + * A new query. + */ + protected function getCollectionQuery($entity_type_id, $params) { + $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); + + $query = $this->queryBuilder->newQuery($entity_type, $params); + + // Limit this query to the bundle type for this resource. + $bundle = $this->resourceType->getBundle(); + if ($bundle && ($bundle_key = $entity_type->getKey('bundle'))) { + $query->condition( + $bundle_key, $bundle + ); + } + + return $query; + } + + /** + * Gets a basic query for a collection count. + * + * @param string $entity_type_id + * The entity type for the entity query. + * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params + * The parameters for the query. + * + * @return \Drupal\Core\Entity\Query\QueryInterface + * A new query. + */ + protected function getCollectionCountQuery($entity_type_id, $params) { + // Override the pagination parameter to get all the available results. + $params[OffsetPage::KEY_NAME] = new JsonApiParamBase([]); + return $this->getCollectionQuery($entity_type_id, $params); + } + + /** + * Builds a response with the appropriate wrapped document. + * + * @param mixed $data + * The data to wrap. + * @param int $response_code + * The response code. + * @param array $headers + * An array of response headers. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response. + */ + protected function buildWrappedResponse($data, $response_code = 200, array $headers = []) { + return new ResourceResponse(new JsonApiDocumentTopLevel($data), $response_code, $headers); + } + + /** + * Respond with an entity collection. + * + * @param \Drupal\jsonapi\EntityCollection $entity_collection + * The collection of entites. + * @param string $entity_type_id + * The entity type. + * + * @return \Drupal\jsonapi\ResourceResponse + * The response. + */ + protected function respondWithCollection(EntityCollection $entity_collection, $entity_type_id) { + $response = $this->buildWrappedResponse($entity_collection); + + // When a new change to any entity in the resource happens, we cannot ensure + // the validity of this cached list. Add the list tag to deal with that. + $list_tag = $this->entityTypeManager->getDefinition($entity_type_id) + ->getListCacheTags(); + $response->getCacheableMetadata()->addCacheTags($list_tag); + return $response; + } + + /** + * Check the access to update the entity and the presence of a relationship. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * @param string $related_field + * The name of the field to check. + */ + protected function relationshipAccess(EntityInterface $entity, $related_field) { + /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $parsed_field_list */ + $entity_access = $entity->access('update', NULL, TRUE); + if (!$entity_access->isAllowed()) { + throw new SerializableHttpException(403, 'The current user is not allowed to update the selected resource.'); + } + if (!($field_list = $entity->get($related_field)) || !$this->isRelationshipField($field_list)) { + throw new SerializableHttpException(404, sprintf('The relationship %s is not present in this resource.', $related_field)); + } + } + + /** + * Takes a field from the origin entity and puts it to the destination entity. + * + * @param EntityInterface $origin + * The entity that contains the field values. + * @param EntityInterface $destination + * The entity that needs to be updated. + * @param string $field_name + * The name of the field to extract and update. + */ + protected function updateEntityField(EntityInterface $origin, EntityInterface $destination, $field_name) { + // The update is different for configuration entities and content entities. + if ($origin instanceof ContentEntityInterface && $destination instanceof ContentEntityInterface) { + // First scenario: both are content entities. + if (!$destination_field_list = $destination->get($field_name)) { + throw new SerializableHttpException(400, sprintf('The provided field (%s) does not exist in the entity with ID %d.', $field_name, $destination->id())); + } + + $origin_field_list = $origin->get($field_name); + if ($destination_field_list->getValue() != $origin_field_list->getValue()) { + $field_access = $destination_field_list->access('edit', NULL, TRUE); + if (!$field_access->isAllowed()) { + throw new SerializableHttpException(403, sprintf('The current user is not allowed to PATCH the selected field (%s).', $destination_field_list->getName())); + } + $destination->{$field_name} = $origin->get($field_name); + } + } + elseif ($origin instanceof ConfigEntityInterface && $destination instanceof ConfigEntityInterface) { + // Second scenario: both are config entities. + $destination->set($field_name, $origin->get($field_name)); + } + else { + throw new SerializableHttpException(400, 'The serialized entity and the destination entity are of different types.'); + } + } + + /** + * Checks if is a relationship field. + * + * @param object $entity_field + * Entity definition. + * @return bool + * Returns TRUE, if entity field is EntityReferenceItem. + */ + protected function isRelationshipField($entity_field) { + /** @var \Drupal\Core\Field\FieldTypePluginManager $field_type_manager */ + $field_type_manager = \Drupal::service('plugin.manager.field.field_type'); + $class = $field_type_manager->getPluginClass($entity_field->getDataDefinition()->getType()); + return ($class == EntityReferenceItem::class || is_subclass_of($class, EntityReferenceItem::class)); + } + + /** + * Build a collection of the entities to respond with and access objects. + * + * @param \Drupal\Core\Entity\EntityStorageInterface $storage + * The entity storage to load the entities from. + * @param int[] $ids + * Array of entity IDs. + * + * @return array + * An array keyed by entity ID containing the keys: + * - entity: the loaded entity or an access exception. + * - access: the access object. + */ + protected function loadEntitiesWithAccess(EntityStorageInterface $storage, $ids) { + $output = []; + foreach ($storage->loadMultiple($ids) as $entity) { + $output[$entity->id()] = static::getEntityAndAccess($entity); + } + return $output; + } + + /** + * Get the object to normalize and the access based on the provided entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to test access for. + * + * @return array + * An array containing the keys: + * - entity: the loaded entity or an access exception. + * - access: the access object. + */ + public static function getEntityAndAccess(EntityInterface $entity) { + $access = $entity->access('view', NULL, TRUE); + // Accumulate the cacheability metadata for the access. + $output = [ + 'access' => $access, + 'entity' => $entity, + ]; + if ($entity instanceof AccessibleInterface && !$access->isAllowed()) { + // Pass an exception to the list of things to normalize. + $output['entity'] = new SerializableHttpException(403, sprintf( + 'Access checks failed for entity %s:%s.', + $entity->getEntityTypeId(), + $entity->id() + )); + } + + return $output; + } + +} diff --git a/core/modules/jsonapi/src/Controller/RequestHandler.php b/core/modules/jsonapi/src/Controller/RequestHandler.php new file mode 100644 index 0000000..c4616b0 --- /dev/null +++ b/core/modules/jsonapi/src/Controller/RequestHandler.php @@ -0,0 +1,289 @@ +getMethod()); + $route = $route_match->getRouteObject(); + + // Deserialize incoming data if available. + /* @var \Symfony\Component\Serializer\SerializerInterface $serializer */ + $serializer = $this->container->get('serializer'); + /* @var \Drupal\jsonapi\Context\CurrentContext $current_context */ + $current_context = $this->container->get('jsonapi.current_context'); + $unserialized = $this->deserializeBody($request, $serializer, $route->getOption('serialization_class'), $current_context); + $format = $request->getRequestFormat(); + if ($unserialized instanceof Response && !$unserialized->isSuccessful()) { + return $unserialized; + } + + // Determine the request parameters that should be passed to the resource + // plugin. + $route_parameters = $route_match->getParameters(); + $parameters = array(); + + // 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. + // All REST routes are restricted to exactly one format, so instead of + // parsing it out of the Accept headers again, we can simply retrieve the + // format requirement. If there is no format associated, just pick JSON. + $action = $this->action($route_match, $method); + $resource = $this->resourceFactory($route, $current_context); + + // Only add the unserialized data if there is something there. + $extra_parameters = $unserialized ? [$unserialized, $request] : [$request]; + + /** @var \Drupal\jsonapi\Error\ErrorHandler $error_handler */ + $error_handler = $this->container->get('jsonapi.error_handler'); + $error_handler->register(); + // Execute the request in context so the cacheable metadata from the entity + // grants system is caught and added to the response. This is surfaced when + // executing the underlying entity query. + $context = new RenderContext(); + /** @var \Drupal\Core\Cache\CacheableResponseInterface $response */ + $response = $this->container->get('renderer') + ->executeInRenderContext($context, function () use ($resource, $action, $parameters, $extra_parameters) { + return call_user_func_array([$resource, $action], array_merge($parameters, $extra_parameters)); + }); + if (!$context->isEmpty()) { + $response->addCacheableDependency($context->pop()); + } + $error_handler->restore(); + + return $this->renderJsonApiResponse($request, $response, $serializer, $format, $error_handler); + } + + /** + * 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\Core\Cache\CacheableResponseInterface $response + * The response from the REST resource. + * @param \Symfony\Component\Serializer\SerializerInterface $serializer + * The serializer to use. + * @param string $format + * The response format. + * @param \Drupal\jsonapi\Error\ErrorHandler $error_handler + * The error handler service. + * + * @return \Drupal\Core\Cache\CacheableResponseInterface + * The altered response. + */ + protected function renderJsonApiResponse(Request $request, ResourceResponse $response, SerializerInterface $serializer, $format, ErrorHandler $error_handler) { + $data = $response->getResponseData(); + $context = new RenderContext(); + + $cacheable_metadata = $response->getCacheableMetadata(); + // Make sure to include the default cacheable metadata, since it won't be + // added if you don't user render arrays and the HtmlRenderer. We are not + // using the container variable '%renderer.config%' because is too tied to + // HTML generation. + $cacheable_metadata->addCacheContexts(static::$requiredCacheContexts); + + // Make sure that any PHP error is surfaced as a serializable exception. + $error_handler->register(); + $output = $this->container->get('renderer') + ->executeInRenderContext($context, function () use ( + $serializer, + $data, + $format, + $request, + $cacheable_metadata + ) { + // 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' => $cacheable_metadata]); + }); + $error_handler->restore(); + $response->setContent($output); + if (!$context->isEmpty()) { + $response->addCacheableDependency($context->pop()); + } + + $response->headers->set('Content-Type', $request->getMimeType($format)); + // Add rest settings config's cache tags. + $response->addCacheableDependency($this->container->get('config.factory') + ->get('jsonapi.resource_info')); + + return $response; + } + + /** + * Deserializes the sent data. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. + * @param \Symfony\Component\Serializer\SerializerInterface $serializer + * The serializer for the deserialization of the input data. + * @param string $serialization_class + * The class the input data needs to deserialize into. + * @param \Drupal\jsonapi\Context\CurrentContext $current_context + * The current context + * + * @return mixed + * The deserialized data or a Response object in case of error. + */ + public function deserializeBody(Request $request, SerializerInterface $serializer, $serialization_class, CurrentContext $current_context) { + $received = $request->getContent(); + if (empty($received)) { + return NULL; + } + $format = $request->getContentType(); + try { + return $serializer->deserialize($received, $serialization_class, $format, [ + 'related' => $request->get('related'), + 'target_entity' => $request->get($current_context->getResourceType()->getEntityTypeId()), + 'resource_type' => $current_context->getResourceType(), + ]); + } + catch (UnexpectedValueException $e) { + throw new SerializableHttpException( + 422, + sprintf('There was an error un-serializing the data. Message: %s.', $e->getMessage()), + $e + ); + } + } + + /** + * Gets the method to execute in the entity resource. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match. + * @param string $method + * The lowercase HTTP method. + * + * @return string + * The method to execute in the EntityResource. + */ + protected function action(RouteMatchInterface $route_match, $method) { + $on_relationship = ($route_match->getRouteObject()->getDefault('_on_relationship')); + switch ($method) { + case 'get': + if ($on_relationship) { + return 'getRelationship'; + } + elseif ($route_match->getParameter('related')) { + return 'getRelated'; + } + return $this->getEntity($route_match) ? 'getIndividual' : 'getCollection'; + + case 'post': + return ($on_relationship) ? 'createRelationship' : 'createIndividual'; + + case 'patch': + return ($on_relationship) ? 'patchRelationship' : 'patchIndividual'; + + case 'delete': + return ($on_relationship) ? 'deleteRelationship' : 'deleteIndividual'; + } + } + + /** + * Gets the entity for the operation. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The matched route. + * + * @return \Drupal\Core\Entity\EntityInterface + * The upcasted entity. + */ + protected function getEntity(RouteMatchInterface $route_match) { + $route = $route_match->getRouteObject(); + return $route_match->getParameter($route->getRequirement('_entity_type')); + } + + /** + * Get the resource. + * + * @param \Symfony\Component\Routing\Route $route + * The matched route. + * @param \Drupal\jsonapi\Context\CurrentContext $current_context + * The current context. + * + * @return \Drupal\jsonapi\Controller\EntityResource + * The instantiated resource. + */ + protected function resourceFactory(Route $route, CurrentContext $current_context) { + /** @var \Drupal\jsonapi\ResourceType\ResourceTypeRepository $resource_type_repository */ + $resource_type_repository = $this->container->get('jsonapi.resource_type.repository'); + /* @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ + $entity_type_manager = $this->container->get('entity_type.manager'); + /* @var \Drupal\jsonapi\Query\QueryBuilder $query_builder */ + $query_builder = $this->container->get('jsonapi.query_builder'); + /* @var \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager */ + $field_manager = $this->container->get('entity_field.manager'); + /* @var \Drupal\Core\Field\FieldTypePluginManagerInterface $plugin_manager */ + $plugin_manager = $this->container->get('plugin.manager.field.field_type'); + $resource = new EntityResource( + $resource_type_repository->get($route->getRequirement('_entity_type'), $route->getRequirement('_bundle')), + $entity_type_manager, + $query_builder, + $field_manager, + $current_context, + $plugin_manager + ); + return $resource; + } + +} diff --git a/core/modules/jsonapi/src/Encoder/JsonEncoder.php b/core/modules/jsonapi/src/Encoder/JsonEncoder.php new file mode 100644 index 0000000..25000e8 --- /dev/null +++ b/core/modules/jsonapi/src/Encoder/JsonEncoder.php @@ -0,0 +1,43 @@ +rasterizeValue(); + } + // Allows wrapping the encoded output. This is so we can use the same + // encoder and normalizers when serializing HttpExceptions to match the + // JSON API specification. + if (!empty($context['data_wrapper'])) { + $data = [$context['data_wrapper'] => $data]; + } + return parent::encode($data, $format, $context); + } + +} diff --git a/core/modules/jsonapi/src/Error/ErrorHandler.php b/core/modules/jsonapi/src/Error/ErrorHandler.php new file mode 100644 index 0000000..a51f646 --- /dev/null +++ b/core/modules/jsonapi/src/Error/ErrorHandler.php @@ -0,0 +1,53 @@ +violations; + } + + /** + * Sets the constraint violations associated with this exception. + * + * @param \Drupal\Core\Entity\EntityConstraintViolationListInterface $violations + * The constraint violations. + */ + public function setViolations(EntityConstraintViolationListInterface $violations) { + $this->violations = $violations; + } + +} diff --git a/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php b/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php new file mode 100644 index 0000000..55c1a8d --- /dev/null +++ b/core/modules/jsonapi/src/EventSubscriber/DefaultExceptionSubscriber.php @@ -0,0 +1,63 @@ +getException(); + $format = $event->getRequest()->getRequestFormat(); + if (!$this->serializer->supportsEncoding($format)) { + return; + } + if (!$exception instanceof HttpException) { + $exception = new SerializableHttpException(500, $exception->getMessage(), $exception); + $event->setException($exception); + } + + $this->setEventResponse($event, $exception->getStatusCode()); + } + + /** + * {@inheritdoc} + */ + protected function setEventResponse(GetResponseForExceptionEvent $event, $status) { + /** @var \Symfony\Component\HttpKernel\Exception\HttpException $exception */ + $exception = $event->getException(); + $format = $event->getRequest()->getRequestFormat(); + if (!$this->serializer->supportsNormalization($exception, $format)) { + return; + } + $encoded_content = $this->serializer->serialize($exception, $format, ['data_wrapper' => 'errors']); + $response = new Response($encoded_content, $status); + $event->setResponse($response); + } + +} diff --git a/core/modules/jsonapi/src/Field/FileDownloadUrl.php b/core/modules/jsonapi/src/Field/FileDownloadUrl.php new file mode 100644 index 0000000..c797871 --- /dev/null +++ b/core/modules/jsonapi/src/Field/FileDownloadUrl.php @@ -0,0 +1,90 @@ +initList(); + + return parent::getValue($include_computed); + } + + /** + * {@inheritdoc} + */ + public function access($operation = 'view', AccountInterface $account = NULL, $return_as_object = FALSE) { + return $this->getEntity() + ->get('uri') + ->access($operation, $account, $return_as_object); + } + + /** + * {@inheritdoc} + */ + public function isEmpty() { + return $this->getEntity()->get('uri')->isEmpty(); + } + + /** + * {@inheritdoc} + */ + public function getIterator() { + $this->initList(); + + return parent::getIterator(); + } + + /** + * {@inheritdoc} + */ + public function get($index) { + $this->initList(); + + return parent::get($index); + } + + /** + * Initialize the internal field list with the modified items. + */ + protected function initList() { + if ($this->list) { + return; + } + $url_list = []; + foreach ($this->getEntity()->get('uri') as $uri_item) { + $url_item = clone $uri_item; + $uri = $uri_item->value; + $url_item->setValue($this->fileCreateRootRelativeUrl($uri)); + $url_list[] = $url_item; + } + $this->list = $url_list; + } + +} diff --git a/core/modules/jsonapi/src/JsonapiServiceProvider.php b/core/modules/jsonapi/src/JsonapiServiceProvider.php new file mode 100644 index 0000000..b5f9ea9 --- /dev/null +++ b/core/modules/jsonapi/src/JsonapiServiceProvider.php @@ -0,0 +1,31 @@ +has('http_middleware.negotiation') && is_a($container->getDefinition('http_middleware.negotiation') + ->getClass(), '\Drupal\Core\StackMiddleware\NegotiationMiddleware', TRUE) + ) { + // @see http://www.iana.org/assignments/media-types/application/vnd.api+json + $container->getDefinition('http_middleware.negotiation') + ->addMethodCall('registerFormat', [ + 'api_json', + ['application/vnd.api+json'], + ]); + } + } + +} diff --git a/core/modules/jsonapi/src/LinkManager/LinkManager.php b/core/modules/jsonapi/src/LinkManager/LinkManager.php new file mode 100644 index 0000000..b8875ed --- /dev/null +++ b/core/modules/jsonapi/src/LinkManager/LinkManager.php @@ -0,0 +1,186 @@ +router = $router; + $this->urlGenerator = $url_generator; + } + + /** + * Gets a link for the entity. + * + * @param int $entity_id + * The entity ID to generate the link for. Note: Depending on the + * configuration this might be the UUID as well. + * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type + * The JSON API resource type. + * @param array $route_parameters + * Parameters for the route generation. + * @param string $key + * A key to build the route identifier. + * + * @return string + * The URL string. + */ + public function getEntityLink($entity_id, ResourceType $resource_type, array $route_parameters, $key) { + $route_parameters += [ + $resource_type->getEntityTypeId() => $entity_id, + '_format' => 'api_json', + ]; + $route_key = sprintf('jsonapi.%s.%s', $resource_type->getTypeName(), $key); + return $this->urlGenerator->generateFromRoute($route_key, $route_parameters, ['absolute' => TRUE]); + } + + /** + * Get the full URL for a given request object. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * @param array|null $query + * The query parameters to use. Leave it empty to get the query from the + * request object. + * + * @return string + * The full URL. + */ + public function getRequestLink(Request $request, $query = NULL) { + $query = $query ?: (array) $request->query->getIterator(); + $result = $this->router->matchRequest($request); + $route_name = $result[RouteObjectInterface::ROUTE_NAME]; + /* @var \Symfony\Component\HttpFoundation\ParameterBag $raw_variables */ + $raw_variables = $result['_raw_variables']; + $route_parameters = $raw_variables->all(); + $options = [ + 'absolute' => TRUE, + 'query' => $query, + ]; + return $this->urlGenerator->generateFromRoute($route_name, $route_parameters, $options); + } + + /** + * Get the pager links for a given request object. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * @param array $link_context + * An associative array with extra data to build the links. + * + * @throws \Drupal\jsonapi\Error\SerializableHttpException + * When the offset and size are invalid. + * + * @return string[] + * An array of URLs, with: + * - a 'next' key if it is not the last page; + * - 'prev' and 'first' keys if it's not the first page. + */ + public function getPagerLinks(Request $request, array $link_context = []) { + $params = $request->get('_json_api_params'); + if ($page_param = $params[OffsetPage::KEY_NAME]) { + /* @var \Drupal\jsonapi\Routing\Param\OffsetPage $page_param */ + $offset = $page_param->getOffset(); + $size = $page_param->getSize(); + } + else { + // Apply the defaults. + $offset = 0; + $size = OffsetPage::$maxSize; + } + if ($size <= 0) { + throw new SerializableHttpException(400, sprintf('The page size needs to be a positive integer.')); + } + $query = (array) $request->query->getIterator(); + $links = []; + // Check if this is not the last page. + if ($link_context['has_next_page']) { + $links['next'] = $this->getRequestLink($request, $this->getPagerQueries('next', $offset, $size, $query)); + } + // Check if this is not the first page. + if ($offset > 0) { + $links['first'] = $this->getRequestLink($request, $this->getPagerQueries('first', $offset, $size, $query)); + $links['prev'] = $this->getRequestLink($request, $this->getPagerQueries('prev', $offset, $size, $query)); + } + + return $links; + } + + /** + * Get the query param array. + * + * @param string $link_id + * The name of the pagination link requested. + * @param int $offset + * The starting index. + * @param int $size + * The pagination page size. + * @param array $query + * The query parameters. + * + * @return array + * The pagination query param array. + */ + protected function getPagerQueries($link_id, $offset, $size, $query = []) { + $extra_query = []; + switch ($link_id) { + case 'next': + $extra_query = [ + 'page' => [ + 'offset' => $offset + $size, + 'limit' => $size, + ], + ]; + break; + + case 'first': + $extra_query = [ + 'page' => [ + 'offset' => 0, + 'limit' => $size, + ], + ]; + break; + + case 'prev': + $extra_query = [ + 'page' => [ + 'offset' => max($offset - $size, 0), + 'limit' => $size, + ], + ]; + break; + } + return array_merge($query, $extra_query); + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/ConfigEntityNormalizer.php b/core/modules/jsonapi/src/Normalizer/ConfigEntityNormalizer.php new file mode 100644 index 0000000..e942072 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/ConfigEntityNormalizer.php @@ -0,0 +1,48 @@ +toArray(); + } + + /** + * {@inheritdoc} + */ + protected function serializeField($field, $context, $format) { + $output = $this->serializer->normalize($field, $format, $context); + if (is_array($output)) { + $output = new FieldNormalizerValue( + [new FieldItemNormalizerValue($output)], + 1 + ); + $output->setPropertyType('attributes'); + return $output; + } + $field instanceof Relationship ? + $output->setPropertyType('relationships') : + $output->setPropertyType('attributes'); + return $output; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/ContentEntityNormalizer.php b/core/modules/jsonapi/src/Normalizer/ContentEntityNormalizer.php new file mode 100644 index 0000000..6f8fcf0 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/ContentEntityNormalizer.php @@ -0,0 +1,8 @@ +linkManager = $link_manager; + $this->resourceTypeRepository = $resource_type_repository; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public function normalize($entity, $format = NULL, array $context = array()) { + // If the fields to use were specified, only output those field values. + $context['resource_type'] = $resource_type = $this->resourceTypeRepository->get( + $entity->getEntityTypeId(), + $entity->bundle() + ); + $resource_type_name = $resource_type->getTypeName(); + // Get the bundle ID of the requested resource. This is used to determine if + // this is a bundle level resource or an entity level resource. + $bundle = $resource_type->getBundle(); + if (!empty($context['sparse_fieldset'][$resource_type_name])) { + $field_names = $context['sparse_fieldset'][$resource_type_name]; + } + else { + $field_names = $this->getFieldNames($entity, $bundle); + } + /* @var Value\FieldNormalizerValueInterface[] $normalizer_values */ + $normalizer_values = []; + foreach ($this->getFields($entity, $bundle) as $field_name => $field) { + if (!in_array($field_name, $field_names)) { + continue; + } + $normalizer_values[$field_name] = $this->serializeField($field, $context, $format); + } + + $link_context = ['link_manager' => $this->linkManager]; + $output = new EntityNormalizerValue($normalizer_values, $context, $entity, $link_context); + // Add the entity level cacheability metadata. + $output->addCacheableDependency($entity); + $output->addCacheableDependency($output); + // Add the field level cacheability metadata. + array_walk($normalizer_values, function ($normalizer_value) { + if ($normalizer_value instanceof RefinableCacheableDependencyInterface) { + $normalizer_value->addCacheableDependency($normalizer_value); + } + }); + return $output; + } + + /** + * Checks if the passed field is a relationship field. + * + * @param mixed $field + * The field. + * + * @return bool + * TRUE if it's a JSON API relationship. + */ + protected function isRelationship($field) { + return $field instanceof EntityReferenceFieldItemList || $field instanceof Relationship; + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = array()) { + if (empty($context['resource_type']) || !$context['resource_type'] instanceof ResourceType) { + throw new SerializableHttpException(412, 'Missing context during denormalization.'); + } + /* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */ + $resource_type = $context['resource_type']; + $entity_type_id = $resource_type->getEntityTypeId(); + $bundle = $resource_type->getBundle(); + $bundle_key = $this->entityTypeManager->getDefinition($entity_type_id) + ->getKey('bundle'); + if ($bundle_key && $bundle) { + $data[$bundle_key] = $bundle; + } + + return $this->entityTypeManager->getStorage($entity_type_id) + ->create($data); + } + + /** + * Gets the field names for the given entity. + * + * @param mixed $entity + * The entity. + * + * @return string[] + * The field names. + */ + protected function getFieldNames($entity, $bundle) { + /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + return array_keys($this->getFields($entity, $bundle)); + } + + /** + * Gets the field names for the given entity. + * + * @param mixed $entity + * The entity. + * @param string $bundle + * The bundle id. + * + * @return array + * The fields. + */ + protected function getFields($entity, $bundle) { + /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + return $entity->getFields(); + } + + /** + * Serializes a given field. + * + * @param mixed $field + * The field to serialize. + * @param array $context + * The normalization context. + * @param string $format + * The serialization format. + * + * @return Value\FieldNormalizerValueInterface + * The normalized value. + */ + protected function serializeField($field, $context, $format) { + /* @var \Drupal\Core\Field\FieldItemListInterface|\Drupal\jsonapi\Normalizer\Relationship $field */ + // Continue if the current user does not have access to view this field. + $access = $field->access('view', $context['account'], TRUE); + if ($field instanceof AccessibleInterface && !$access->isAllowed()) { + return (new NullFieldNormalizerValue())->addCacheableDependency($access); + } + /** @var \Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue $output */ + $output = $this->serializer->normalize($field, $format, $context); + $is_relationship = $this->isRelationship($field); + $property_type = $is_relationship ? 'relationships' : 'attributes'; + $output->setPropertyType($property_type); + + if ($output instanceof RefinableCacheableDependencyInterface) { + // Add the cache dependency to the field level object because we want to + // allow the field normalizers to add extra cacheability metadata. + $output->addCacheableDependency($access); + } + + return $output; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php b/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php new file mode 100644 index 0000000..9ca2095 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/EntityReferenceFieldNormalizer.php @@ -0,0 +1,201 @@ +linkManager = $link_manager; + $this->fieldManager = $field_manager; + $this->pluginManager = $plugin_manager; + $this->resourceTypeRepository = $resource_type_repository; + $this->entityRepository = $entity_repository; + } + + /** + * {@inheritdoc} + */ + public function normalize($field, $format = NULL, array $context = array()) { + /* @var $field \Drupal\Core\Field\FieldItemListInterface */ + // Build the relationship object based on the Entity Reference and normalize + // that object instead. + $main_property = $field->getItemDefinition()->getMainPropertyName(); + $definition = $field->getFieldDefinition(); + $cardinality = $definition + ->getFieldStorageDefinition() + ->getCardinality(); + $entity_collection = new EntityCollection(array_map(function ($item) { + return $item->get('entity')->getValue(); + }, (array) $field->getIterator())); + $relationship = new Relationship($this->resourceTypeRepository, $field->getName(), $cardinality, $entity_collection, $field->getEntity(), $main_property); + return $this->serializer->normalize($relationship, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = array()) { + // If we get to here is through a write method on a relationship operation. + /** @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */ + $resource_type = $context['resource_type']; + $entity_type_id = $resource_type->getEntityTypeId(); + $field_definitions = $this->fieldManager->getFieldDefinitions( + $entity_type_id, + $resource_type->getBundle() + ); + if (empty($context['related']) || empty($field_definitions[$context['related']])) { + throw new SerializableHttpException(400, 'Invalid or missing related field.'); + } + /* @var \Drupal\field\Entity\FieldConfig $field_definition */ + $field_definition = $field_definitions[$context['related']]; + // This is typically 'target_id'. + $item_definition = $field_definition->getItemDefinition(); + $property_key = $item_definition->getMainPropertyName(); + $target_resources = $this->getAllowedResourceTypes($item_definition); + + $is_multiple = $field_definition->getFieldStorageDefinition()->isMultiple(); + $data = $this->massageRelationshipInput($data, $is_multiple); + $values = array_map(function ($value) use ($property_key, $target_resources) { + // Make sure that the provided type is compatible with the targeted + // resource. + if (!in_array($value['type'], $target_resources)) { + throw new SerializableHttpException(400, sprintf( + 'The provided type (%s) does not mach the destination resource types (%s).', + $value['type'], + implode(', ', $target_resources) + )); + } + + // Load the entity by UUID. + list($entity_type_id,) = explode('--', $value['type']); + $entity = $this->entityRepository->loadEntityByUuid($entity_type_id, $value['id']); + $value['id'] = $entity ? $entity->id() : NULL; + + return [$property_key => $value['id']]; + }, $data['data']); + return $this->pluginManager + ->createFieldItemList($context['target_entity'], $context['related'], $values); + } + + /** + * Validates and massages the relationship input depending on the cardinality. + * + * @param array $data + * The input data from the body. + * @param bool $is_multiple + * Indicates if the relationship is to-many. + * + * @return array + * The massaged data array. + */ + protected function massageRelationshipInput($data, $is_multiple) { + if ($is_multiple) { + if (!is_array($data['data'])) { + throw new SerializableHttpException(400, 'Invalid body payload for the relationship.'); + } + // Leave the invalid elements. + $invalid_elements = array_filter($data['data'], function ($element) { + return empty($element['type']) || empty($element['id']); + }); + if ($invalid_elements) { + throw new SerializableHttpException(400, 'Invalid body payload for the relationship.'); + } + } + else { + // For to-one relationships you can have a NULL value. + if (is_null($data['data'])) { + return ['data' => []]; + } + if (empty($data['data']['type']) || empty($data['data']['id'])) { + throw new SerializableHttpException(400, 'Invalid body payload for the relationship.'); + } + $data['data'] = [$data['data']]; + } + return $data; + } + + /** + * Build the list of resource types supported by this entity reference field. + * + * @param \Drupal\Core\Field\TypedData\FieldItemDataDefinition $item_definition + * The field item definition. + * + * @return string[] + * List of resource types. + */ + protected function getAllowedResourceTypes(FieldItemDataDefinition $item_definition) { + // Build the list of allowed resources. + $target_entity_id = $item_definition->getSetting('target_type'); + $handler_settings = $item_definition->getSetting('handler_settings'); + $target_bundles = empty($handler_settings['target_bundles']) ? + [] : + $handler_settings['target_bundles']; + return array_map(function ($target_bundle) use ($target_entity_id) { + return $this->resourceTypeRepository + ->get($target_entity_id, $target_bundle) + ->getTypeName(); + }, $target_bundles); + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php new file mode 100644 index 0000000..477aa57 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php @@ -0,0 +1,46 @@ +toArray(); + if (isset($context['langcode'])) { + $values['lang'] = $context['langcode']; + } + return new FieldItemNormalizerValue($values); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = array()) { + throw new UnexpectedValueException('Denormalization not implemented for JSON API'); + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php new file mode 100644 index 0000000..5d4ab5b --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/FieldNormalizer.php @@ -0,0 +1,69 @@ +normalizeFieldItems($field, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = array()) { + throw new UnexpectedValueException('Denormalization not implemented for JSON API'); + } + + /** + * Helper function to normalize field items. + * + * @param \Drupal\Core\Field\FieldItemListInterface $field + * The field object. + * @param string $format + * The format. + * @param array $context + * The context array. + * + * @return \Drupal\jsonapi\Normalizer\Value\FieldNormalizerValue + * The array of normalized field items. + */ + protected function normalizeFieldItems(FieldItemListInterface $field, $format, $context) { + $normalizer_items = array(); + if (!$field->isEmpty()) { + foreach ($field as $field_item) { + $normalizer_items[] = $this->serializer->normalize($field_item, $format, $context); + } + } + $cardinality = $field->getFieldDefinition() + ->getFieldStorageDefinition() + ->getCardinality(); + return new FieldNormalizerValue($normalizer_items, $cardinality); + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php b/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php new file mode 100644 index 0000000..529f862 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/HttpExceptionNormalizer.php @@ -0,0 +1,154 @@ +currentUser = $current_user; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = NULL, array $context = []) { + $errors = $this->buildErrorObjects($object); + + $errors = array_map(function($error) { + return new FieldItemNormalizerValue([$error]); + }, $errors); + + return new HttpExceptionNormalizerValue( + $errors, + FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED + ); + } + + /** + * Builds the normalized JSON API error objects for the response. + * + * @param \Symfony\Component\HttpKernel\Exception\HttpException $exception + * The Exception. + * + * @return array + * The error objects to include in the response. + */ + protected function buildErrorObjects(HttpException $exception) { + $error = []; + $status_code = $exception->getStatusCode(); + if (!empty(Response::$statusTexts[$status_code])) { + $error['title'] = Response::$statusTexts[$status_code]; + } + $error += [ + 'status' => $status_code, + 'detail' => $exception->getMessage(), + 'links' => [ + 'info' => $this->getInfoUrl($status_code), + ], + 'code' => $exception->getCode(), + ]; + if ($this->currentUser->hasPermission('access site reports')) { + // The following information may contain sensitive information. Only show + // it to authorized users. + $error['source'] = [ + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + ]; + $error['meta'] = [ + 'exception' => (string) $exception, + 'trace' => $exception->getTrace(), + ]; + } + + return [ $error ]; + } + + /** + * Return a string to the common problem type. + * + * @return string + * URL pointing to the specific RFC-2616 section. + */ + protected function getInfoUrl($status_code) { + // Depending on the error code we'll return a different URL. + $url = 'http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html'; + $sections = array( + '100' => '#sec10.1.1', + '101' => '#sec10.1.2', + '200' => '#sec10.2.1', + '201' => '#sec10.2.2', + '202' => '#sec10.2.3', + '203' => '#sec10.2.4', + '204' => '#sec10.2.5', + '205' => '#sec10.2.6', + '206' => '#sec10.2.7', + '300' => '#sec10.3.1', + '301' => '#sec10.3.2', + '302' => '#sec10.3.3', + '303' => '#sec10.3.4', + '304' => '#sec10.3.5', + '305' => '#sec10.3.6', + '307' => '#sec10.3.8', + '400' => '#sec10.4.1', + '401' => '#sec10.4.2', + '402' => '#sec10.4.3', + '403' => '#sec10.4.4', + '404' => '#sec10.4.5', + '405' => '#sec10.4.6', + '406' => '#sec10.4.7', + '407' => '#sec10.4.8', + '408' => '#sec10.4.9', + '409' => '#sec10.4.10', + '410' => '#sec10.4.11', + '411' => '#sec10.4.12', + '412' => '#sec10.4.13', + '413' => '#sec10.4.14', + '414' => '#sec10.4.15', + '415' => '#sec10.4.16', + '416' => '#sec10.4.17', + '417' => '#sec10.4.18', + '500' => '#sec10.5.1', + '501' => '#sec10.5.2', + '502' => '#sec10.5.3', + '503' => '#sec10.5.4', + '504' => '#sec10.5.5', + '505' => '#sec10.5.6', + ); + return empty($sections[$status_code]) ? $url : $url . $sections[$status_code]; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php b/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php new file mode 100644 index 0000000..09041ef --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/JsonApiDocumentTopLevelNormalizer.php @@ -0,0 +1,195 @@ +linkManager = $link_manager; + $this->currentContext = $current_context; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = array()) { + $context += [ + 'on_relationship' => $this->currentContext->isOnRelationship(), + ]; + $normalized = []; + if (!empty($data['data']['attributes'])) { + $normalized = $data['data']['attributes']; + } + if (!empty($data['data']['relationships'])) { + // Turn all single object relationship data fields into an array of objects. + $relationships = array_map(function ($relationship) { + if (isset($relationship['data']['type']) && isset($relationship['data']['id'])) { + return ['data' => [$relationship['data']]]; + } + else { + return $relationship; + } + }, $data['data']['relationships']); + + // Get an array of ids for every relationship. + $relationships = array_map(function ($relationship) { + $id_list = array_column($relationship['data'], 'id'); + list($entity_type_id,) = explode('--', $relationship['data'][0]['type']); + $entity_storage = $this->entityTypeManager->getStorage($entity_type_id); + // In order to maintain the order ($delta) of the relationships, we need + // to load the entities and explore the uuid value. + $related_entities = array_values($entity_storage->loadByProperties(['uuid' => $id_list])); + $map = []; + foreach ($related_entities as $related_entity) { + $map[$related_entity->uuid()] = $related_entity->id(); + } + $canonical_ids = array_map(function ($input_value) use ($map) { + return empty($map[$input_value]) ? NULL : $map[$input_value]; + }, $id_list); + + return array_filter($canonical_ids); + }, $relationships); + + // Add the relationship ids. + $normalized = array_merge($normalized, $relationships); + } + // Override deserialization target class with the one in the ResourceType. + $class = $context['resource_type']->getDeserializationTargetClass(); + + return $this + ->serializer + ->denormalize($normalized, $class, $format, $context); + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = NULL, array $context = array()) { + $context += ['resource_type' => $this->currentContext->getResourceType()]; + $value_extractor = $this->buildNormalizerValue($object->getData(), $format, $context); + if (!empty($context['cacheable_metadata'])) { + $context['cacheable_metadata']->addCacheableDependency($value_extractor); + } + $normalized = $value_extractor->rasterizeValue(); + $included = array_filter($value_extractor->rasterizeIncludes()); + if (!empty($included)) { + $included = array_map(function ($value) { + return $value['data'] === FALSE ? ['meta' => $value['meta']] : $value['data']; + }, $included); + $normalized['included'] = $included; + } + + return $normalized; + } + + /** + * Build the normalizer value. + * + * @return \Drupal\jsonapi\Normalizer\Value\JsonApiDocumentTopLevelNormalizerValue + * The normalizer value. + */ + public function buildNormalizerValue($data, $format = NULL, array $context = array()) { + $context += $this->expandContext($context['request']); + if ($data instanceof EntityReferenceFieldItemListInterface) { + $output = $this->serializer->normalize($data, $format, $context); + // The only normalizer value that computes nested includes automatically is the JsonApiDocumentTopLevelNormalizerValue + $output->setIncludes($output->getAllIncludes()); + return $output; + } + else { + $is_collection = $data instanceof EntityCollection; + // To improve the logical workflow deal with an array at all times. + $entities = $is_collection ? $data->toArray() : [$data]; + $context['has_next_page'] = $is_collection ? $data->hasNextPage() : FALSE; + $serializer = $this->serializer; + $normalizer_values = array_map(function ($entity) use ($format, $context, $serializer) { + return $serializer->normalize($entity, $format, $context); + }, $entities); + } + + return new JsonApiDocumentTopLevelNormalizerValue($normalizer_values, $context, $is_collection, [ + 'link_manager' => $this->linkManager, + 'has_next_page' => $context['has_next_page'], + ]); + } + + /** + * Expand the context information based on the current request context. + * + * @param Request $request + * The request to get the URL params from to expand the context. + * + * @return array + * The expanded context. + */ + protected function expandContext(Request $request) { + $context = array( + 'account' => NULL, + 'sparse_fieldset' => NULL, + 'resource_type' => NULL, + 'include' => array_filter(explode(',', $request->query->get('include'))), + ); + if (isset($this->currentContext)) { + $context['resource_type'] = $this->currentContext->getResourceType(); + } + if ($request->query->get('fields')) { + $context['sparse_fieldset'] = array_map(function ($item) { + return explode(',', $item); + }, $request->query->get('fields')); + } + + return $context; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/NormalizerBase.php b/core/modules/jsonapi/src/Normalizer/NormalizerBase.php new file mode 100644 index 0000000..46c3496 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/NormalizerBase.php @@ -0,0 +1,51 @@ +formats) && parent::supportsNormalization($data, $format); + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = NULL) { + if (in_array($format, $this->formats) && (class_exists($this->supportedInterfaceOrClass) || interface_exists($this->supportedInterfaceOrClass))) { + $target = new \ReflectionClass($type); + $supported = new \ReflectionClass($this->supportedInterfaceOrClass); + if ($supported->isInterface()) { + return $target->implementsInterface($this->supportedInterfaceOrClass); + } + else { + return ($target->getName() == $this->supportedInterfaceOrClass || $target->isSubclassOf($this->supportedInterfaceOrClass)); + } + } + + return FALSE; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/Relationship.php b/core/modules/jsonapi/src/Normalizer/Relationship.php new file mode 100644 index 0000000..2483b6d --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/Relationship.php @@ -0,0 +1,141 @@ +resourceTypeRepository = $resource_type_repository; + $this->propertyName = $field_name; + $this->cardinality = $cardinality; + $this->hostEntity = $host_entity; + $this->items = []; + foreach ($entities as $entity) { + $this->items[] = new RelationshipItem( + $resource_type_repository, + $entity, + $this, + $target_key + ); + } + } + + /** + * Gets the cardinality. + * + * @return mixed + */ + public function getCardinality() { + return $this->cardinality; + } + + /** + * Gets the host entity. + * + * @return \Drupal\Core\Entity\EntityInterface + */ + public function getHostEntity() { + return $this->hostEntity; + } + + /** + * Sets the host entity. + * + * @param \Drupal\Core\Entity\EntityInterface $hostEntity + */ + public function setHostEntity(EntityInterface $hostEntity) { + $this->hostEntity = $hostEntity; + } + + /** + * {@inheritdoc} + */ + public function access($operation, AccountInterface $account = NULL, $return_as_object = FALSE) { + // Hard coded to TRUE. Revisit this if we need more control over this. + return TRUE; + } + + /** + * Gets the field name. + * + * @return string + */ + public function getPropertyName() { + return $this->propertyName; + } + + /** + * Gets the items. + * + * @return \Drupal\jsonapi\Normalizer\RelationshipItem[] + */ + public function getItems() { + return $this->items; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/RelationshipItem.php b/core/modules/jsonapi/src/Normalizer/RelationshipItem.php new file mode 100644 index 0000000..b10201e --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/RelationshipItem.php @@ -0,0 +1,101 @@ +targetResourceType = $resource_type_repository->get( + $target_entity->getEntityTypeId(), + $target_entity->bundle() + ); + $this->targetKey = $target_key; + $this->targetEntity = $target_entity; + $this->parent = $parent; + } + + /** + * Gets the target entity. + * + * @return \Drupal\Core\Entity\EntityInterface + */ + public function getTargetEntity() { + return $this->targetEntity; + } + + /** + * Gets the targetResourceConfig. + * + * @return mixed + */ + public function getTargetResourceType() { + return $this->targetResourceType; + } + + /** + * Gets the relationship value. + * + * Defaults to the entity ID. + * + * @return string + */ + public function getValue() { + return [$this->targetKey => $this->getTargetEntity()->uuid()]; + } + + /** + * Gets the relationship object that contains this relationship item. + * + * @return \Drupal\jsonapi\Normalizer\Relationship + */ + public function getParent() { + return $this->parent; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/RelationshipItemNormalizer.php b/core/modules/jsonapi/src/Normalizer/RelationshipItemNormalizer.php new file mode 100644 index 0000000..3c301c6 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/RelationshipItemNormalizer.php @@ -0,0 +1,139 @@ +resourceTypeRepository = $resource_type_repository; + $this->jsonapiDocumentToplevelNormalizer = $jsonapi_document_toplevel_normalizer; + } + + /** + * {@inheritdoc} + */ + public function normalize($relationship_item, $format = NULL, array $context = array()) { + /* @var $relationship_item \Drupal\jsonapi\Normalizer\RelationshipItem */ + // TODO: We are always loading the referenced entity. Even if it is not + // going to be included. That may be a performance issue. We do it because + // we need to know the entity type and bundle to load the JSON API resource + // type for the relationship item. We need a better way of finding about + // this. + $target_entity = $relationship_item->getTargetEntity(); + $values = $relationship_item->getValue(); + if (isset($context['langcode'])) { + $values['lang'] = $context['langcode']; + } + $normalizer_value = new RelationshipItemNormalizerValue( + $values, + $relationship_item->getTargetResourceType() + ); + + $host_field_name = $relationship_item->getParent()->getPropertyName(); + if (!empty($context['include']) && in_array($host_field_name, $context['include'])) { + $context = $this->buildSubContext($context, $target_entity, $host_field_name); + $entity_and_access = EntityResource::getEntityAndAccess($target_entity); + $included_normalizer_value = $this + ->jsonapiDocumentToplevelNormalizer + ->buildNormalizerValue($entity_and_access['entity'], $format, $context); + $normalizer_value->setInclude($included_normalizer_value); + $normalizer_value->addCacheableDependency($entity_and_access['access']); + $normalizer_value->addCacheableDependency($included_normalizer_value); + // Add the cacheable dependency of the included item directly to the + // response cacheable metadata. This is similar to the flatten include + // data structure, instead of a content graph. + if (!empty($context['cacheable_metadata'])) { + $context['cacheable_metadata']->addCacheableDependency($normalizer_value); + } + } + return $normalizer_value; + } + + /** + * Builds the sub-context for the relationship include. + * + * @param array $context + * The serialization context. + * @param \Drupal\Core\Entity\EntityInterface $entity + * The related entity. + * @param string $host_field_name + * The name of the field reference. + * + * @return array + * The modified new context. + */ + protected function buildSubContext($context, EntityInterface $entity, $host_field_name) { + // Swap out the context for the context of the referenced resource. + $context['resource_type'] = $this->resourceTypeRepository + ->get($entity->getEntityTypeId(), $entity->bundle()); + // Since we're going one level down the only includes we need are the ones + // that apply to this level as well. + $include_candidates = array_filter($context['include'], function ($include) use ($host_field_name) { + return strpos($include, $host_field_name . '.') === 0; + }); + $context['include'] = array_map(function ($include) use ($host_field_name) { + return str_replace($host_field_name . '.', '', $include); + }, $include_candidates); + return $context; + } + + /** + * {@inheritdoc} + */ + public function getUuid($data) { + if (isset($data['uuid'])) { + return NULL; + } + $uuid = $data['uuid']; + // The value may be a nested array like $uuid[0]['value']. + if (is_array($uuid) && isset($uuid[0]['value'])) { + $uuid = $uuid[0]['value']; + } + return $uuid; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php b/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php new file mode 100644 index 0000000..dc5aadd --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/RelationshipNormalizer.php @@ -0,0 +1,118 @@ +resourceTypeRepository = $resource_type_repository; + $this->linkManager = $link_manager; + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = array()) { + throw new UnexpectedValueException('Denormalization not implemented for JSON API'); + } + + /** + * Helper function to normalize field items. + * + * @param \Drupal\jsonapi\Normalizer\Relationship $relationship + * The field object. + * @param string $format + * The format. + * @param array $context + * The context array. + * + * @return \Drupal\jsonapi\Normalizer\Value\RelationshipNormalizerValue + * The array of normalized field items. + */ + public function normalize($relationship, $format = NULL, array $context = array()) { + /* @var \Drupal\jsonapi\Normalizer\Relationship $relationship */ + $normalizer_items = array(); + foreach ($relationship->getItems() as $relationship_item) { + $normalizer_items[] = $this->serializer->normalize($relationship_item, $format, $context); + } + $cardinality = $relationship->getCardinality(); + $link_context = [ + 'host_entity_id' => $relationship->getHostEntity()->uuid(), + 'field_name' => $relationship->getPropertyName(), + 'link_manager' => $this->linkManager, + 'resource_type' => $context['resource_type'], + ]; + return new RelationshipNormalizerValue($normalizer_items, $cardinality, $link_context); + } + + /** + * Builds the sub-context for the relationship include. + * + * @param array $context + * The serialization context. + * @param \Drupal\Core\Entity\EntityInterface $entity + * The related entity. + * @param string $host_field_name + * The name of the field reference. + * + * @return array + * The modified new context. + * + * @see EntityReferenceItemNormalizer::buildSubContext() + * @todo This is duplicated code from the reference item. Reuse code instead. + */ + protected function buildSubContext($context, EntityInterface $entity, $host_field_name) { + // Swap out the context for the context of the referenced resource. + $context['resource_type'] = $this->resourceTypeRepository + ->get($entity->getEntityTypeId(), $entity->bundle()); + // Since we're going one level down the only includes we need are the ones + // that apply to this level as well. + $include_candidates = array_filter($context['include'], function ($include) use ($host_field_name) { + return strpos($include, $host_field_name . '.') === 0; + }); + $context['include'] = array_map(function ($include) use ($host_field_name) { + return str_replace($host_field_name . '.', '', $include); + }, $include_candidates); + return $context; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/ScalarNormalizer.php b/core/modules/jsonapi/src/Normalizer/ScalarNormalizer.php new file mode 100644 index 0000000..6e84f87 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/ScalarNormalizer.php @@ -0,0 +1,42 @@ +formats); + } + + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = NULL, array $context = array()) { + $value = new FieldItemNormalizerValue(['value' => $object]); + return new FieldNormalizerValue([$value], 1); + } + + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = NULL, array $context = array()) { + throw new UnexpectedValueException('Denormalization not implemented for JSON API'); + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/UnprocessableHttpEntityExceptionNormalizer.php b/core/modules/jsonapi/src/Normalizer/UnprocessableHttpEntityExceptionNormalizer.php new file mode 100644 index 0000000..556defa --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/UnprocessableHttpEntityExceptionNormalizer.php @@ -0,0 +1,84 @@ +getViolations(); + $entity_violations = $violations->getEntityViolations(); + foreach ($entity_violations as $violation) { + /** @var \Symfony\Component\Validator\ConstraintViolation $violation */ + $error['detail'] = 'Entity is not valid: ' + . $violation->getMessage(); + $error['source']['pointer'] = '/data'; + $errors[] = $error; + } + + $entity = $violations->getEntity(); + foreach ($violations->getFieldNames() as $field_name) { + $field_violations = $violations->getByField($field_name); + $cardinality = $entity->get($field_name) + ->getFieldDefinition() + ->getFieldStorageDefinition() + ->getCardinality(); + + foreach ($field_violations as $violation) { + /** @var \Symfony\Component\Validator\ConstraintViolation $violation */ + $error['detail'] = $violation->getPropertyPath() . ': ' + . $violation->getMessage(); + + $pointer = '/data/attributes/' + . str_replace('.', '/', $violation->getPropertyPath()); + if ($cardinality == 1) { + // Remove erroneous '/0/' index for single-value fields. + $pointer = str_replace("/data/attributes/$field_name/0/", "/data/attributes/$field_name/", $pointer); + } + $error['source']['pointer'] = $pointer; + + $errors[] = $error; + } + } + + return $errors; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/Value/EntityNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/EntityNormalizerValue.php new file mode 100644 index 0000000..b30a79b --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/Value/EntityNormalizerValue.php @@ -0,0 +1,147 @@ +values = array_filter($values, function($value) { + return !($value instanceof NullFieldNormalizerValue); + }); + $this->context = $context; + $this->entity = $entity; + $this->linkManager = $link_context['link_manager']; + // Get an array of arrays of includes. + $this->includes = array_map(function ($value) { + return $value->getIncludes(); + }, $values); + // Flatten the includes. + $this->includes = array_reduce($this->includes, function ($carry, $includes) { + return array_merge($carry, $includes); + }, []); + // Filter the empty values. + $this->includes = array_filter($this->includes); + array_walk($this->includes, function ($include) { + $this->addCacheableDependency($include); + }); + } + + /** + * {@inheritdoc} + */ + public function rasterizeValue() { + // Create the array of normalized fields, starting with the URI. + $rasterized = [ + 'type' => $this->context['resource_type']->getTypeName(), + 'id' => $this->entity->uuid(), + 'attributes' => [], + 'relationships' => [], + ]; + $rasterized['links'] = [ + 'self' => $this->linkManager->getEntityLink( + $rasterized['id'], + $this->context['resource_type'], + [], + 'individual' + ), + ]; + + foreach ($this->getValues() as $field_name => $normalizer_value) { + $rasterized[$normalizer_value->getPropertyType()][$field_name] = $normalizer_value->rasterizeValue(); + } + return array_filter($rasterized); + } + + /** + * {@inheritdoc} + */ + public function rasterizeIncludes() { + // First gather all the includes in the chain. + return array_map(function ($include) { + return $include->rasterizeValue(); + }, $this->getIncludes()); + } + + /** + * Gets the values. + * + * @return mixed + * The values. + */ + public function getValues() { + return $this->values; + } + + /** + * Gets a flattened list of includes in all the chain. + * + * @return \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue[] + * The array of included relationships. + */ + public function getIncludes() { + $nested_includes = array_map(function ($include) { + return $include->getIncludes(); + }, $this->includes); + return array_reduce(array_filter($nested_includes), function ($carry, $item) { + return array_merge($carry, $item); + }, $this->includes); + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/Value/FieldItemNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/FieldItemNormalizerValue.php new file mode 100644 index 0000000..0291982 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/Value/FieldItemNormalizerValue.php @@ -0,0 +1,107 @@ +raw = $values; + } + + /** + * {@inheritdoc} + */ + public function rasterizeValue() { + // If there is only one property, then output it directly. + $value = count($this->raw) == 1 ? reset($this->raw) : $this->raw; + + return $this->rasterizeValueRecursive($value); + } + + /** + * {@inheritdoc} + */ + public function rasterizeIncludes() { + return $this->include->rasterizeValue(); + } + + /** + * Add an include. + * + * @param ValueExtractorInterface $include + * The included entity. + */ + public function setInclude(ValueExtractorInterface $include) { + $this->include = $include; + } + + /** + * Gets the include. + * + * @return \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue + * The include. + */ + public function getInclude() { + return $this->include; + } + + /** + * Rasterizes a value recursively. + * + * This is mainly for configuration entities where a field can be a tree of + * values to rasterize. + * + * @param mixed $value + * Either a scalar, an array or a rasterizable object. + * + * @return mixed + * The rasterized value. + */ + protected function rasterizeValueRecursive($value) { + if (!$value || is_scalar($value)) { + return $value; + } + if (is_array($value)) { + $output = []; + foreach ($value as $key => $item) { + $output[$key] = $this->rasterizeValueRecursive($item); + } + + return $output; + } + if ($value instanceof ValueExtractorInterface) { + return $value->rasterizeValue(); + } + // If the object can be turned into a string it's better than nothing. + if (method_exists($value, '__toString')) { + return $value->__toString(); + } + + // We give up, since we do not know how to rasterize this. + return NULL; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValue.php new file mode 100644 index 0000000..a3697d8 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValue.php @@ -0,0 +1,128 @@ +values = $values; + $this->includes = array_map(function ($value) { + return $value->getInclude(); + }, $values); + $this->includes = array_filter($this->includes); + $this->cardinality = $cardinality; + } + + /** + * {@inheritdoc} + */ + public function rasterizeValue() { + if (empty($this->values)) { + return NULL; + } + return $this->cardinality == 1 ? + $this->values[0]->rasterizeValue() : + array_map(function ($value) { + return $value->rasterizeValue(); + }, $this->values); + } + + /** + * {@inheritdoc} + */ + public function rasterizeIncludes() { + return array_map(function ($include) { + return $include->rasterizeValue(); + }, $this->includes); + } + + /** + * {@inheritdoc} + */ + public function getIncludes() { + return $this->includes; + } + + /** + * {@inheritdoc} + */ + public function getPropertyType() { + return $this->propertyType; + } + + /** + * {@inheritdoc} + */ + public function setPropertyType($property_type) { + $this->propertyType = $property_type; + } + + /** + * {@inheritdoc} + */ + public function setIncludes($includes) { + $this->includes = $includes; + } + + /** + * {@inheritdoc} + */ + public function getAllIncludes() { + $nested_includes = array_map(function ($include) { + return $include->getIncludes(); + }, $this->getIncludes()); + $includes = array_reduce(array_filter($nested_includes), function ($carry, $item) { + return array_merge($carry, $item); + }, $this->getIncludes()); + // Make sure we don't output duplicate includes. + return array_values(array_reduce($includes, function ($unique_includes, $include) { + $rasterized_include = $include->rasterizeValue(); + $unique_includes[$rasterized_include['data']['type'] . ':' . $rasterized_include['data']['id']] = $include; + return $unique_includes; + }, [])); + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValueInterface.php b/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValueInterface.php new file mode 100644 index 0000000..72f102d --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/Value/FieldNormalizerValueInterface.php @@ -0,0 +1,56 @@ +values = $values; + array_walk($values, function ($value) { + $this->addCacheableDependency($value); + }); + // Make sure that different sparse fieldsets are cached differently. + $this->addCacheableDependency(new RequestCacheabilityDependency()); + + $this->context = $context; + $this->isCollection = $is_collection; + $this->linkManager = $link_context['link_manager']; + // Remove the manager and store the link context. + unset($link_context['link_manager']); + $this->linkContext = $link_context; + // Get an array of arrays of includes. + $this->includes = array_map(function ($value) { + return $value->getIncludes(); + }, $values); + // Flatten the includes. + $this->includes = array_reduce($this->includes, function ($carry, $includes) { + array_walk($includes, function ($include) { + $this->addCacheableDependency($include); + }); + return array_merge($carry, $includes); + }, []); + // Filter the empty values. + $this->includes = array_filter($this->includes); + } + + /** + * {@inheritdoc} + */ + public function rasterizeValue() { + // Create the array of normalized fields, starting with the URI. + $rasterized = ['data' => []]; + + foreach ($this->values as $normalizer_value) { + if ($normalizer_value instanceof HttpExceptionNormalizerValue) { + $previous_errors = NestedArray::getValue($rasterized, ['meta', 'errors']) ?: []; + // Add the errors to the pre-existing errors. + $rasterized['meta']['errors'] = array_merge($previous_errors, $normalizer_value->rasterizeValue()); + } + else { + $rasterized['data'][] = $normalizer_value->rasterizeValue(); + } + } + $rasterized['data'] = array_filter($rasterized['data']); + // Deal with the single entity case. + $rasterized['data'] = $this->isCollection ? + $rasterized['data'] : + reset($rasterized['data']); + + // Add the self link. + if ($this->context['request']) { + /* @var \Symfony\Component\HttpFoundation\Request $request */ + $request = $this->context['request']; + $rasterized['links'] = [ + 'self' => $this->linkManager->getRequestLink($request), + ]; + // If this is a collection we need to append the pager links. + if ($this->isCollection) { + $rasterized['links'] += $this->linkManager->getPagerLinks($request, $this->linkContext); + } + } + return $rasterized; + } + + /** + * Gets a flattened list of includes in all the chain. + * + * @return \Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue[] + * The array of included relationships. + */ + public function getIncludes() { + $nested_includes = array_map(function ($include) { + return $include->getIncludes(); + }, $this->includes); + $includes = array_reduce(array_filter($nested_includes), function ($carry, $item) { + return array_merge($carry, $item); + }, $this->includes); + // Make sure we don't output duplicate includes. + return array_values(array_reduce($includes, function ($unique_includes, $include) { + $rasterized_include = $include->rasterizeValue(); + + $unique_key = $rasterized_include['data'] === FALSE ? + $rasterized_include['meta']['errors'][0]['detail'] : + $rasterized_include['data']['type'] . ':' . $rasterized_include['data']['id']; + $unique_includes[$unique_key] = $include; + return $unique_includes; + }, [])); + } + + /** + * {@inheritdoc} + */ + public function rasterizeIncludes() { + // First gather all the includes in the chain. + return array_map(function ($include) { + return $include->rasterizeValue(); + }, $this->getIncludes()); + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/Value/NullFieldNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/NullFieldNormalizerValue.php new file mode 100644 index 0000000..e11b9d1 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/Value/NullFieldNormalizerValue.php @@ -0,0 +1,45 @@ +propertyType; + } + + public function setPropertyType($property_type) { + $this->propertyType = $property_type; + return $this; + } + + public function rasterizeValue() { + return NULL; + } + + public function rasterizeIncludes() { + return []; + } + + public function setIncludes($includes) { + // Do nothing. + } + + public function getAllIncludes() { + return NULL; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/Value/RelationshipItemNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/RelationshipItemNormalizerValue.php new file mode 100644 index 0000000..8955eaf --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/Value/RelationshipItemNormalizerValue.php @@ -0,0 +1,58 @@ +resource = $resource; + } + + /** + * {@inheritdoc} + */ + public function rasterizeValue() { + if (!$value = parent::rasterizeValue()) { + return $value; + } + return [ + 'type' => $this->resource->getTypeName(), + 'id' => $value, + ]; + } + + /** + * Sets the resource. + * + * @param string $resource + * The resource to set. + */ + public function setResource($resource) { + $this->resource = $resource; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/Value/RelationshipNormalizerValue.php b/core/modules/jsonapi/src/Normalizer/Value/RelationshipNormalizerValue.php new file mode 100644 index 0000000..d4450d1 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/Value/RelationshipNormalizerValue.php @@ -0,0 +1,92 @@ +hostEntityId = $link_context['host_entity_id']; + $this->fieldName = $link_context['field_name']; + $this->linkManager = $link_context['link_manager']; + $this->resourceType = $link_context['resource_type']; + array_walk($values, function ($field_item_value) { + if (!$field_item_value instanceof RelationshipItemNormalizerValue) { + throw new \RuntimeException(sprintf('Unexpected normalizer item value for this %s.', get_called_class())); + } + }); + parent::__construct($values, $cardinality); + } + + /** + * {@inheritdoc} + */ + public function rasterizeValue() { + if (!$value = parent::rasterizeValue()) { + // According to the JSON API specs empty relationships are either NULL or + // an empty array. + return $this->cardinality == 1 ? ['data' => NULL] : ['data' => []]; + } + // Generate the links for the relationship. + $route_parameters = ['related' => $this->fieldName]; + return [ + 'data' => $value, + 'links' => [ + 'self' => $this->linkManager->getEntityLink( + $this->hostEntityId, + $this->resourceType, + $route_parameters, + 'relationship' + ), + 'related' => $this->linkManager->getEntityLink( + $this->hostEntityId, + $this->resourceType, + $route_parameters, + 'related' + ), + ], + ]; + } + +} diff --git a/core/modules/jsonapi/src/Normalizer/Value/ValueExtractorInterface.php b/core/modules/jsonapi/src/Normalizer/Value/ValueExtractorInterface.php new file mode 100644 index 0000000..a65a218 --- /dev/null +++ b/core/modules/jsonapi/src/Normalizer/Value/ValueExtractorInterface.php @@ -0,0 +1,26 @@ +getEntityTypeFromDefaults($definition, $name, $defaults); + if ($storage = $this->entityManager->getStorage($entity_type_id)) { + if (!$entities = $storage->loadByProperties(['uuid' => $value])) { + return NULL; + } + $entity = reset($entities); + // If the entity type is translatable, ensure we return the proper + // translation object for the current context. + if ($entity instanceof EntityInterface && $entity instanceof TranslatableInterface) { + $entity = $this->entityManager->getTranslationFromContext($entity, NULL, ['operation' => 'entity_upcast']); + } + return $entity; + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function applies($definition, $name, Route $route) { + return $route->getOption('_is_jsonapi'); + } + +} diff --git a/core/modules/jsonapi/src/Query/ConditionOption.php b/core/modules/jsonapi/src/Query/ConditionOption.php new file mode 100644 index 0000000..36f0277 --- /dev/null +++ b/core/modules/jsonapi/src/Query/ConditionOption.php @@ -0,0 +1,101 @@ +id = $id; + $this->field = $field; + $this->value = $value; + $this->operator = $operator; + $this->langcode = $langcode; + $this->parentId = $parent_id; + } + + /** + * {@inheritdoc} + */ + public function id() { + return $this->id; + } + + /** + * {@inheritdoc} + */ + public function apply($query) { + return $query->condition($this->field, $this->value, $this->operator, $this->langcode); + } + + /** + * Returns the id of this option's parent. + * + * @return string|null + * Either the id of its parent or NULL. + */ + public function parentId() { + return $this->parentId; + } + +} diff --git a/core/modules/jsonapi/src/Query/GroupOption.php b/core/modules/jsonapi/src/Query/GroupOption.php new file mode 100644 index 0000000..050ba63 --- /dev/null +++ b/core/modules/jsonapi/src/Query/GroupOption.php @@ -0,0 +1,157 @@ +id = $id; + $this->conjunction = $conjunction; + $this->parentGroup = $parent_group; + } + + /** + * {@inheritdoc} + */ + public function id() { + return $this->id; + } + + /** + * {@inheritdoc} + */ + public function parentId() { + return $this->parentGroup; + } + + /** + * {@inheritdoc} + */ + public function insert($target_id, QueryOptionInterface $option) { + $find_proper_id = function ($child_id, $group_option) use ($target_id) { + if ($child_id) { + return $child_id; + }; + return $group_option->hasChild($target_id) ? + $group_option->id() : + NULL; + }; + + if ($this->id() == $target_id) { + $prop = $option instanceof GroupOption ? 'childGroups' : 'childOptions'; + $this->{$prop}[$option->id()] = $option; + return TRUE; + } + elseif ($proper_child = array_reduce($this->childGroups, $find_proper_id, NULL)) { + return $this->childGroups[$proper_child]->insert($target_id, $option); + } + + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function apply($query) { + switch ($this->conjunction) { + case 'OR': + $group = $query->orConditionGroup(); + break; + + case 'AND': + default: + $group = $query->andConditionGroup(); + break; + } + + if (!empty($this->childOptions)) { + $group = array_reduce($this->childOptions, function ($group, $child) { + return $child->apply($group); + }, $group); + } + + if (!empty($this->childGroups)) { + $group = array_reduce($this->childGroups, function ($group, $child) { + return $child->apply($group); + }, $group); + } + + return $query->condition($group); + } + + /** + * {@inheritdoc} + */ + public function hasChild($id) { + // Return FALSE if this node has no child. + if (!isset($this->childOptions) || empty($this->childOptions)) { + return FALSE; + } + + // If any of the options have the specified id, return TRUE. + if (in_array($id, array_keys($this->childOptions))) { + return TRUE; + } + + // If any child GroupOptions or their children have the id return TRUE. + return array_reduce($this->groupOptions, function ($has_child, $group) use ($id) { + // If we already know that we have the child, skip evaluation and return. + if (!$has_child) { + // Determine if this group or any of its children match the $id. + $has_child = ($group->id() == $id || $group->hasChild($id)); + } + return $has_child; + }, FALSE); + } + +} diff --git a/core/modules/jsonapi/src/Query/OffsetPagerOption.php b/core/modules/jsonapi/src/Query/OffsetPagerOption.php new file mode 100644 index 0000000..9e08cf3 --- /dev/null +++ b/core/modules/jsonapi/src/Query/OffsetPagerOption.php @@ -0,0 +1,57 @@ +size = $size; + $this->offset = $offset ?: 0; + } + + /** + * {@inheritdoc} + */ + public function id() { + return 'offset_pager'; + } + + /** + * {@inheritdoc} + */ + public function apply($query) { + if (isset($this->offset) && isset($this->size)) { + // Request one extra entity to know if there is a next page. + $query->range($this->offset, $this->size + 1); + $query->addMetaData('pager_size', (int) $this->size); + } + + return $query; + } + +} diff --git a/core/modules/jsonapi/src/Query/QueryBuilder.php b/core/modules/jsonapi/src/Query/QueryBuilder.php new file mode 100644 index 0000000..f439aa5 --- /dev/null +++ b/core/modules/jsonapi/src/Query/QueryBuilder.php @@ -0,0 +1,363 @@ +entityTypeManager = $entity_type_manager; + $this->currentContext = $current_context; + $this->fieldResolver = $field_resolver; + } + + /** + * Creates a new Entity Query. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type for which to create a query. + * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params + * The JSON API parameters. + * + * @return \Drupal\Core\Entity\Query\QueryInterface + * The new query. + */ + public function newQuery(EntityTypeInterface $entity_type, array $params = []) { + $this->entityType = $entity_type; + + $this->configureFromContext($params); + + $query = $this->entityTypeManager + ->getStorage($this->entityType->id()) + ->getQuery() + ->accessCheck(TRUE); + + // This applies each option from the option tree to the query before + // returning it. + $applied_query = array_reduce($this->options, function ($query, $option) { + /* @var \Drupal\jsonapi\Query\QueryOptionInterface $option */ + return $option->apply($query); + }, $query); + + return $applied_query ? $applied_query : $query; + } + + /** + * Configure the query from the current context and the provided parameters. + * + * To avoid using the global context so much use the passed in parameters + * over the ones in the current context. + * + * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface[] $params + * The JSON API parameters. + */ + protected function configureFromContext(array $params = []) { + // TODO: Explore the possibility to turn JsonApiParam into a plugin type. + $param_keys = [Filter::KEY_NAME, Sort::KEY_NAME]; + foreach ($param_keys as $param_key) { + if (isset($params[$param_key])) { + $this->configureParam($param_key, $params[$param_key]); + } + elseif ($param = $this->currentContext->getJsonApiParameter($param_key)) { + $this->configureParam($param_key, $param); + } + } + // We always add a default pagination parameter. + $pager = isset($params[OffsetPage::KEY_NAME]) ? + $params[OffsetPage::KEY_NAME] : + new OffsetPage([]); + $this->configureParam(OffsetPage::KEY_NAME, $pager); + } + + /** + * Configure a parameter based on the type parameter type. + * + * @param string $type + * The parameter type. + * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param + * The parameter to configure. + */ + protected function configureParam($type, JsonApiParamInterface $param) { + switch ($type) { + case Filter::KEY_NAME: + $this->configureFilter($param); + break; + + case Sort::KEY_NAME: + $this->configureSort($param); + break; + + case OffsetPage::KEY_NAME: + $this->configurePager($param); + break; + } + } + + /** + * Configures the query builder from a Filter parameter. + * + * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param + * A Filter parameter from which to configure this query builder. + * + * @todo The nested closures passing parameters by reference may not be ideal. + */ + protected function configureFilter(JsonApiParamInterface $param) { + $extracted = []; + + foreach ($param->get() as $filter_index => $filter) { + foreach ($filter as $filter_type => $properties) { + switch ($filter_type) { + case Filter::CONDITION_KEY: + $extracted[] = $this->newCondtionOption($filter_index, $properties); + break; + + case Filter::GROUP_KEY: + $extracted[] = $this->newGroupOption($filter_index, $properties); + break; + + default: + throw new SerializableHttpException( + 400, + sprintf('Invalid syntax in the filter parameter: %s.', $filter_index) + ); + }; + } + } + + $this->buildTree($extracted); + } + + /** + * Configures the query builder from a Sort parameter. + * + * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param + * A Sort parameter from which to configure this query builder. + */ + protected function configureSort(JsonApiParamInterface $param) { + $extracted = []; + foreach ($param->get() as $sort_index => $sort) { + $extracted[] = $this->newSortOption(sprintf('sort_%s', $sort_index), $sort); + } + + $this->buildTree($extracted); + } + + /** + * Configures the query builder from a Pager parameter. + * + * @param \Drupal\jsonapi\Routing\Param\JsonApiParamInterface $param + * A pager parameter from which to configure this query builder. + */ + protected function configurePager(JsonApiParamInterface $param) { + $this->buildTree([$this->newPagerOption($param->get())]); + } + + /** + * Returns a new ConditionOption. + * + * @param string $condition_id + * A unique id for the option. + * @param array $properties + * The condition properties. + * + * @return \Drupal\jsonapi\Query\ConditionOption + * The condition object. + */ + protected function newCondtionOption($condition_id, array $properties) { + $langcode_key = $this->getLangcodeKey(); + $langcode = isset($properties[$langcode_key]) ? $properties[$langcode_key] : NULL; + $membership = isset($properties[Filter::MEMBER_KEY]) ? $properties[Filter::MEMBER_KEY] : NULL; + $field = isset($properties[Filter::PATH_KEY]) ? $properties[Filter::PATH_KEY] : NULL; + $value = isset($properties[Filter::VALUE_KEY]) ? $properties[Filter::VALUE_KEY] : NULL; + $operator = isset($properties[Filter::OPERATOR_KEY]) ? $properties[Filter::OPERATOR_KEY] : NULL; + return new ConditionOption( + $condition_id, + $this->fieldResolver->resolveInternal($field), + $value, + $operator, + $langcode, + $membership + ); + } + + /** + * Returns a new GroupOption. + * + * @param string $identifier + * A unique id for the option. + * @param array $properties + * The group properties. + * + * @return \Drupal\jsonapi\Query\GroupOption + * The group object. + */ + protected function newGroupOption($identifier, array $properties) { + $parent_group = isset($properties[Filter::MEMBER_KEY]) ? $properties[Filter::MEMBER_KEY] : NULL; + $conjunction = isset($properties[Filter::CONJUNCTION_KEY]) ? $properties[Filter::CONJUNCTION_KEY] : NULL; + return new GroupOption($identifier, $conjunction, $parent_group); + } + + /** + * Returns a new SortOption. + * + * @param string $identifier + * A unique id for the option. + * @param array $properties + * The sort properties. + * + * @return \Drupal\jsonapi\Query\SortOption + * The sort object. + */ + protected function newSortOption($identifier, array $properties) { + $field = isset($properties[Sort::FIELD_KEY]) ? $properties[Sort::FIELD_KEY] : NULL; + $direction = isset($properties[Sort::DIRECTION_KEY]) ? $properties[Sort::DIRECTION_KEY] : NULL; + $langcode = isset($properties[Sort::LANGUAGE_KEY]) ? $properties[Sort::LANGUAGE_KEY] : NULL; + return new SortOption( + $identifier, + $this->fieldResolver->resolveInternal($field), + $direction, + $langcode + ); + } + + /** + * Returns a new SortOption. + * + * @param array $properties + * The pager properties. + * + * @return \Drupal\jsonapi\Query\OffsetPagerOption + * The sort object. + */ + protected function newPagerOption(array $properties) { + // Add defaults to avoid unset warnings. + $properties += [ + 'limit' => NULL, + 'offset' => 0, + ]; + return new OffsetPagerOption($properties['limit'], $properties['offset']); + } + + /** + * Builds a tree of QueryOptions. + * + * @param \Drupal\jsonapi\Query\QueryOptionInterface[] $options + * An array of QueryOptions. + */ + protected function buildTree(array $options) { + $remaining = $options; + while (!empty($remaining)) { + $insert = array_pop($remaining); + if (method_exists($insert, 'parentId') && $parent_id = $insert->parentId()) { + if (!$this->insert($parent_id, $insert)) { + array_unshift($remaining, $insert); + } + } + else { + $this->options[$insert->id()] = $insert; + } + } + } + + /** + * Inserts a QueryOption into the appropriate child QueryOption. + * + * @param string $target_id + * Unique ID of the intended QueryOption parent. + * @param \Drupal\jsonapi\Query\QueryOptionInterface $option + * The QueryOption to insert. + * + * @return bool + * Whether the option could be inserted or not. + */ + protected function insert($target_id, QueryOptionInterface $option) { + if (!empty($this->options)) { + $find_target_child = function ($child, QueryOptionInterface $my_option) use ($target_id) { + if ($child) { + return $child; + } + if ( + $my_option->id() == $target_id || + (method_exists($my_option, 'hasChild') && $my_option->hasChild($target_id)) + ) { + return $my_option->id(); + } + return FALSE; + }; + + if ($appropriate_child = array_reduce($this->options, $find_target_child, NULL)) { + return $this->options[$appropriate_child]->insert($target_id, $option); + } + } + + return FALSE; + } + + /** + * Get the language code key. + * + * @return string + * The key. + */ + protected function getLangcodeKey() { + $entity_type_id = $this->currentContext->getResourceType() + ->getEntityTypeId(); + return $this->entityTypeManager + ->getDefinition($entity_type_id) + ->getKey('langcode'); + } + +} diff --git a/core/modules/jsonapi/src/Query/QueryOptionInterface.php b/core/modules/jsonapi/src/Query/QueryOptionInterface.php new file mode 100644 index 0000000..91110f0 --- /dev/null +++ b/core/modules/jsonapi/src/Query/QueryOptionInterface.php @@ -0,0 +1,29 @@ +id = $id; + $this->field = $field; + $this->direction = $direction; + $this->langcode = $langcode; + } + + /** + * {@inheritdoc} + */ + public function id() { + return $this->id; + } + + /** + * {@inheritdoc} + */ + public function apply($query) { + return $query->sort($this->field, $this->direction); + } + +} diff --git a/core/modules/jsonapi/src/RequestCacheabilityDependency.php b/core/modules/jsonapi/src/RequestCacheabilityDependency.php new file mode 100644 index 0000000..2825c7b --- /dev/null +++ b/core/modules/jsonapi/src/RequestCacheabilityDependency.php @@ -0,0 +1,34 @@ +entities = array_filter(array_values($entities)); + } + + /** + * Returns an iterator for entities. + * + * @return \ArrayIterator + * An \ArrayIterator instance + */ + public function getIterator() { + return new \ArrayIterator($this->entities); + } + + /** + * Returns the number of entities. + * + * @return int + * The number of parameters + */ + public function count() { + return count($this->entities); + } + + /** + * Returns the collection as an array. + * + * @return \Drupal\Core\Entity\EntityInterface[] + * The array of entities. + */ + public function toArray() { + return $this->entities; + } + + /** + * Checks if there is a next page in the collection. + * + * @return bool + * TRUE if the collection has a next page. + */ + public function hasNextPage() { + return (bool) $this->hasNextPage; + } + + /** + * Sets the has next page flag. + * + * Once the collection query has been executed and we build the entity collection, we now if there will be a next page + * with extra entities. + * + * @param bool $has_next_page + * TRUE if the collection has a next page. + */ + public function setHasNextPage($has_next_page) { + $this->hasNextPage = (bool) $has_next_page; + } + +} diff --git a/core/modules/jsonapi/src/Resource/JsonApiDocumentTopLevel.php b/core/modules/jsonapi/src/Resource/JsonApiDocumentTopLevel.php new file mode 100644 index 0000000..ccecd88 --- /dev/null +++ b/core/modules/jsonapi/src/Resource/JsonApiDocumentTopLevel.php @@ -0,0 +1,45 @@ +data = $data; + } + + /** + * Gets the data. + * + * @return \Drupal\Core\Entity\EntityInterface|\Drupal\jsonapi\EntityCollection + * The data. + */ + public function getData() { + return $this->data; + } + +} diff --git a/core/modules/jsonapi/src/ResourceResponse.php b/core/modules/jsonapi/src/ResourceResponse.php new file mode 100644 index 0000000..b421df8 --- /dev/null +++ b/core/modules/jsonapi/src/ResourceResponse.php @@ -0,0 +1,57 @@ +responseData = $data; + parent::__construct('', $status, $headers); + } + + /** + * Returns response data that should be serialized. + * + * @return mixed + * Response data that should be serialized. + */ + public function getResponseData() { + return $this->responseData; + } + +} diff --git a/core/modules/jsonapi/src/ResourceResponseInterface.php b/core/modules/jsonapi/src/ResourceResponseInterface.php new file mode 100644 index 0000000..3ad91e6 --- /dev/null +++ b/core/modules/jsonapi/src/ResourceResponseInterface.php @@ -0,0 +1,20 @@ +entityTypeId; + } + + /** + * Gets the type name. + * + * @return string + * The type name. + */ + public function getTypeName() { + return $this->typeName; + } + + /** + * Gets the bundle. + * + * @return string + * The bundle of the entity. Defaults to the entity type ID if the entity + * type does not make use of different bundles. + * + * @see \Drupal\Core\Entity\EntityInterface::bundle + */ + public function getBundle() { + return $this->bundle; + } + + /** + * Gets the deserialization target class. + * + * @return string + * The deserialization target class. + */ + public function getDeserializationTargetClass() { + return $this->deserializationTargetClass; + } + + /** + * Instantiates a ResourceType object. + * + * @param string $entity_type_id + * An entity type ID. + * @param string $bundle + * A bundle. + * @param string $deserialization_target_class + * The deserialization target class. + */ + public function __construct($entity_type_id, $bundle, $deserialization_target_class) { + $this->entityTypeId = $entity_type_id; + $this->bundle = $bundle; + $this->deserializationTargetClass = $deserialization_target_class; + + $this->typeName = sprintf('%s--%s', $this->entityTypeId, $this->bundle); + } + +} diff --git a/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php new file mode 100644 index 0000000..876e099 --- /dev/null +++ b/core/modules/jsonapi/src/ResourceType/ResourceTypeRepository.php @@ -0,0 +1,106 @@ +entityTypeManager = $entity_type_manager; + $this->bundleManager = $bundle_manager; + } + + /** + * Gets all JSON API resource types. + * + * @return \Drupal\jsonapi\ResourceType\ResourceType[] + * The set of all JSON API resource types in this Drupal instance. + */ + public function all() { + if (!$this->all) { + $entity_type_ids = array_keys($this->entityTypeManager->getDefinitions()); + foreach ($entity_type_ids as $entity_type_id) { + $this->all = array_merge($this->all, array_map(function ($bundle) use ($entity_type_id) { + return new ResourceType( + $entity_type_id, + $bundle, + $this->entityTypeManager->getDefinition($entity_type_id)->getClass() + ); + }, array_keys($this->bundleManager->getBundleInfo($entity_type_id)))); + } + } + return $this->all; + } + + /** + * Gets a specific JSON API resource type based on entity type ID and bundle. + * + * @param string $entity_type_id + * The entity type id. + * @param string $bundle_id + * The id for the bundle to find. + * + * @return \Drupal\jsonapi\ResourceType\ResourceType + * The requested JSON API resource type, if it exists. NULL otherwise. + */ + public function get($entity_type_id, $bundle) { + if (empty($entity_type_id)) { + throw new PreconditionFailedHttpException('Server error. The current route is malformed.'); + } + foreach ($this->all(TRUE) as $resource) { + if ($resource->getEntityTypeId() == $entity_type_id && $resource->getBundle() == $bundle) { + return $resource; + } + } + return NULL; + } + +} diff --git a/core/modules/jsonapi/src/Routing/JsonApiParamEnhancer.php b/core/modules/jsonapi/src/Routing/JsonApiParamEnhancer.php new file mode 100644 index 0000000..b0274d9 --- /dev/null +++ b/core/modules/jsonapi/src/Routing/JsonApiParamEnhancer.php @@ -0,0 +1,66 @@ +fieldManager = $field_manager; + } + + /** + * {@inheritdoc} + */ + public function applies(Route $route) { + // This enhancer applies to the JSON API routes. + return $route->getDefault(RouteObjectInterface::CONTROLLER_NAME) == Routes::FRONT_CONTROLLER; + } + + /** + * {@inheritdoc} + */ + public function enhance(array $defaults, Request $request) { + $options = []; + if ($request->query->has('filter')) { + $entity_type_id = $defaults[RouteObjectInterface::ROUTE_OBJECT]->getRequirement('_entity_type'); + $options['filter'] = new Filter($request->query->get('filter'), $entity_type_id, $this->fieldManager); + } + if ($request->query->has('sort')) { + $options['sort'] = new Sort($request->query->get('sort')); + } + if ($request->query->has('page')) { + $options['page'] = new OffsetPage($request->query->get('page'), OffsetPage::$maxSize); + } + else { + $options['page'] = new OffsetPage(['start' => 0, 'limit' => OffsetPage::$maxSize], OffsetPage::$maxSize); + } + $defaults['_json_api_params'] = $options; + return $defaults; + } + +} diff --git a/core/modules/jsonapi/src/Routing/Param/Filter.php b/core/modules/jsonapi/src/Routing/Param/Filter.php new file mode 100644 index 0000000..3869db3 --- /dev/null +++ b/core/modules/jsonapi/src/Routing/Param/Filter.php @@ -0,0 +1,115 @@ +] parameter for conditions. + * + * @var string + */ + const CONDITION_KEY = 'condition'; + + /** + * Key in the filter[] parameter for groups. + * + * @var string + */ + const GROUP_KEY = 'group'; + + /** + * Key in the filter[][] parameter for group membership. + * + * @var string + */ + const MEMBER_KEY = 'memberOf'; + + /** + * The field key in the filter condition: filter[lorem][condition][]. + * + * @var string + */ + const PATH_KEY = 'path'; + + /** + * The value key in the filter condition: filter[lorem][condition][]. + * + * @var string + */ + const VALUE_KEY = 'value'; + + /** + * The operator key in the condition: filter[lorem][condition][]. + * + * @var string + */ + const OPERATOR_KEY = 'operator'; + + /** + * The conjunction key in the condition: filter[lorem][group][]. + * + * @var string + */ + const CONJUNCTION_KEY = 'conjunction'; + + /** + * {@inheritdoc} + */ + protected function expand() { + // We should always get an array for the filter. + if (!is_array($this->original)) { + throw new SerializableHttpException(400, 'Incorrect value passed to the filter parameter.'); + } + + $expanded = []; + foreach ($this->original as $filter_index => $filter_item) { + $expanded[$filter_index] = $this->expandItem($filter_index, $filter_item); + } + return $expanded; + } + + /** + * Expands a filter item in case a shortcut was used. + * + * Possible cases for the conditions: + * 1. filter[uuid][value]=1234. + * 2. filter[0][condition][field]=uuid&filter[0][condition][value]=1234. + * 3. filter[uuid][condition][value]=1234. + * 4. filter[uuid][value]=1234&filter[uuid][group]=my_group. + * + * @param string $filter_index + * The index. + * @param array $filter_item + * The raw filter item. + * + * @return array + * The expanded filter item. + */ + protected function expandItem($filter_index, array $filter_item) { + if (isset($filter_item[static::VALUE_KEY])) { + if (!isset($filter_item[static::PATH_KEY])) { + $filter_item[static::PATH_KEY] = $filter_index; + } + $filter_item = [ + static::CONDITION_KEY => $filter_item, + ]; + + if (!isset($filter_item[static::CONDITION_KEY][static::OPERATOR_KEY])) { + $filter_item[static::CONDITION_KEY][static::OPERATOR_KEY] = '='; + } + } + + return $filter_item; + } + +} diff --git a/core/modules/jsonapi/src/Routing/Param/JsonApiParamBase.php b/core/modules/jsonapi/src/Routing/Param/JsonApiParamBase.php new file mode 100644 index 0000000..21cb503 --- /dev/null +++ b/core/modules/jsonapi/src/Routing/Param/JsonApiParamBase.php @@ -0,0 +1,69 @@ +original = $original; + } + + /** + * {@inheritdoc} + */ + public function get() { + if (!$this->data) { + $this->data = $this->expand(); + } + return $this->data; + } + + /** + * {@inheritdoc} + */ + public function getOriginal() { + return $this->original; + } + + /** + * {@inheritdoc} + */ + public function getKey() { + return static::KEY_NAME; + } + + /** + * Apply all necessary defaults and transformations to the parameter. + * + * @return string|string[] + * The expanded data. + */ + protected function expand() { + // The base implementation does no expansion. + return $this->original; + } + +} diff --git a/core/modules/jsonapi/src/Routing/Param/JsonApiParamInterface.php b/core/modules/jsonapi/src/Routing/Param/JsonApiParamInterface.php new file mode 100644 index 0000000..bb757aa --- /dev/null +++ b/core/modules/jsonapi/src/Routing/Param/JsonApiParamInterface.php @@ -0,0 +1,41 @@ +original)) { + throw new SerializableHttpException(400, 'The page parameter needs to be an array.'); + } + $output = $this->original + ['limit' => static::$maxSize]; + $output['limit'] = $output['limit'] > static::$maxSize ? + static::$maxSize : + $output['limit']; + return $output; + } + + /** + * Returns the current offset. + * + * @return int + */ + public function getOffset() { + $data = $this->get(); + return isset($data['offset']) ? $data['offset'] : 0; + } + + /** + * Returns the page size. + * + * @return int + */ + public function getSize() { + $data = $this->get(); + $size = isset($data['limit']) ? $data['limit'] : static::$maxSize; + return $size > static::$maxSize ? static::$maxSize : $size; + } + +} diff --git a/core/modules/jsonapi/src/Routing/Param/Sort.php b/core/modules/jsonapi/src/Routing/Param/Sort.php new file mode 100644 index 0000000..3e78b4e --- /dev/null +++ b/core/modules/jsonapi/src/Routing/Param/Sort.php @@ -0,0 +1,131 @@ +]. + * + * @var string + */ + const FIELD_KEY = 'path'; + + /** + * The direction key in the sort parameter: sort[lorem][]. + * + * @var string + */ + const DIRECTION_KEY = 'direction'; + + /** + * The langcode key in the sort parameter: sort[lorem][]. + * + * @var string + */ + const LANGUAGE_KEY = 'langcode'; + + /** + * The conjunction key in the condition: filter[lorem][group][]. + * + * @var string + */ + + /** + * {@inheritdoc} + */ + protected function expand() { + $sort = $this->original; + + if (empty($sort)) { + throw new SerializableHttpException(400, 'You need to provide a value for the sort parameter.'); + } + + // Expand a JSON API compliant sort into a more expressive sort parameter. + if (is_string($sort)) { + $sort = $this->expandFieldString($sort); + } + + // Expand any defaults into the sort array. + $expanded = []; + foreach ($sort as $sort_index => $sort_item) { + $expanded[$sort_index] = $this->expandItem($sort_index, $sort_item); + } + + return $expanded; + } + + /** + * Expands a simple string sort into a more expressive sort that we can use. + * + * @param string $fields + * The comma separated list of fields to expand into an array. + * + * @return array + * The expanded sort. + */ + protected function expandFieldString($fields) { + return array_map(function ($field) { + $sort = []; + + if ($field[0] == '-') { + $sort[static::DIRECTION_KEY] = 'DESC'; + $sort[static::FIELD_KEY] = substr($field, 1); + } + else { + $sort[static::DIRECTION_KEY] = 'ASC'; + $sort[static::FIELD_KEY] = $field; + } + + return $sort; + }, explode(',', $fields)); + } + + /** + * Expands a sort item in case a shortcut was used. + * + * @param string $sort_index + * Unique identifier for the sort parameter being expanded. + * @param array $sort_item + * The raw sort item. + * + * @return array + * The expanded sort item. + */ + protected function expandItem($sort_index, array $sort_item) { + $defaults = [ + static::DIRECTION_KEY => 'ASC', + static::LANGUAGE_KEY => NULL, + ]; + + if (!isset($sort_item[static::FIELD_KEY])) { + throw new SerializableHttpException(400, 'You need to provide a field name for the sort parameter.'); + } + + $expected_keys = [ + static::FIELD_KEY, + static::DIRECTION_KEY, + static::LANGUAGE_KEY, + ]; + + $expanded = array_merge($defaults, $sort_item); + + // Verify correct sort keys. + if (count(array_diff($expected_keys, array_keys($expanded))) > 0) { + throw new SerializableHttpException(400, 'You have provided an invalid set of sort keys.'); + } + + return $expanded; + } + +} diff --git a/core/modules/jsonapi/src/Routing/RouteEnhancer.php b/core/modules/jsonapi/src/Routing/RouteEnhancer.php new file mode 100644 index 0000000..b9844a0 --- /dev/null +++ b/core/modules/jsonapi/src/Routing/RouteEnhancer.php @@ -0,0 +1,43 @@ +getRequirement('_bundle') && (bool) $route->getRequirement('_entity_type'); + } + + /** + * {@inheritdoc} + */ + public function enhance(array $defaults, Request $request) { + $route = $defaults[RouteObjectInterface::ROUTE_OBJECT]; + $entity_type = $route->getRequirement('_entity_type'); + if (!isset($defaults[$entity_type]) || !($entity = $defaults[$entity_type])) { + return $defaults; + } + $retrieved_bundle = $entity->bundle(); + $configured_bundle = $route->getRequirement('_bundle'); + if ($retrieved_bundle != $configured_bundle) { + // If the bundle in the loaded entity does not match the bundle in the + // route (which is set based on the corresponding ResourceType), then + // throw an exception. + throw new SerializableHttpException(404, sprintf('The loaded entity bundle (%s) does not match the configured resource (%s).', $retrieved_bundle, $configured_bundle)); + } + return $defaults; + } + +} diff --git a/core/modules/jsonapi/src/Routing/Routes.php b/core/modules/jsonapi/src/Routing/Routes.php new file mode 100644 index 0000000..2ee225c --- /dev/null +++ b/core/modules/jsonapi/src/Routing/Routes.php @@ -0,0 +1,171 @@ +resourceTypeRepository = $resource_type_repository; + $this->authCollector = $auth_collector; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + /* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepository $resource_type_repository */ + $resource_type_repository = $container->get('jsonapi.resource_type.repository'); + /* @var \Drupal\Core\Authentication\AuthenticationCollectorInterface $auth_collector */ + $auth_collector = $container->get('authentication_collector'); + + return new static($resource_type_repository, $auth_collector); + } + + /** + * {@inheritdoc} + */ + public function routes() { + $collection = new RouteCollection(); + foreach ($this->resourceTypeRepository->all() as $resource_type) { + $route_base_path = sprintf('/jsonapi/%s/%s', $resource_type->getEntityTypeId(), $resource_type->getBundle()); + $build_route_name = function ($key) use ($resource_type) { + return sprintf('jsonapi.%s.%s', $resource_type->getTypeName(), $key); + }; + + $defaults = [ + RouteObjectInterface::CONTROLLER_NAME => static::FRONT_CONTROLLER, + ]; + // Options that apply to all routes. + $options = [ + '_auth' => $this->authProviderList(), + '_is_jsonapi' => TRUE, + ]; + + // Collection endpoint, like /jsonapi/file/photo. + $route_collection = (new Route($route_base_path, $defaults)) + ->setRequirement('_entity_type', $resource_type->getEntityTypeId()) + ->setRequirement('_bundle', $resource_type->getBundle()) + ->setRequirement('_permission', 'access content') + ->setRequirement('_format', 'api_json') + ->setRequirement('_custom_parameter_names', 'TRUE') + ->setOption('serialization_class', JsonApiDocumentTopLevel::class) + ->setMethods(['GET', 'POST']); + $route_collection->addOptions($options); + $collection->add($build_route_name('collection'), $route_collection); + + // Individual endpoint, like /jsonapi/file/photo/123. + $parameters = [$resource_type->getEntityTypeId() => ['type' => 'entity:' . $resource_type->getEntityTypeId()]]; + $route_individual = (new Route(sprintf('%s/{%s}', $route_base_path, $resource_type->getEntityTypeId()))) + ->addDefaults($defaults) + ->setRequirement('_entity_type', $resource_type->getEntityTypeId()) + ->setRequirement('_bundle', $resource_type->getBundle()) + ->setRequirement('_permission', 'access content') + ->setRequirement('_format', 'api_json') + ->setRequirement('_custom_parameter_names', 'TRUE') + ->setOption('parameters', $parameters) + ->setOption('_auth', $this->authProviderList()) + ->setOption('serialization_class', JsonApiDocumentTopLevel::class) + ->setMethods(['GET', 'PATCH', 'DELETE']); + $route_individual->addOptions($options); + $collection->add($build_route_name('individual'), $route_individual); + + // Related resource, like /jsonapi/file/photo/123/comments. + $route_related = (new Route(sprintf('%s/{%s}/{related}', $route_base_path, $resource_type->getEntityTypeId()), $defaults)) + ->setRequirement('_entity_type', $resource_type->getEntityTypeId()) + ->setRequirement('_bundle', $resource_type->getBundle()) + ->setRequirement('_permission', 'access content') + ->setRequirement('_format', 'api_json') + ->setRequirement('_custom_parameter_names', 'TRUE') + ->setOption('parameters', $parameters) + ->setOption('_auth', $this->authProviderList()) + ->setMethods(['GET']); + $route_related->addOptions($options); + $collection->add($build_route_name('related'), $route_related); + + // Related endpoint, like /jsonapi/file/photo/123/relationships/comments. + $route_relationship = (new Route(sprintf('%s/{%s}/relationships/{related}', $route_base_path, $resource_type->getEntityTypeId()), $defaults + ['_on_relationship' => TRUE])) + ->setRequirement('_entity_type', $resource_type->getEntityTypeId()) + ->setRequirement('_bundle', $resource_type->getBundle()) + ->setRequirement('_permission', 'access content') + ->setRequirement('_format', 'api_json') + ->setRequirement('_custom_parameter_names', 'TRUE') + ->setOption('parameters', $parameters) + ->setOption('_auth', $this->authProviderList()) + ->setOption('serialization_class', EntityReferenceFieldItemList::class) + ->setMethods(['GET', 'POST', 'PATCH', 'DELETE']); + $route_relationship->addOptions($options); + $collection->add($build_route_name('relationship'), $route_relationship); + } + + return $collection; + } + + /** + * Build a list of authentication provider ids. + * + * @return string[] + * The list of IDs. + */ + protected function authProviderList() { + if (isset($this->providerIds)) { + return $this->providerIds; + } + $this->providerIds = array_keys($this->authCollector->getSortedProviders()); + + return $this->providerIds; + } + +} diff --git a/core/modules/jsonapi/tests/phpunit.xml b/core/modules/jsonapi/tests/phpunit.xml new file mode 100644 index 0000000..b3bff7a --- /dev/null +++ b/core/modules/jsonapi/tests/phpunit.xml @@ -0,0 +1,18 @@ + + + + + + ./src/ + + + + + + ./vendor + + + ../src + + + diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php new file mode 100644 index 0000000..87fdadb --- /dev/null +++ b/core/modules/jsonapi/tests/src/Functional/JsonApiFunctionalTest.php @@ -0,0 +1,749 @@ +httpClient = $this->container->get('http_client_factory') + ->fromOptions(['base_uri' => $this->baseUrl]); + + // Create Basic page and Article node types. + if ($this->profile != 'standard') { + $this->drupalCreateContentType(array( + 'type' => 'article', + 'name' => 'Article', + )); + + // Setup vocabulary. + Vocabulary::create([ + 'vid' => 'tags', + 'name' => 'Tags', + ])->save(); + + // Add tags and field_image to the article. + $this->createEntityReferenceField( + 'node', + 'article', + 'field_tags', + 'Tags', + 'taxonomy_term', + 'default', + [ + 'target_bundles' => [ + 'tags' => 'tags', + ], + 'auto_create' => TRUE, + ], + FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED + ); + $this->createImageField('field_image', 'article'); + } + + FieldStorageConfig::create(array( + 'field_name' => 'field_link', + 'entity_type' => 'node', + 'type' => 'link', + 'settings' => [], + 'cardinality' => 1, + ))->save(); + + $field_config = FieldConfig::create([ + 'field_name' => 'field_link', + 'label' => 'Link', + 'entity_type' => 'node', + 'bundle' => 'article', + 'required' => FALSE, + 'settings' => [], + 'description' => '', + ]); + $field_config->save(); + + $this->user = $this->drupalCreateUser([ + 'create article content', + 'edit any article content', + 'delete any article content', + ]); + + // Create a user that can + $this->userCanViewProfiles = $this->drupalCreateUser([ + 'access user profiles', + ]); + + $this->grantPermissions(Role::load(RoleInterface::ANONYMOUS_ID), [ + 'access user profiles', + 'administer taxonomy', + ]); + + drupal_flush_all_caches(); + } + + /** + * {@inheritdoc} + */ + protected function drupalGet($path, array $options = array(), array $headers = array()) { + // Make sure we don't forget the format parameter. + $options += ['query' => []]; + $options['query'] += ['_format' => 'api_json']; + + return parent::drupalGet($path, $options, $headers); + } + + /** + * Performs a HTTP request. Wraps the Guzzle HTTP client. + * + * Why wrap the Guzzle HTTP client? Because any error response is returned via + * an exception, which would make the tests unnecessarily complex to read. + * + * @see \GuzzleHttp\ClientInterface::request() + * + * @param string $method + * HTTP method. + * @param \Drupal\Core\Url $url + * URL to request. + * @param array $request_options + * Request options to apply. + * + * @return \Psr\Http\Message\ResponseInterface + */ + protected function request($method, Url $url, array $request_options) { + $url->setOption('query', ['_format' => 'api_json']); + try { + $response = $this->httpClient->request($method, $url->toString(), $request_options); + } + catch (ClientException $e) { + $response = $e->getResponse(); + } + catch (ServerException $e) { + $response = $e->getResponse(); + } + + return $response; + } + + /** + * Test the GET method. + */ + public function testRead() { + $this->createDefaultContent(60, 5, TRUE, TRUE); + // 1. Load all articles (1st page). + $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article')); + $this->assertSession()->statusCodeEquals(200); + $this->assertEquals(OffsetPage::$maxSize, count($collection_output['data'])); + $this->assertSession() + ->responseHeaderEquals('Content-Type', 'application/vnd.api+json'); + // 2. Load all articles (Offset 3). + $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [ + 'query' => ['page' => ['offset' => 3]], + ])); + $this->assertSession()->statusCodeEquals(200); + $this->assertEquals(OffsetPage::$maxSize, count($collection_output['data'])); + $this->assertContains('page[offset]=53', $collection_output['links']['next']); + // 3. Load all articles (1st page, 2 items) + $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [ + 'query' => ['page' => ['limit' => 2]], + ])); + $this->assertSession()->statusCodeEquals(200); + $this->assertEquals(2, count($collection_output['data'])); + // 4. Load all articles (2nd page, 2 items). + $collection_output = Json::decode($this->drupalGet('/jsonapi/node/article', [ + 'query' => [ + 'page' => [ + 'limit' => 2, + 'offset' => 2, + ], + ], + ])); + $this->assertSession()->statusCodeEquals(200); + $this->assertEquals(2, count($collection_output['data'])); + $this->assertContains('page[offset]=4', $collection_output['links']['next']); + // 5. Single article. + $uuid = $this->nodes[0]->uuid(); + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid)); + $this->assertSession()->statusCodeEquals(200); + $this->assertArrayHasKey('type', $single_output['data']); + $this->assertEquals($this->nodes[0]->getTitle(), $single_output['data']['attributes']['title']); + // 6. Single relationship item. + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/type')); + $this->assertSession()->statusCodeEquals(200); + $this->assertArrayHasKey('type', $single_output['data']); + $this->assertArrayNotHasKey('attributes', $single_output['data']); + $this->assertArrayHasKey('related', $single_output['links']); + // 7. Single relationship image. + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/field_image')); + $this->assertSession()->statusCodeEquals(200); + $this->assertArrayHasKey('type', $single_output['data']); + $this->assertArrayNotHasKey('attributes', $single_output['data']); + $this->assertArrayHasKey('related', $single_output['links']); + // 8. Multiple relationship item. + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/field_tags')); + $this->assertSession()->statusCodeEquals(200); + $this->assertArrayHasKey('type', $single_output['data'][0]); + $this->assertArrayNotHasKey('attributes', $single_output['data'][0]); + $this->assertArrayHasKey('related', $single_output['links']); + // 9. Related tags with includes. + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/field_tags', [ + 'query' => ['include' => 'vid'], + ])); + $this->assertSession()->statusCodeEquals(200); + $this->assertEquals('taxonomy_term--tags', $single_output['data'][0]['type']); + $this->assertArrayHasKey('tid', $single_output['data'][0]['attributes']); + $this->assertContains( + '/taxonomy_term/tags/', + $single_output['data'][0]['links']['self'] + ); + $this->assertEquals( + 'taxonomy_vocabulary--taxonomy_vocabulary', + $single_output['included'][0]['type'] + ); + // 10. Single article with includes. + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid, [ + 'query' => ['include' => 'uid,field_tags'], + ])); + $this->assertSession()->statusCodeEquals(200); + $this->assertEquals('node--article', $single_output['data']['type']); + $first_include = reset($single_output['included']); + $this->assertEquals( + 'user--user', + $first_include['type'] + ); + $last_include = end($single_output['included']); + $this->assertEquals( + 'taxonomy_term--tags', + $last_include['type'] + ); + // 11. Includes with relationships. + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article/' . $uuid . '/relationships/uid', [ + 'query' => ['include' => 'uid'], + ])); + $this->assertSession()->statusCodeEquals(200); + $this->assertEquals('user--user', $single_output['data']['type']); + $this->assertArrayHasKey('related', $single_output['links']); + $first_include = reset($single_output['included']); + $this->assertEquals( + 'user--user', + $first_include['type'] + ); + $this->assertFalse(empty($first_include['attributes'])); + $this->assertTrue(empty($first_include['attributes']['mail'])); + $this->assertTrue(empty($first_include['attributes']['pass'])); + // 12. Collection with one access denied + $this->nodes[1]->set('status', FALSE); + $this->nodes[1]->save(); + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [ + 'query' => ['page' => ['limit' => 2]], + ])); + $this->assertSession()->statusCodeEquals(200); + $this->assertEquals(1, count($single_output['data'])); + $this->assertEquals(1, count($single_output['meta']['errors'])); + $this->assertEquals(403, $single_output['meta']['errors'][0]['status']); + $this->nodes[1]->set('status', TRUE); + $this->nodes[1]->save(); + // 13. Test filtering when using short syntax. + $filter = [ + 'uid.uuid' => ['value' => $this->user->uuid()], + 'field_tags.uuid' => ['value' => $this->tags[0]->uuid()], + ]; + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [ + 'query' => ['filter' => $filter, 'include' => 'uid,field_tags'], + ])); + $this->assertSession()->statusCodeEquals(200); + $this->assertGreaterThan(0, count($single_output['data'])); + // 14. Test filtering when using long syntax. + $filter = [ + 'and_group' => ['group' => ['conjunction' => 'AND']], + 'filter_user' => [ + 'condition' => [ + 'path' => 'uid.uuid', + 'value' => $this->user->uuid(), + 'memberOf' => 'and_group', + ], + ], + 'filter_tags' => [ + 'condition' => [ + 'path' => 'field_tags.uuid', + 'value' => $this->tags[0]->uuid(), + 'memberOf' => 'and_group', + ], + ], + ]; + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [ + 'query' => ['filter' => $filter, 'include' => 'uid,field_tags'], + ])); + $this->assertSession()->statusCodeEquals(200); + $this->assertGreaterThan(0, count($single_output['data'])); + // 15. Test filtering when using invalid syntax. + $filter = [ + 'and_group' => ['group' => ['conjunction' => 'AND']], + 'filter_user' => [ + 'condition' => [ + 'name-with-a-typo' => 'uid.uuid', + 'value' => $this->user->uuid(), + 'memberOf' => 'and_group', + ], + ], + ]; + $this->drupalGet('/jsonapi/node/article', [ + 'query' => ['filter' => $filter], + ]); + $this->assertSession()->statusCodeEquals(400); + // 16. Test filtering on the same field. + $filter = [ + 'or_group' => ['group' => ['conjunction' => 'OR']], + 'filter_tags_1' => [ + 'condition' => [ + 'path' => 'field_tags.uuid', + 'value' => $this->tags[0]->uuid(), + 'memberOf' => 'or_group', + ], + ], + 'filter_tags_2' => [ + 'condition' => [ + 'path' => 'field_tags.uuid', + 'value' => $this->tags[1]->uuid(), + 'memberOf' => 'or_group', + ], + ], + ]; + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [ + 'query' => ['filter' => $filter, 'include' => 'field_tags'], + ])); + $this->assertSession()->statusCodeEquals(200); + $this->assertGreaterThanOrEqual(2, count($single_output['included'])); + // 17. Single user (check fields lacking 'view' access). + $user_url = Url::fromRoute('jsonapi.user--user.individual', [ + 'user' => $this->user->uuid(), + ]); + $response = $this->request('GET', $user_url, [ + 'auth' => [ + $this->userCanViewProfiles->getUsername(), + $this->userCanViewProfiles->pass_raw, + ], + ]); + $single_output = Json::decode($response->getBody()->__toString()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('user--user', $single_output['data']['type']); + $this->assertEquals($this->user->get('name')->value, $single_output['data']['attributes']['name']); + $this->assertTrue(empty($single_output['data']['attributes']['mail'])); + $this->assertTrue(empty($single_output['data']['attributes']['pass'])); + // 18. Test filtering on the column of a link. + $filter = [ + 'linkUri' => [ + 'condition' => [ + 'path' => 'field_link.uri', + 'value' => 'https://', + 'operator' => 'STARTS_WITH', + ], + ], + ]; + $single_output = Json::decode($this->drupalGet('/jsonapi/node/article', [ + 'query' => ['filter' => $filter], + ])); + $this->assertSession()->statusCodeEquals(200); + $this->assertGreaterThanOrEqual(1, count($single_output['data'])); + } + + /** + * Test POST, PATCH and DELETE. + */ + public function testWrite() { + $this->createDefaultContent(0, 3, FALSE, FALSE); + // 1. Successful post. + $collection_url = Url::fromRoute('jsonapi.node--article.collection'); + $body = [ + 'data' => [ + 'type' => 'node--article', + 'attributes' => [ + 'langcode' => 'en', + 'title' => 'My custom title', + 'status' => '1', + 'promote' => '1', + 'sticky' => '0', + 'default_langcode' => '1', + 'body' => [ + 'value' => 'Custom value', + 'format' => 'plain_text', + 'summary' => 'Custom summary', + ], + ], + 'relationships' => [ + 'type' => [ + 'data' => [ + 'type' => 'node_type--node_type', + 'id' => 'article', + ], + ], + 'uid' => [ + 'data' => [ + 'type' => 'user--user', + 'id' => '1', + ], + ], + 'field_tags' => [ + 'data' => [ + [ + 'type' => 'taxonomy_term--tags', + 'id' => $this->tags[0]->uuid(), + ], + [ + 'type' => 'taxonomy_term--tags', + 'id' => $this->tags[1]->uuid(), + ], + ], + ], + ], + ], + ]; + $response = $this->request('POST', $collection_url, [ + 'body' => Json::encode($body), + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $created_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(201, $response->getStatusCode()); + $this->assertArrayHasKey('uuid', $created_response['data']['attributes']); + $uuid = $created_response['data']['attributes']['uuid']; + $this->assertEquals(2, count($created_response['data']['relationships']['field_tags']['data'])); + // 2. Authorization error. + $response = $this->request('POST', $collection_url, [ + 'body' => Json::encode($body), + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $created_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(403, $response->getStatusCode()); + $this->assertNotEmpty($created_response['errors']); + $this->assertEquals('Forbidden', $created_response['errors'][0]['title']); + // 3. Missing Content-Type error. + $response = $this->request('POST', $collection_url, [ + 'body' => Json::encode($body), + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + ]); + $created_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(422, $response->getStatusCode()); + $this->assertNotEmpty($created_response['errors']); + $this->assertEquals('Unprocessable Entity', $created_response['errors'][0]['title']); + // 4. Article with a duplicate ID + $invalid_body = $body; + $invalid_body['data']['attributes']['nid'] = 1; + $response = $this->request('POST', $collection_url, [ + 'body' => Json::encode($invalid_body), + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $created_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertNotEmpty($created_response['errors']); + $this->assertEquals('Internal Server Error', $created_response['errors'][0]['title']); + // 5. Article with wrong reference UUIDs for tags. + $body_invalid_tags = $body; + $body_invalid_tags['data']['relationships']['field_tags']['data'][0]['id'] = 'lorem'; + $body_invalid_tags['data']['relationships']['field_tags']['data'][1]['id'] = 'ipsum'; + $response = $this->request('POST', $collection_url, [ + 'body' => Json::encode($body_invalid_tags), + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $created_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals(0, count($created_response['data']['relationships']['field_tags']['data'])); + // 6. Serialization error. + $response = $this->request('POST', $collection_url, [ + 'body' => '{"bad json",,,}', + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $created_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(422, $response->getStatusCode()); + $this->assertNotEmpty($created_response['errors']); + $this->assertEquals('Unprocessable Entity', $created_response['errors'][0]['title']); + // 7. Successful PATCH. + $body = [ + 'data' => [ + 'id' => $uuid, + 'type' => 'node--article', + 'attributes' => ['title' => 'My updated title'], + ], + ]; + $individual_url = Url::fromRoute('jsonapi.node--article.individual', [ + 'node' => $uuid, + ]); + $response = $this->request('PATCH', $individual_url, [ + 'body' => Json::encode($body), + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $updated_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('My updated title', $updated_response['data']['attributes']['title']); + // 8. Field access forbidden check. + $body = [ + 'data' => [ + 'id' => $uuid, + 'type' => 'node--article', + 'attributes' => [ + 'title' => 'My updated title', + 'status' => 0, + ], + ], + ]; + $response = $this->request('PATCH', $individual_url, [ + 'body' => Json::encode($body), + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $updated_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(403, $response->getStatusCode()); + $this->assertEquals('The current user is not allowed to PATCH the selected field (status).', $updated_response['errors'][0]['detail']); + $node = \Drupal::entityManager()->loadEntityByUuid('node', $uuid); + $this->assertEquals(1, $node->get('status')->value, 'Node status was not changed.'); + // 9. Successful POST to related endpoint. + $body = [ + 'data' => [ + [ + 'id' => $this->tags[2]->uuid(), + 'type' => 'taxonomy_term--tags', + ], + ], + ]; + $relationship_url = Url::fromRoute('jsonapi.node--article.relationship', [ + 'node' => $uuid, + 'related' => 'field_tags', + ]); + $response = $this->request('POST', $relationship_url, [ + 'body' => Json::encode($body), + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $updated_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(201, $response->getStatusCode()); + $this->assertEquals(3, count($updated_response['data'])); + $this->assertEquals('taxonomy_term--tags', $updated_response['data'][2]['type']); + $this->assertEquals($this->tags[2]->uuid(), $updated_response['data'][2]['id']); + // 10. Successful PATCH to related endpoint. + $body = [ + 'data' => [ + [ + 'id' => $this->tags[1]->uuid(), + 'type' => 'taxonomy_term--tags', + ], + ], + ]; + $response = $this->request('PATCH', $relationship_url, [ + 'body' => Json::encode($body), + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $updated_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertCount(1, $updated_response['data']); + $this->assertEquals('taxonomy_term--tags', $updated_response['data'][0]['type']); + $this->assertEquals($this->tags[1]->uuid(), $updated_response['data'][0]['id']); + // 11. Successful DELETE to related endpoint. + $payload = $updated_response; + $response = $this->request('DELETE', $relationship_url, [ + // Send a request with no body. + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $updated_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals( + 'You need to provide a body for DELETE operations on a relationship (field_tags).', + $updated_response['errors'][0]['detail'] + ); + $this->assertEquals(400, $response->getStatusCode()); + $response = $this->request('DELETE', $relationship_url, [ + // Send a request with no authentication. + 'body' => Json::encode($payload), + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $this->assertEquals(403, $response->getStatusCode()); + $response = $this->request('DELETE', $relationship_url, [ + // Remove the existing relationship item. + 'body' => Json::encode($payload), + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $updated_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(201, $response->getStatusCode()); + $this->assertCount(0, $updated_response['data']); + // 12. PATCH with invalid title and body format. + $body = [ + 'data' => [ + 'id' => $uuid, + 'type' => 'node--article', + 'attributes' => [ + 'title' => '', + 'body' => [ + 'value' => 'Custom value', + 'format' => 'invalid_format', + 'summary' => 'Custom summary', + ], + ], + ], + ]; + $response = $this->request('PATCH', $individual_url, [ + 'body' => Json::encode($body), + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + 'headers' => ['Content-Type' => 'application/vnd.api+json'], + ]); + $updated_response = Json::decode($response->getBody()->__toString()); + $this->assertEquals(422, $response->getStatusCode()); + $this->assertCount(2, $updated_response['errors']); + for ($i = 0; $i < 2; $i++) { + $this->assertEquals("Unprocessable Entity", $updated_response['errors'][$i]['title']); + $this->assertEquals(422, $updated_response['errors'][$i]['status']); + $this->assertEquals(0, $updated_response['errors'][$i]['code']); + } + $this->assertEquals("title: This value should not be null.", $updated_response['errors'][0]['detail']); + $this->assertEquals("body.0.format: The value you selected is not a valid choice.", $updated_response['errors'][1]['detail']); + $this->assertEquals("/data/attributes/title", $updated_response['errors'][0]['source']['pointer']); + $this->assertEquals("/data/attributes/body/format", $updated_response['errors'][1]['source']['pointer']); + // 13. Successful DELETE. + $response = $this->request('DELETE', $individual_url, [ + 'auth' => [$this->user->getUsername(), $this->user->pass_raw], + ]); + $this->assertEquals(204, $response->getStatusCode()); + $response = $this->request('GET', $individual_url, []); + $this->assertEquals(404, $response->getStatusCode()); + } + + /** + * Creates default content to test the API. + * + * @param int $num_articles + * Number of articles to create. + * @param int $num_tags + * Number of tags to create. + * @param bool $article_has_image + * Set to TRUE if you want to add an image to the generated articles. + * @param bool $article_has_link + * Set to TRUE if you want to add a link to the generated articles. + */ + protected function createDefaultContent($num_articles, $num_tags, $article_has_image, $article_has_link) { + $random = $this->getRandomGenerator(); + for ($created_tags = 0; $created_tags < $num_tags; $created_tags++) { + $term = Term::create([ + 'vid' => 'tags', + 'name' => $random->name(), + ]); + $term->save(); + $this->tags[] = $term; + } + for ($created_nodes = 0; $created_nodes < $num_articles; $created_nodes++) { + // Get N random tags. + $selected_tags = mt_rand(1, $num_tags); + $tags = []; + while (count($tags) < $selected_tags) { + $tags[] = mt_rand(1, $num_tags); + $tags = array_unique($tags); + } + $values = [ + 'uid' => ['target_id' => $this->user->id()], + 'type' => 'article', + 'field_tags' => array_map(function ($tag) { + return ['target_id' => $tag]; + }, $tags), + ]; + if ($article_has_image) { + $file = File::create([ + 'uri' => 'vfs://' . $random->name() . '.png', + ]); + $file->setPermanent(); + $file->save(); + $this->files[] = $file; + $values['field_image'] = ['target_id' => $file->id()]; + } + if ($article_has_link) { + $values['field_link'] = [ + 'title' => $this->getRandomGenerator()->name(), + 'uri' => sprintf( + '%s://%s.%s', + 'http' . (mt_rand(0, 2) > 1 ? '' : 's'), + $this->getRandomGenerator()->name(), + 'org' + ), + ]; + } + $this->nodes[] = $this->createNode($values); + } + if ($article_has_link) { + // Make sure that there is at least 1 https link for ::testRead() #19. + $this->nodes[0]->field_link = [ + 'title' => 'Drupal', + 'uri' => 'https://drupal.org' + ]; + $this->nodes[0]->save(); + } + } + +} diff --git a/core/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php b/core/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php new file mode 100644 index 0000000..aff2c58 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Kernel/Controller/EntityResourceTest.php @@ -0,0 +1,858 @@ +installEntitySchema('node'); + $this->installEntitySchema('user'); + // Add the additional table schemas. + $this->installSchema('system', ['sequences']); + $this->installSchema('node', ['node_access']); + $this->installSchema('user', ['users_data']); + NodeType::create([ + 'type' => 'lorem', + ])->save(); + $type = NodeType::create([ + 'type' => 'article', + ]); + $type->save(); + $this->user = User::create([ + 'name' => 'user1', + 'mail' => 'user@localhost', + 'status' => 1, + 'roles' => ['test_role_one', 'test_role_two'], + ]); + $this->createEntityReferenceField('node', 'article', 'field_relationships', 'Relationship', 'node', 'default', ['target_bundles' => ['article']], FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); + $this->user->save(); + $this->node = Node::create([ + 'title' => 'dummy_title', + 'type' => 'article', + 'uid' => $this->user->id(), + ]); + $this->node->save(); + + $this->node2 = Node::create([ + 'type' => 'article', + 'title' => 'Another test node', + 'uid' => $this->user->id(), + ]); + $this->node2->save(); + + $this->node3 = Node::create([ + 'type' => 'article', + 'title' => 'Unpublished test node', + 'uid' => $this->user->id(), + 'status' => 0, + ]); + $this->node3->save(); + + $this->node4 = Node::create([ + 'type' => 'article', + 'title' => 'Test node with related nodes', + 'uid' => $this->user->id(), + 'field_relationships' => [ + ['target_id' => $this->node->id()], + ['target_id' => $this->node2->id()], + ['target_id' => $this->node3->id()], + ], + ]); + $this->node4->save(); + + // Give anonymous users permission to view user profiles, so that we can + // verify the cache tags of cached versions of user profile pages. + array_map(function ($role_id) { + Role::create([ + 'id' => $role_id, + 'permissions' => [ + 'access user profiles', + 'access content', + ], + ])->save(); + }, [RoleInterface::ANONYMOUS_ID, 'test_role_one', 'test_role_two']); + } + + + /** + * @covers ::getIndividual + */ + public function testGetIndividual() { + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->getIndividual($this->node, new Request()); + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $this->assertEquals(1, $response->getResponseData()->getData()->id()); + } + + /** + * @covers ::getIndividual + * @expectedException \Drupal\jsonapi\Error\SerializableHttpException + */ + public function testGetIndividualDenied() { + $role = Role::load(RoleInterface::ANONYMOUS_ID); + $role->revokePermission('access content'); + $role->save(); + $entity_resource = $this->buildEntityResource('node', 'article'); + $entity_resource->getIndividual($this->node, new Request()); + } + + /** + * @covers ::getCollection + */ + public function testGetCollection() { + $request = new Request([], [], [ + '_route_params' => ['_json_api_params' => []], + '_json_api_params' => [], + ]); + + // Get the response. + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->getCollection($request); + + // Assertions. + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData()); + $this->assertEquals(1, $response->getResponseData()->getData()->getIterator()->current()->id()); + $this->assertEquals([ + 'node:1', + 'node:2', + 'node:3', + 'node:4', + 'node_list' + ], $response->getCacheableMetadata()->getCacheTags()); + } + + /** + * @covers ::getCollection + */ + public function testGetFilteredCollection() { + $field_manager = $this->container->get('entity_field.manager'); + $filter = new Filter(['type' => ['value' => 'article']], 'node_type', $field_manager); + // The fake route. + $route = new Route(NULL, [], [ + '_entity_type' => 'node', + '_bundle' => 'article', + ]); + // The request. + $request = new Request([], [], [ + '_route_params' => [ + '_json_api_params' => [ + 'filter' => $filter, + ], + ], + '_json_api_params' => [ + 'filter' => $filter, + ], + '_route_object' => $route, + ]); + $request_stack = new RequestStack(); + $request_stack->push($request); + // Get the entity resource. + $current_context = new CurrentContext( + $this->container->get('jsonapi.resource_type.repository'), + $request_stack, + new CurrentRouteMatch($request_stack) + ); + $this->container->set('jsonapi.current_context', $current_context); + + $entity_resource = new EntityResource( + $this->container->get('jsonapi.resource_type.repository')->get('node_type', 'node_type'), + $this->container->get('entity_type.manager'), + $this->container->get('jsonapi.query_builder'), + $field_manager, + $current_context, + $this->container->get('plugin.manager.field.field_type') + ); + + // Get the response. + $response = $entity_resource->getCollection($request); + + // Assertions. + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData()); + $this->assertCount(1, $response->getResponseData()->getData()); + $this->assertEquals(['config:node_type_list'], $response->getCacheableMetadata()->getCacheTags()); + } + + /** + * @covers ::getCollection + */ + public function testGetSortedCollection() { + // Fake the request. + $field_manager = $this->container->get('entity_field.manager'); + // The fake route. + $route = new Route(NULL, [], [ + '_entity_type' => 'node', + '_bundle' => 'article', + ]); + $sort = new Sort('-type'); + // The request. + $request = new Request([], [], [ + '_route_params' => [ + '_json_api_params' => [ + 'sort' => $sort, + ], + ], + '_json_api_params' => [ + 'sort' => $sort, + ], + '_route_object' => $route, + ]); + $request_stack = new RequestStack(); + $request_stack->push($request); + // Get the entity resource. + $current_context = new CurrentContext( + $this->container->get('jsonapi.resource_type.repository'), + $request_stack, + new CurrentRouteMatch($request_stack) + ); + $this->container->set('jsonapi.current_context', $current_context); + + $entity_resource = new EntityResource( + $this->container->get('jsonapi.resource_type.repository')->get('node_type', 'node_type'), + $this->container->get('entity_type.manager'), + $this->container->get('jsonapi.query_builder'), + $field_manager, + $current_context, + $this->container->get('plugin.manager.field.field_type') + ); + + // Get the response. + $response = $entity_resource->getCollection($request); + + // Assertions. + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData()); + $this->assertCount(2, $response->getResponseData()->getData()); + $this->assertEquals($response->getResponseData()->getData()->toArray()[0]->id(), 'lorem'); + $this->assertEquals(['config:node_type_list'], $response->getCacheableMetadata()->getCacheTags()); + } + + /** + * @covers ::getCollection + */ + public function testGetPagedCollection() { + // Fake the request. + $field_manager = $this->container->get('entity_field.manager'); + // The fake route. + $route = new Route(NULL, [], [ + '_entity_type' => 'node', + '_bundle' => 'article', + ]); + $pager = new OffsetPage(['offset' => 1, 'limit' => 1]); + // The request. + $request = new Request([], [], [ + '_route_params' => [ + '_json_api_params' => [ + 'page' => $pager, + ], + ], + '_json_api_params' => [ + 'page' => $pager, + ], + '_route_object' => $route, + ]); + $request_stack = new RequestStack(); + $request_stack->push($request); + // Get the entity resource. + $current_context = new CurrentContext( + $this->container->get('jsonapi.resource_type.repository'), + $request_stack, + new CurrentRouteMatch($request_stack) + ); + $this->container->set('jsonapi.current_context', $current_context); + + $entity_resource = new EntityResource( + $this->container->get('jsonapi.resource_type.repository')->get('node', 'article'), + $this->container->get('entity_type.manager'), + $this->container->get('jsonapi.query_builder'), + $field_manager, + $current_context, + $this->container->get('plugin.manager.field.field_type') + ); + + // Get the response. + $response = $entity_resource->getCollection($request); + + // Assertions. + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData()); + $data = $response->getResponseData()->getData(); + $this->assertCount(1, $data); + $this->assertEquals(2, $data->toArray()[0]->id()); + $this->assertEquals(['node:2', 'node_list'], $response->getCacheableMetadata()->getCacheTags()); + } + + /** + * @covers ::getCollection + */ + public function testGetEmptyCollection() { + $filter = new Filter( + ['uuid' => ['value' => 'invalid']], + 'node', + $this->container->get('entity_field.manager') + ); + $request = new Request([], [], [ + '_route_params' => [ + '_json_api_params' => [ + 'filter' => $filter, + ], + ], + '_json_api_params' => [ + 'filter' => $filter, + ], + ]); + + // Get the response. + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->getCollection($request); + + // Assertions. + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $this->assertInstanceOf(EntityCollection::class, $response->getResponseData()->getData()); + $this->assertEquals(0, $response->getResponseData()->getData()->count()); + $this->assertEquals(['node_list'], $response->getCacheableMetadata()->getCacheTags()); + } + + /** + * @covers ::getRelated + */ + public function testGetRelated() { + // to-one relationship. + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->getRelated($this->node, 'uid', new Request()); + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $this->assertInstanceOf(User::class, $response->getResponseData() + ->getData()); + $this->assertEquals(1, $response->getResponseData()->getData()->id()); + + // to-many relationship. + $response = $entity_resource->getRelated($this->user, 'roles', new Request()); + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response + ->getResponseData()); + $this->assertInstanceOf(EntityCollection::class, $response + ->getResponseData() + ->getData()); + $this->assertEquals([ + 'config:user.role.test_role_one', + 'config:user.role.test_role_two', + 'user:1', + ], $response + ->getCacheableMetadata() + ->getCacheTags()); + // to-many relationship. + $response = $entity_resource->getRelated($this->node4, 'field_relationships', new Request()); + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response + ->getResponseData()); + $this->assertInstanceOf(EntityCollection::class, $response + ->getResponseData() + ->getData()); + $this->assertEquals( + ['node:1', 'node:2', 'node:3', 'node:4'], + $response->getCacheableMetadata()->getCacheTags() + ); + } + + /** + * @covers ::getRelationship + */ + public function testGetRelationship() { + // to-one relationship. + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->getRelationship($this->node, 'uid', new Request()); + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $this->assertInstanceOf( + EntityReferenceFieldItemListInterface::class, + $response->getResponseData()->getData() + ); + $this->assertEquals(1, $response + ->getResponseData() + ->getData() + ->getEntity() + ->id() + ); + $this->assertEquals('node', $response + ->getResponseData() + ->getData() + ->getEntity() + ->getEntityTypeId() + ); + } + + /** + * @covers ::createIndividual + */ + public function testCreateIndividual() { + $node = Node::create([ + 'type' => 'article', + 'title' => 'Lorem ipsum', + ]); + Role::load(Role::ANONYMOUS_ID) + ->grantPermission('create article content') + ->save(); + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->createIndividual($node, new Request()); + // As a side effect, the node will also be saved. + $this->assertNotEmpty($node->id()); + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $this->assertEquals(5, $response->getResponseData()->getData()->id()); + $this->assertEquals(201, $response->getStatusCode()); + } + + /** + * @covers ::createIndividual + */ + public function testCreateIndividualWithMissingRequiredData() { + $node = Node::create([ + 'type' => 'article', + // No title specified, even if its required. + ]); + Role::load(Role::ANONYMOUS_ID) + ->grantPermission('create article content') + ->save(); + $this->setExpectedException(HttpException::class, 'Unprocessable Entity: validation failed.'); + $entity_resource = $this->buildEntityResource('node', 'article'); + $entity_resource->createIndividual($node, new Request()); + } + + /** + * @covers ::createIndividual + */ + public function testCreateIndividualConfig() { + $node_type = NodeType::create([ + 'type' => 'test', + 'name' => 'Test Type', + 'description' => 'Lorem ipsum', + ]); + Role::load(Role::ANONYMOUS_ID) + ->grantPermission('administer content types') + ->save(); + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->createIndividual($node_type, new Request()); + // As a side effect, the node type will also be saved. + $this->assertNotEmpty($node_type->id()); + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $this->assertEquals('test', $response->getResponseData()->getData()->id()); + $this->assertEquals(201, $response->getStatusCode()); + } + + /** + * @covers ::patchIndividual + * @dataProvider patchIndividualProvider + */ + public function testPatchIndividual($values) { + $parsed_node = Node::create($values); + Role::load(Role::ANONYMOUS_ID) + ->grantPermission('edit any article content') + ->save(); + $payload = Json::encode([ + 'data' => [ + 'type' => 'article', + 'id' => $this->node->uuid(), + 'attributes' => [ + 'title' => '', + 'field_relationships' => '', + ], + ], + ]); + $request = new Request([], [], [], [], [], [], $payload); + + // Create a new EntityResource that uses uuid. + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->patchIndividual($this->node, $parsed_node, $request); + + // As a side effect, the node will also be saved. + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $updated_node = $response->getResponseData()->getData(); + $this->assertInstanceOf(Node::class, $updated_node); + $this->assertSame($values['title'], $this->node->getTitle()); + $this->assertSame($values['field_relationships'], $this->node->get('field_relationships')->getValue()); + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * Provides data for the testPatchIndividual. + * + * @return array + * The input data for the test function. + */ + public function patchIndividualProvider() { + return [ + [ + [ + 'type' => 'article', + 'title' => 'PATCHED', + 'field_relationships' => [['target_id' => 1]], + ], + ], + ]; + } + + /** + * @covers ::patchIndividual + * @dataProvider patchIndividualConfigProvider + */ + public function testPatchIndividualConfig($values) { + // List of fields to be ignored. + $ignored_fields = ['uuid', 'entityTypeId', 'type']; + $node_type = NodeType::create([ + 'type' => 'test', + 'name' => 'Test Type', + 'description' => '', + ]); + $node_type->save(); + + $parsed_node_type = NodeType::create($values); + Role::load(Role::ANONYMOUS_ID) + ->grantPermission('administer content types') + ->save(); + Role::load(Role::ANONYMOUS_ID) + ->grantPermission('edit any article content') + ->save(); + $payload = Json::encode([ + 'data' => [ + 'type' => 'node_type', + 'id' => $node_type->uuid(), + 'attributes' => $values, + ], + ]); + $request = new Request([], [], [], [], [], [], $payload); + + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->patchIndividual($node_type, $parsed_node_type, $request); + + // As a side effect, the node will also be saved. + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $updated_node_type = $response->getResponseData()->getData(); + $this->assertInstanceOf(NodeType::class, $updated_node_type); + // If the field is ignored then we should not see a difference. + foreach ($values as $field_name => $value) { + in_array($field_name, $ignored_fields) ? + $this->assertNotSame($value, $node_type->get($field_name)) : + $this->assertSame($value, $node_type->get($field_name)); + } + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * Provides data for the testPatchIndividualConfig. + * + * @return array + * The input data for the test function. + */ + public function patchIndividualConfigProvider() { + return [ + [['description' => 'PATCHED', 'status' => FALSE]], + [[]], + ]; + } + + /** + * @covers ::patchIndividual + * @dataProvider patchIndividualConfigFailedProvider + * @expectedException \Drupal\Core\Config\ConfigException + */ + public function testPatchIndividualFailedConfig($values) { + $this->testPatchIndividualConfig($values); + } + + /** + * Provides data for the testPatchIndividualFailedConfig. + * + * @return array + * The input data for the test function. + */ + public function patchIndividualConfigFailedProvider() { + return [ + [['uuid' => 'PATCHED']], + [['type' => 'article', 'status' => FALSE]], + ]; + } + + /** + * @covers ::deleteIndividual + */ + public function testDeleteIndividual() { + $node = Node::create([ + 'type' => 'article', + 'title' => 'Lorem ipsum', + ]); + $nid = $node->id(); + $node->save(); + Role::load(Role::ANONYMOUS_ID) + ->grantPermission('delete own article content') + ->save(); + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->deleteIndividual($node, new Request()); + // As a side effect, the node will also be deleted. + $count = $this->container->get('entity_type.manager') + ->getStorage('node') + ->getQuery() + ->condition('nid', $nid) + ->count() + ->execute(); + $this->assertEquals(0, $count); + $this->assertNull($response->getResponseData()); + $this->assertEquals(204, $response->getStatusCode()); + } + + /** + * @covers ::deleteIndividual + */ + public function testDeleteIndividualConfig() { + $node_type = NodeType::create([ + 'type' => 'test', + 'name' => 'Test Type', + 'description' => 'Lorem ipsum', + ]); + $id = $node_type->id(); + $node_type->save(); + Role::load(Role::ANONYMOUS_ID) + ->grantPermission('administer content types') + ->save(); + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->deleteIndividual($node_type, new Request()); + // As a side effect, the node will also be deleted. + $count = $this->container->get('entity_type.manager') + ->getStorage('node_type') + ->getQuery() + ->condition('type', $id) + ->count() + ->execute(); + $this->assertEquals(0, $count); + $this->assertNull($response->getResponseData()); + $this->assertEquals(204, $response->getStatusCode()); + } + + /** + * @covers ::createRelationship + */ + public function testCreateRelationship() { + $parsed_field_list = $this->container + ->get('plugin.manager.field.field_type') + ->createFieldItemList($this->node, 'field_relationships', [ + ['target_id' => $this->node->id()], + ]); + Role::load(Role::ANONYMOUS_ID) + ->grantPermission('edit any article content') + ->save(); + + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->createRelationship($this->node, 'field_relationships', $parsed_field_list, new Request()); + + // As a side effect, the node will also be saved. + $this->assertNotEmpty($this->node->id()); + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $field_list = $response->getResponseData()->getData(); + $this->assertInstanceOf(EntityReferenceFieldItemListInterface::class, $field_list); + $this->assertSame('field_relationships', $field_list->getName()); + $this->assertEquals([['target_id' => 1]], $field_list->getValue()); + $this->assertEquals(201, $response->getStatusCode()); + } + + /** + * @covers ::patchRelationship + * @dataProvider patchRelationshipProvider + */ + public function testPatchRelationship($relationships) { + $this->node->field_relationships->appendItem(['target_id' => $this->node->id()]); + $this->node->save(); + $parsed_field_list = $this->container + ->get('plugin.manager.field.field_type') + ->createFieldItemList($this->node, 'field_relationships', $relationships); + Role::load(Role::ANONYMOUS_ID) + ->grantPermission('edit any article content') + ->save(); + + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->patchRelationship($this->node, 'field_relationships', $parsed_field_list, new Request()); + + // As a side effect, the node will also be saved. + $this->assertNotEmpty($this->node->id()); + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $field_list = $response->getResponseData()->getData(); + $this->assertInstanceOf(EntityReferenceFieldItemListInterface::class, $field_list); + $this->assertSame('field_relationships', $field_list->getName()); + $this->assertEquals($relationships, $field_list->getValue()); + $this->assertEquals(200, $response->getStatusCode()); + } + + /** + * Provides data for the testPatchRelationship. + * + * @return array + * The input data for the test function. + */ + public function patchRelationshipProvider() { + return [ + // Replace relationships. + [[['target_id' => 2], ['target_id' => 1]]], + // Remove relationships. + [[]], + ]; + } + + /** + * @covers ::deleteRelationship + * @dataProvider deleteRelationshipProvider + */ + public function testDeleteRelationship($deleted_rels, $kept_rels) { + $this->node->field_relationships->appendItem(['target_id' => $this->node->id()]); + $this->node->field_relationships->appendItem(['target_id' => $this->node2->id()]); + $this->node->save(); + $parsed_field_list = $this->container + ->get('plugin.manager.field.field_type') + ->createFieldItemList($this->node, 'field_relationships', $deleted_rels); + Role::load(Role::ANONYMOUS_ID) + ->grantPermission('edit any article content') + ->save(); + + $entity_resource = $this->buildEntityResource('node', 'article'); + $response = $entity_resource->deleteRelationship($this->node, 'field_relationships', $parsed_field_list, new Request()); + + // As a side effect, the node will also be saved. + $this->assertInstanceOf(JsonApiDocumentTopLevel::class, $response->getResponseData()); + $field_list = $response->getResponseData()->getData(); + $this->assertInstanceOf(EntityReferenceFieldItemListInterface::class, $field_list); + $this->assertSame('field_relationships', $field_list->getName()); + $this->assertEquals($kept_rels, $field_list->getValue()); + $this->assertEquals(201, $response->getStatusCode()); + } + + /** + * Provides data for the testDeleteRelationship. + * + * @return array + * The input data for the test function. + */ + public function deleteRelationshipProvider() { + return [ + // Remove one relationship. + [[['target_id' => 1]], [['target_id' => 2]]], + // Remove all relationships. + [[['target_id' => 2], ['target_id' => 1]], []], + // Remove no relationship. + [[], [['target_id' => 1], ['target_id' => 2]]], + ]; + } + + /** + * Instantiates a test EntityResource. + * + * @param string $entity_type_id + * The entity type ID. + * @param string $bundle + * The bundle. + * + * @return \Drupal\jsonapi\Controller\EntityResource + * The resource. + */ + protected function buildEntityResource($entity_type_id, $bundle) { + // The fake route. + $route = new Route(NULL, [], [ + '_entity_type' => $entity_type_id, + '_bundle' => $bundle, + ]); + // The request. + $request = new Request([], [], ['_route_object' => $route]); + $request_stack = new RequestStack(); + $request_stack->push($request); + // Get the entity resource. + $current_context = new CurrentContext( + $this->container->get('jsonapi.resource_type.repository'), + $request_stack, + new CurrentRouteMatch($request_stack) + ); + $this->container->set('jsonapi.current_context', $current_context); + + return new EntityResource( + new ResourceType($entity_type_id, $bundle, NULL), + $this->container->get('entity_type.manager'), + $this->container->get('jsonapi.query_builder'), + $this->container->get('entity_field.manager'), + $current_context, + $this->container->get('plugin.manager.field.field_type') + ); + } + +} diff --git a/core/modules/jsonapi/tests/src/Kernel/Field/FileDownloadUrlTest.php b/core/modules/jsonapi/tests/src/Kernel/Field/FileDownloadUrlTest.php new file mode 100644 index 0000000..080b9b1 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Kernel/Field/FileDownloadUrlTest.php @@ -0,0 +1,73 @@ +installEntitySchema('file'); + $this->installSchema('file', array('file_usage')); + + // Create a new file entity. + $this->file = File::create(array( + 'filename' => $this->filename, + 'uri' => sprintf('public://%s', $this->filename), + 'filemime' => 'text/plain', + 'status' => FILE_STATUS_PERMANENT, + )); + + $this->file->save(); + } + + /** + * Test the URL computed field. + */ + public function testUrlField() { + $url_field = $this->file->get('url'); + // Test all the different ways to access a field item. + $values = [ + $url_field->value, + $url_field->getValue()[0]['value'], + $url_field->get(0)->toArray()['value'], + $url_field->first()->getValue()['value'], + ]; + array_walk($values, function ($value) { + $this->assertContains('simpletest', $value); + $this->assertContains($this->filename, $value); + }); + } + +} diff --git a/core/modules/jsonapi/tests/src/Kernel/JsonapiKernelTestBase.php b/core/modules/jsonapi/tests/src/Kernel/JsonapiKernelTestBase.php new file mode 100644 index 0000000..96a1100 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Kernel/JsonapiKernelTestBase.php @@ -0,0 +1,65 @@ + $field_name, + 'type' => 'entity_reference', + 'entity_type' => $entity_type, + 'cardinality' => $cardinality, + 'settings' => array( + 'target_type' => $target_entity_type, + ), + ))->save(); + } + if (!FieldConfig::loadByName($entity_type, $bundle, $field_name)) { + FieldConfig::create(array( + 'field_name' => $field_name, + 'entity_type' => $entity_type, + 'bundle' => $bundle, + 'label' => $field_label, + 'settings' => array( + 'handler' => $selection_handler, + 'handler_settings' => $handler_settings, + ), + ))->save(); + } + } + +} diff --git a/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php new file mode 100644 index 0000000..7feb0d0 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Kernel/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php @@ -0,0 +1,579 @@ +installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installEntitySchema('taxonomy_term'); + // Add the additional table schemas. + $this->installSchema('system', ['sequences']); + $this->installSchema('node', ['node_access']); + $this->installSchema('user', ['users_data']); + $type = NodeType::create([ + 'type' => 'article', + ]); + $type->save(); + $this->createEntityReferenceField( + 'node', + 'article', + 'field_tags', + 'Tags', + 'taxonomy_term', + 'default', + ['target_bundles' => ['tags']], + FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED + ); + $this->user = User::create([ + 'name' => 'user1', + 'mail' => 'user@localhost', + ]); + $this->user2 = User::create([ + 'name' => 'user2', + 'mail' => 'user2@localhost', + ]); + + $this->user->save(); + $this->user2->save(); + + $this->vocabulary = Vocabulary::create(['name' => 'Tags', 'vid' => 'tags']); + $this->vocabulary->save(); + + $this->term1 = Term::create([ + 'name' => 'term1', + 'vid' => $this->vocabulary->id(), + ]); + $this->term2 = Term::create([ + 'name' => 'term2', + 'vid' => $this->vocabulary->id(), + ]); + + $this->term1->save(); + $this->term2->save(); + + $this->node = Node::create([ + 'title' => 'dummy_title', + 'type' => 'article', + 'uid' => 1, + 'field_tags' => [ + ['target_id' => $this->term1->id()], + ['target_id' => $this->term2->id()], + ], + ]); + + $this->node->save(); + + $link_manager = $this->prophesize(LinkManager::class); + $link_manager + ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string')) + ->willReturn('dummy_entity_link'); + $link_manager + ->getRequestLink(Argument::any()) + ->willReturn('dummy_document_link'); + $this->container->set('jsonapi.link_manager', $link_manager->reveal()); + + $this->nodeType = NodeType::load('article'); + + Role::create([ + 'id' => RoleInterface::ANONYMOUS_ID, + 'permissions' => [ + 'access content', + ], + ])->save(); + } + + + /** + * {@inheritdoc} + */ + public function tearDown() { + if ($this->node) { + $this->node->delete(); + } + if ($this->term1) { + $this->term1->delete(); + } + if ($this->term2) { + $this->term2->delete(); + } + if ($this->vocabulary) { + $this->vocabulary->delete(); + } + if ($this->user) { + $this->user->delete(); + } + if ($this->user2) { + $this->user2->delete(); + } + } + + /** + * @covers ::normalize + */ + public function testNormalize() { + list($request, $resource_type) = $this->generateProphecies('node', 'article'); + $request->query = new ParameterBag([ + 'fields' => [ + 'node--article' => 'title,type,uid,field_tags', + 'user--user' => 'name', + ], + 'include' => 'uid,field_tags', + ]); + + $response = new ResourceResponse(); + $normalized = $this + ->container + ->get('serializer.normalizer.jsonapi_document_toplevel.jsonapi') + ->normalize( + new JsonApiDocumentTopLevel($this->node), + 'api_json', + [ + 'request' => $request, + 'resource_type' => $resource_type, + 'cacheable_metadata' => $response->getCacheableMetadata(), + ] + ); + $this->assertSame($normalized['data']['attributes']['title'], 'dummy_title'); + $this->assertEquals($normalized['data']['id'], $this->node->uuid()); + $this->assertSame([ + 'data' => [ + 'type' => 'node_type--node_type', + 'id' => NodeType::load('article')->uuid(), + ], + 'links' => [ + 'self' => 'dummy_entity_link', + 'related' => 'dummy_entity_link', + ], + ], $normalized['data']['relationships']['type']); + $this->assertTrue(!isset($normalized['data']['attributes']['created'])); + $this->assertSame('node--article', $normalized['data']['type']); + $this->assertEquals([ + 'data' => [ + 'type' => 'user--user', + 'id' => $this->user->uuid(), + ], + 'links' => [ + 'self' => 'dummy_entity_link', + 'related' => 'dummy_entity_link', + ], + ], $normalized['data']['relationships']['uid']); + $this->assertEquals( + 'Access checks failed for entity user:' . $this->user->id() . '.', + $normalized['included'][0]['meta']['errors'][0]['detail'] + ); + $this->assertEquals(403, $normalized['included'][0]['meta']['errors'][0]['status']); + $this->assertEquals($this->term1->uuid(), $normalized['included'][1]['id']); + $this->assertEquals('taxonomy_term--tags', $normalized['included'][1]['type']); + $this->assertEquals($this->term1->label(), $normalized['included'][1]['attributes']['name']); + $this->assertTrue(!isset($normalized['included'][0]['attributes']['created'])); + // Make sure that the cache tags for the includes and the requested entities + // are bubbling as expected. + $this->assertSame( + ['node:1', 'taxonomy_term:1', 'taxonomy_term:2'], + $response->getCacheableMetadata()->getCacheTags() + ); + $this->assertSame( + Cache::PERMANENT, + $response->getCacheableMetadata()->getCacheMaxAge() + ); + } + + /** + * @covers ::normalize + */ + public function testNormalizeRelated() { + list($request, $resource_type) = $this->generateProphecies('node', 'article', 'uid'); + $request->query = new ParameterBag([ + 'fields' => [ + 'user--user' => 'name,roles', + ], + 'include' => 'roles' + ]); + $document_wrapper = $this->prophesize(JsonApiDocumentTopLevel::class); + $author = $this->node->get('uid')->entity; + $document_wrapper->getData()->willReturn($author); + + $response = new ResourceResponse(); + $normalized = $this + ->container + ->get('serializer.normalizer.jsonapi_document_toplevel.jsonapi') + ->normalize( + $document_wrapper->reveal(), + 'api_json', + [ + 'request' => $request, + 'resource_type' => $resource_type, + 'cacheable_metadata' => $response->getCacheableMetadata(), + ] + ); + $this->assertSame($normalized['data']['attributes']['name'], 'user1'); + $this->assertEquals($normalized['data']['id'], User::load(1)->uuid()); + $this->assertEquals($normalized['data']['type'], 'user--user'); + // Make sure that the cache tags for the includes and the requested entities + // are bubbling as expected. + $this->assertSame(['user:1'], $response->getCacheableMetadata() + ->getCacheTags()); + $this->assertSame(Cache::PERMANENT, $response->getCacheableMetadata() + ->getCacheMaxAge()); + } + + /** + * @covers ::normalize + */ + public function testNormalizeUuid() { + list($request, $resource_type) = $this->generateProphecies('node', 'article', 'uuid'); + $document_wrapper = $this->prophesize(JsonApiDocumentTopLevel::class); + $document_wrapper->getData()->willReturn($this->node); + $request->query = new ParameterBag([ + 'fields' => [ + 'node--article' => 'title,type,uid,field_tags', + 'user--user' => 'name', + ], + 'include' => 'uid,field_tags', + ]); + + $response = new ResourceResponse(); + $normalized = $this + ->container + ->get('serializer.normalizer.jsonapi_document_toplevel.jsonapi') + ->normalize( + $document_wrapper->reveal(), + 'api_json', + [ + 'request' => $request, + 'resource_type' => $resource_type, + 'cacheable_metadata' => $response->getCacheableMetadata(), + ] + ); + $this->assertStringMatchesFormat($this->node->uuid(), $normalized['data']['id']); + $this->assertEquals($this->node->type->entity->uuid(), $normalized['data']['relationships']['type']['data']['id']); + $this->assertEquals($this->user->uuid(), $normalized['data']['relationships']['uid']['data']['id']); + $this->assertTrue(empty($normalized['included'][0]['id'])); + $this->assertEquals($this->term1->uuid(), $normalized['included'][1]['id']); + // Make sure that the cache tags for the includes and the requested entities + // are bubbling as expected. + $this->assertSame( + ['node:1', 'taxonomy_term:1', 'taxonomy_term:2'], + $response->getCacheableMetadata()->getCacheTags() + ); + } + + /** + * @covers ::normalize + */ + public function testNormalizeException() { + list($request, $resource_type) = $this->generateProphecies('node', 'article', 'id'); + $document_wrapper = $this->prophesize(JsonApiDocumentTopLevel::class); + $document_wrapper->getData()->willReturn($this->node); + $request->query = new ParameterBag([ + 'fields' => [ + 'node--article' => 'title,type,uid', + 'user--user' => 'name', + ], + 'include' => 'uid' + ]); + + $response = new ResourceResponse(); + $normalized = $this + ->container + ->get('serializer') + ->serialize( + new BadRequestHttpException('Lorem'), + 'api_json', + [ + 'request' => $request, + 'resource_type' => $resource_type, + 'cacheable_metadata' => $response->getCacheableMetadata(), + 'data_wrapper' => 'errors', + ] + ); + $normalized = Json::decode($normalized); + $this->assertNotEmpty($normalized['errors']); + $this->assertArrayNotHasKey('data', $normalized); + $this->assertEquals(400, $normalized['errors'][0]['status']); + $this->assertEquals('Lorem', $normalized['errors'][0]['detail']); + $this->assertEquals(['info' => 'http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1'], $normalized['errors'][0]['links']); + } + + /** + * @covers ::normalize + */ + public function testNormalizeConfig() { + list($request, $resource_type) = $this->generateProphecies('node_type', 'node_type', 'id'); + $document_wrapper = $this->prophesize(JsonApiDocumentTopLevel::class); + $document_wrapper->getData()->willReturn($this->nodeType); + $request->query = new ParameterBag([ + 'fields' => [ + 'node_type--node_type' => 'uuid,display_submitted', + ], + 'include' => NULL + ]); + + $response = new ResourceResponse(); + $normalized = $this + ->container + ->get('serializer.normalizer.jsonapi_document_toplevel.jsonapi') + ->normalize($document_wrapper->reveal(), 'api_json', [ + 'request' => $request, + 'resource_type' => $resource_type, + 'cacheable_metadata' => $response->getCacheableMetadata(), + ]); + $this->assertTrue(empty($normalized['data']['attributes']['type'])); + $this->assertTrue(!empty($normalized['data']['attributes']['uuid'])); + $this->assertSame($normalized['data']['attributes']['display_submitted'], TRUE); + $this->assertSame($normalized['data']['id'], NodeType::load('article')->uuid()); + $this->assertSame($normalized['data']['type'], 'node_type--node_type'); + // Make sure that the cache tags for the includes and the requested entities + // are bubbling as expected. + $this->assertSame(['config:node.type.article'], $response->getCacheableMetadata() + ->getCacheTags()); + } + + /** + * Try to POST a node and check if it exists afterwards. + * + * @covers ::denormalize + */ + public function testDenormalize() { + $payload = '{"type":"article", "data":{"attributes":{"title":"Testing article"}}}'; + + list($request, $resource_type) = $this->generateProphecies('node', 'article', 'id'); + $node = $this + ->container + ->get('serializer.normalizer.jsonapi_document_toplevel.jsonapi') + ->denormalize(Json::decode($payload), JsonApiDocumentTopLevelNormalizer::class, 'api_json', [ + 'request' => $request, + 'resource_type' => $resource_type, + ]); + $this->assertInstanceOf('\Drupal\node\Entity\Node', $node); + $this->assertSame('Testing article', $node->getTitle()); + } + + /** + * Try to POST a node and check if it exists afterwards. + * + * @covers ::denormalize + */ + public function testDenormalizeUuid() { + $configurations = [ + // Good data. + [ + [ + [$this->term2->uuid(), $this->term1->uuid()], + $this->user2->uuid(), + ], + [ + [$this->term2->id(), $this->term1->id()], + $this->user2->id(), + ], + ], + // Bad data in first tag. + [ + [ + ['invalid-uuid', $this->term1->uuid()], + $this->user2->uuid(), + ], + [ + [$this->term1->id()], + $this->user2->id(), + ], + ], + // Bad data in user and first tag. + [ + [ + ['invalid-uuid', $this->term1->uuid()], + 'also-invalid-uuid', + ], + [ + [$this->term1->id()], + NULL + ], + ], + ]; + + foreach ($configurations as $configuration) { + list($payload_data, $expected) = $this->denormalizeUuidProviderBuilder($configuration); + $payload = Json::encode($payload_data); + + list($request, $resource_type) = $this->generateProphecies('node', 'article'); + $this->container->get('request_stack')->push($request); + $node = $this + ->container + ->get('serializer.normalizer.jsonapi_document_toplevel.jsonapi') + ->denormalize(Json::decode($payload), JsonApiDocumentTopLevelNormalizer::class, 'api_json', [ + 'request' => $request, + 'resource_type' => $resource_type, + ]); + + /* @var \Drupal\node\Entity\Node $node */ + $this->assertInstanceOf('\Drupal\node\Entity\Node', $node); + $this->assertSame('Testing article', $node->getTitle()); + if (!empty($expected['user_id'])) { + $owner = $node->getOwner(); + $this->assertEquals($expected['user_id'], $owner->id()); + } + $tags = $node->get('field_tags')->getValue(); + $this->assertEquals($expected['tag_ids'][0], $tags[0]['target_id']); + if (!empty($expected['tag_ids'][1])) { + $this->assertEquals($expected['tag_ids'][1], $tags[1]['target_id']); + } + } + } + + /** + * We cannot use a PHPUnit data provider because our data depends on $this. + * + * @param array $options + * + * @return array + * The test data. + */ + protected function denormalizeUuidProviderBuilder($options) { + list($input, $expected) = $options; + list($input_tag_uuids, $input_user_uuid) = $input; + list($expected_tag_ids, $expected_user_id) = $expected; + + return [ + [ + 'type' => 'node--article', + 'data' => [ + 'attributes' => [ + 'title' => 'Testing article', + 'id' => '33095485-70D2-4E51-A309-535CC5BC0115', + ], + 'relationships' => [ + 'uid' => [ + 'data' => [ + 'type' => 'user--user', + 'id' => $input_user_uuid, + ], + ], + 'field_tags' => [ + 'data' => [ + [ + 'type' => 'taxonomy_term--tags', + 'id' => $input_tag_uuids[0], + ], + [ + 'type' => 'taxonomy_term--tags', + 'id' => $input_tag_uuids[1], + ], + ], + ], + ], + ], + ], + [ + 'tag_ids' => $expected_tag_ids, + 'user_id' => $expected_user_id, + ], + ]; + } + + /** + * Generates the prophecies for the mocked entity request. + * + * @param string $entity_type_id + * The ID of the entity type. Ex: node. + * @param string $bundle + * The bundle. Ex: article. + * + * @return array + * A numeric array containing the request and the ResourceType. + */ + protected function generateProphecies($entity_type_id, $bundle, $related_property = NULL) { + $path = sprintf('/%s/%s', $entity_type_id, $bundle); + $path = $related_property ? + sprintf('%s/%s', $path, $related_property) : + $path; + + $route = new Route($path, [ + '_on_relationship' => NULL, + ], [ + '_entity_type' => $entity_type_id, + '_bundle' => $bundle, + ]); + $request = new Request([], [], [ + RouteObjectInterface::ROUTE_OBJECT => $route, + ]); + /* @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ + $entity_type_manager = $this->container->get('entity_type.manager'); + + $resource_type = new ResourceType( + $entity_type_id, + $bundle, + $entity_type_manager->getDefinition($entity_type_id)->getClass() + ); + + /* @var \Symfony\Component\HttpFoundation\RequestStack $request_stack */ + $request_stack = $this->container->get('request_stack'); + $request_stack->push($request); + $this->container->set('request_stack', $request_stack); + $this->container->get('serializer'); + + return [$request, $resource_type]; + } + +} diff --git a/core/modules/jsonapi/tests/src/Kernel/ResourceType/ResourceTypeRepositoryTest.php b/core/modules/jsonapi/tests/src/Kernel/ResourceType/ResourceTypeRepositoryTest.php new file mode 100644 index 0000000..f2cd644 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Kernel/ResourceType/ResourceTypeRepositoryTest.php @@ -0,0 +1,97 @@ +installEntitySchema('node'); + $this->installEntitySchema('user'); + // Add the additional table schemas. + $this->installSchema('system', ['sequences']); + $this->installSchema('node', ['node_access']); + $this->installSchema('user', ['users_data']); + NodeType::create([ + 'type' => 'article', + ])->save(); + NodeType::create([ + 'type' => 'page', + ])->save(); + + $this->resourceTypeRepository = $this->container->get('jsonapi.resource_type.repository'); + } + + /** + * @covers ::all + */ + public function testAll() { + // Make sure that there are resources being created. + $all = $this->resourceTypeRepository->all(); + $this->assertNotEmpty($all); + array_walk($all, function (ResourceType $resource_type) { + $this->assertNotEmpty($resource_type->getDeserializationTargetClass()); + $this->assertNotEmpty($resource_type->getEntityTypeId()); + $this->assertNotEmpty($resource_type->getTypeName()); + }); + } + + /** + * @covers ::get + * @dataProvider getProvider + */ + public function testGet($entity_type_id, $bundle, $entity_class) { + // Make sure that there are resources being created. + $resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle); + $this->assertInstanceOf(ResourceType::class, $resource_type); + $this->assertSame($entity_class, $resource_type->getDeserializationTargetClass()); + $this->assertSame($entity_type_id, $resource_type->getEntityTypeId()); + $this->assertSame($bundle, $resource_type->getBundle()); + $this->assertSame($entity_type_id . '--' . $bundle, $resource_type->getTypeName()); + } + + /** + * Data provider for testGet. + * + * @returns array + * The data for the test method. + */ + public function getProvider() { + return [ + ['node', 'article', 'Drupal\node\Entity\Node'], + ['node_type', 'node_type', 'Drupal\node\Entity\NodeType'], + ['menu', 'menu', 'Drupal\system\Entity\Menu'], + ]; + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Access/CustomParameterNamesTest.php b/core/modules/jsonapi/tests/src/Unit/Access/CustomParameterNamesTest.php new file mode 100644 index 0000000..632b402 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Access/CustomParameterNamesTest.php @@ -0,0 +1,90 @@ +attributes->set('_json_api_params', [$name => '123']); + $result = $access_checker->access($request); + + if ($valid) { + $this->assertTrue($result->isAllowed()); + } + else { + $this->assertFalse($result->isAllowed()); + } + } + + public function providerTestJsonApiParamsValidation() { + // Copied from http://jsonapi.org/format/upcoming/#document-member-names. + $data = []; + $data['alphanumeric-lowercase'] = ['12kittens', TRUE]; + $data['alphanumeric-uppercase'] = ['12KITTENS', TRUE]; + $data['alphanumeric-mixed'] = ['12KiTtEnS', TRUE]; + $data['unicode-above-u+0080'] = ['12🐱🐱', TRUE]; + $data['hyphen-start'] = ['-kittens', FALSE]; + $data['hyphen-middle'] = ['kitt-ens', TRUE]; + $data['hyphen-end'] = ['kittens-', FALSE]; + $data['lowline-start'] = ['_kittens', FALSE]; + $data['lowline-middle'] = ['kitt_ens', TRUE]; + $data['lowline-end'] = ['kittens_', FALSE]; + $data['space-start'] = [' kittens', FALSE]; + $data['space-middle'] = ['kitt ens', TRUE]; + $data['space-end'] = ['kittens ', FALSE]; + + $unsafe_chars = [ + '+', + ',', + '.', + '[', + ']', + '!', + '”', + '#', + '$', + '%', + '&', + '’', + '(', + ')', + '*', + '/', + ':', + ';', + '<', + '=', + '>', + '?', + '@', + '“', + '^', + '`', + '{', + '|', + '}', + '~', + ]; + foreach ($unsafe_chars as $unsafe_char) { + $data['unsafe-' . $unsafe_char] = ['kitt' . $unsafe_char . 'ens', FALSE]; + } + + return $data; + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Context/CurrentContextTest.php b/core/modules/jsonapi/tests/src/Unit/Context/CurrentContextTest.php new file mode 100644 index 0000000..b0f465d --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Context/CurrentContextTest.php @@ -0,0 +1,155 @@ +fieldManager = $this->prophesize(EntityFieldManagerInterface::CLASS)->reveal(); + + // Create a mock for the current route match. + $this->currentRoute = new Route( + '/jsonapi/articles', + [], + ['_entity_type' => 'node', '_bundle' => 'article'] + ); + + // Create a mock for the ResourceTypeRepository service. + $resource_type_repository_prophecy = $this->prophesize(ResourceTypeRepository::CLASS); + $resource_type_repository_prophecy->get('node', 'article') + ->willReturn(new ResourceType('node', 'article', NodeInterface::class)); + $this->resourceTypeRepository = $resource_type_repository_prophecy->reveal(); + + $this->requestStack = new RequestStack(); + $this->requestStack->push(new Request([], [], [ + '_json_api_params' => [ + 'filter' => new Filter([], 'node', $this->fieldManager), + 'sort' => new Sort([]), + 'page' => new OffsetPage([]), + // 'include' => new IncludeParam([]), + // 'fields' => new Fields([]),. + ], + RouteObjectInterface::ROUTE_OBJECT => $this->currentRoute, + ])); + + $this->routeMatcher = new CurrentRouteMatch($this->requestStack); + } + + /** + * @covers ::getResourceType + */ + public function testGetResourceType() { + $request_context = new CurrentContext($this->resourceTypeRepository, $this->requestStack, $this->routeMatcher); + + $this->assertEquals( + $this->resourceTypeRepository->get('node', 'article'), + $request_context->getResourceType() + ); + } + + /** + * @covers ::getJsonApiParameter + */ + public function testGetJsonApiParameter() { + $request_context = new CurrentContext($this->resourceTypeRepository, $this->requestStack, $this->routeMatcher); + + $expected = new Sort([]); + $actual = $request_context->getJsonApiParameter('sort'); + + $this->assertEquals($expected, $actual); + } + + /** + * @covers ::hasExtension + */ + public function testHasExtensionWithExistingExtension() { + $request = new Request(); + $request->headers->set('Content-Type', 'application/vnd.api+json; ext="ext1,ext2"'); + $this->requestStack->push($request); + $request_context = new CurrentContext($this->resourceTypeRepository, $this->requestStack, $this->routeMatcher); + + $this->assertTrue($request_context->hasExtension('ext1')); + $this->assertTrue($request_context->hasExtension('ext2')); + } + + /** + * @covers ::getExtensions + */ + public function testGetExtensions() { + $request = new Request(); + $request->headers->set('Content-Type', 'application/vnd.api+json; ext="ext1,ext2"'); + $this->requestStack->push($request); + $request_context = new CurrentContext($this->resourceTypeRepository, $this->requestStack, $this->routeMatcher); + + $this->assertEquals(['ext1', 'ext2'], $request_context->getExtensions()); + } + + /** + * @covers ::hasExtension + */ + public function testHasExtensionWithNotExistingExtension() { + $request = new Request(); + $request->headers->set('Content-Type', 'application/vnd.api+json;'); + $this->requestStack->push($request); + $request_context = new CurrentContext($this->resourceTypeRepository, $this->requestStack, $this->routeMatcher); + $this->assertFalse($request_context->hasExtension('ext1')); + $this->assertFalse($request_context->hasExtension('ext2')); + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Context/FieldResolverTest.php b/core/modules/jsonapi/tests/src/Unit/Context/FieldResolverTest.php new file mode 100644 index 0000000..12fa6b0 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Context/FieldResolverTest.php @@ -0,0 +1,117 @@ +prophesize(CurrentContext::class); + + $current_context->getResourceType() + ->willReturn(new ResourceType('lorem', $this->randomMachineName(), NULL)); + + $this->currentContext = $current_context->reveal(); + } + + /** + * Expects a public field name to be expanded into a Drupal field name. + * + * @covers ::resolveInternal + */ + public function testResolveInternalNested() { + $field_manager = $this->prophesize(EntityFieldManagerInterface::class); + $field_storage1 = $this->prophesize(FieldStorageDefinitionInterface::class); + $field_storage1->getSetting('target_type')->willReturn('ipsum'); + $field_storage2 = $this->prophesize(FieldStorageDefinitionInterface::class); + $field_storage2->getSetting('target_type')->willReturn('dolor'); + $field_storage3 = $this->prophesize(FieldStorageDefinitionInterface::class); + $field_storage3->getSetting('target_type')->willReturn(NULL); + $field_manager->getFieldStorageDefinitions('lorem') + ->willReturn(['host' => $field_storage1->reveal()]); + $field_manager->getFieldStorageDefinitions('ipsum') + ->willReturn(['nested' => $field_storage2->reveal()]); + $field_manager->getFieldStorageDefinitions('dolor') + ->willReturn(['deep' => $field_storage3->reveal()]); + + $original = 'host.nested.deep'; + $expected = 'host.entity.nested.entity.deep'; + $field_resolver = new FieldResolver($this->currentContext, $field_manager->reveal()); + + $this->assertEquals($expected, $field_resolver->resolveInternal($original)); + } + + /** + * Expects a public field name to be expanded into a Drupal field name ending + * with a complex field. + * + * @covers ::resolveInternal + */ + public function testResolveInternalComplex() { + $field_manager = $this->prophesize(EntityFieldManagerInterface::class); + $field_storage1 = $this->prophesize(FieldStorageDefinitionInterface::class); + $field_storage1->getSetting('target_type')->willReturn('ipsum'); + $field_storage2 = $this->prophesize(FieldStorageDefinitionInterface::class); + $field_storage2->getSetting('target_type')->willReturn(NULL); + $field_manager->getFieldStorageDefinitions('lorem') + ->willReturn(['host' => $field_storage1->reveal()]); + $field_manager->getFieldStorageDefinitions('ipsum') + ->willReturn(['nested' => $field_storage2->reveal()]); + + $original = 'host.nested.deep'; + $expected = 'host.entity.nested.deep'; + $field_resolver = new FieldResolver($this->currentContext, $field_manager->reveal()); + + $this->assertEquals($expected, $field_resolver->resolveInternal($original)); + } + + /** + * Expects an error when an invalid field is provided. + * + * @covers ::resolveInternal + * + * @expectedException \Drupal\jsonapi\Error\SerializableHttpException + */ + public function testResolveInternalError() { + $field_manager = $this->prophesize(EntityFieldManagerInterface::class); + $field_storage1 = $this->prophesize(FieldStorageDefinitionInterface::class); + $field_storage1->getType()->willReturn('entity_reference'); + $field_storage1->getSetting('target_type')->willReturn('ipsum'); + $field_manager->getFieldStorageDefinitions('lorem') + ->willReturn(['fail' => $field_storage1->reveal()]); + + $original = 'host.nested.deep'; + $not_expected = 'host.entity.nested.entity.deep'; + $field_resolver = new FieldResolver($this->currentContext, $field_manager->reveal()); + + $this->assertEquals($not_expected, $field_resolver->resolveInternal($original)); + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Controller/RequestHandlerTest.php b/core/modules/jsonapi/tests/src/Unit/Controller/RequestHandlerTest.php new file mode 100644 index 0000000..8f86e18 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Controller/RequestHandlerTest.php @@ -0,0 +1,60 @@ +prophesize(EntityStorageInterface::class); + $request_handler = new RequestHandler($entity_storage->reveal()); + $request = $this->prophesize(Request::class); + $request->getContentType()->willReturn(NULL); + $request->getContent()->willReturn('this is not used'); + $request->getMethod()->willReturn(NULL); + $request->get(Argument::any())->willReturn(NULL); + $request->getMimeType(Argument::any())->willReturn(NULL); + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->deserialize(Argument::type('string'), Argument::type('string'), Argument::any(), Argument::type('array')) + ->willThrow(new UnexpectedValueException('Foo')); + $serializer->serialize(Argument::any(), Argument::any(), Argument::any()) + ->willReturn('{"errors":[{"status":422,"message":"Foo"}]}'); + $current_context = $this->prophesize(CurrentContext::class); + $current_context->getResourceType() + ->willReturn(new ResourceType($this->randomMachineName(), $this->randomMachineName(), NULL)); + try { + $request_handler->deserializeBody( + $request->reveal(), + $serializer->reveal(), + 'invalid', + $current_context->reveal() + ); + $this->fail('Expected exception.'); + } + catch (HttpException $e) { + $this->assertEquals(422, $e->getStatusCode()); + // Re-throw the exception so the test runner can catch it. + throw $e; + } + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/LinkManager/LinkManagerTest.php b/core/modules/jsonapi/tests/src/Unit/LinkManager/LinkManagerTest.php new file mode 100644 index 0000000..65ccefe --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/LinkManager/LinkManagerTest.php @@ -0,0 +1,172 @@ +prophesize(ChainRouterInterface::class); + $router->matchRequest(Argument::type(Request::class))->willReturn([ + RouteObjectInterface::ROUTE_NAME => 'fake', + '_raw_variables' => new ParameterBag(['lorem' => 'ipsum']), + ]); + $url_generator = $this->prophesize(UrlGeneratorInterface::class); + $url_generator->generateFromRoute(Argument::cetera())->willReturnArgument(2); + $this->linkManager = new LinkManager($router->reveal(), $url_generator->reveal()); + } + + + /** + * @covers ::getPagerLinks + * @dataProvider getPagerLinksProvider + */ + public function testGetPagerLinks($offset, $size, $has_next_page, array $pages) { + // Add the extra stuff to the expected query. + $pages = array_filter($pages); + $pages = array_map(function ($page) { + return ['absolute' => TRUE, 'query' => ['page' => $page]]; + }, $pages); + + $request = $this->prophesize(Request::class); + // Have the request return the desired page parameter. + $page_param = $this->prophesize(OffsetPage::class); + $page_param->getOffset()->willReturn($offset); + $page_param->getSize()->willReturn($size); + $request->get('_json_api_params')->willReturn(['page' => $page_param->reveal()]); + $request->query = new ParameterBag(); + + $links = $this->linkManager + ->getPagerLinks($request->reveal(), ['has_next_page' => $has_next_page]); + $this->assertEquals($pages, $links); + } + + /** + * Data provider for testGetPagerLinks + * + * @return array + * The data for the test method. + */ + public function getPagerLinksProvider() { + return [ + [1, 4, TRUE, [ + 'first' => ['offset' => 0, 'limit' => 4], + 'prev' => ['offset' => 0, 'limit' => 4], + 'next' => ['offset' => 5, 'limit' => 4], + ]], + [6, 4, FALSE, [ + 'first' => ['offset' => 0, 'limit' => 4], + 'prev' => ['offset' => 2, 'limit' => 4], + 'next' => NULL, + ]], + [7, 4, FALSE, [ + 'first' => ['offset' => 0, 'limit' => 4], + 'prev' => ['offset' => 3, 'limit' => 4], + 'next' => NULL, + ]], + [10, 4, FALSE, [ + 'first' => ['offset' => 0, 'limit' => 4], + 'prev' => ['offset' => 6, 'limit' => 4], + 'next' => NULL, + ]], + [5, 4, TRUE, [ + 'first' => ['offset' => 0, 'limit' => 4], + 'prev' => ['offset' => 1, 'limit' => 4], + 'next' => ['offset' => 9, 'limit' => 4], + ]], + [0, 4, TRUE, [ + 'first' => NULL, + 'prev' => NULL, + 'next' => ['offset' => 4, 'limit' => 4], + ]], + [0, 1, FALSE, [ + 'first' => NULL, + 'prev' => NULL, + 'next' => NULL, + ]], + [0, 1, FALSE, [ + 'first' => NULL, + 'prev' => NULL, + 'next' => NULL, + ]], + ]; + } + + /** + * Test errors. + * + * @covers ::getPagerLinks + * @expectedException \Drupal\jsonapi\Error\SerializableHttpException + * @dataProvider getPagerLinksErrorProvider + */ + public function testGetPagerLinksError($offset, $size, $total, array $pages) { + $this->testGetPagerLinks($offset, $size, $total, $pages); + } + + /** + * Data provider for testGetPagerLinksError. + * + * @return array + * The data for the test method. + */ + public function getPagerLinksErrorProvider() { + return [ + [0, -5, FALSE, [ + 'first' => NULL, + 'prev' => NULL, + 'last' => NULL, + 'next' => NULL, + ]], + ]; + } + + /** + * @covers ::getRequestLink + */ + public function testGetRequestLink() { + $request = $this->prophesize(Request::class); + // Have the request return the desired page parameter. + $page_param = $this->prophesize(OffsetPage::class); + $page_param->getOffset()->willReturn(NULL); + $page_param->getSize()->willReturn(NULL); + $request->get('_json_api_params')->willReturn(['page' => $page_param->reveal()]); + $request->query = new ParameterBag(['amet' => 'pax']); + + $query = $this->linkManager->getRequestLink($request->reveal(), ['dolor' => 'sid']); + $this->assertEquals([ + 'absolute' => TRUE, + 'query' => ['dolor' => 'sid'], + ], $query); + // Get the default query from the request object. + $query = $this->linkManager->getRequestLink($request->reveal()); + $this->assertEquals([ + 'absolute' => TRUE, + 'query' => ['amet' => 'pax'], + ], $query); + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/ConfigEntityNormalizerTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/ConfigEntityNormalizerTest.php new file mode 100644 index 0000000..732dca0 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/ConfigEntityNormalizerTest.php @@ -0,0 +1,89 @@ +prophesize(LinkManager::class); + + $resource_type_repository = $this->prophesize(ResourceTypeRepository::class); + $resource_type_repository->get(Argument::type('string'), Argument::type('string')) + ->willReturn(new ResourceType('dolor', 'sid', NULL)); + + $this->normalizer = new ConfigEntityNormalizer( + $link_manager->reveal(), + $resource_type_repository->reveal(), + $this->prophesize(EntityTypeManagerInterface::class)->reveal() + ); + + $normalizers = [new ScalarNormalizer()]; + $serializer = new Serializer($normalizers, []); + $this->normalizer->setSerializer($serializer); + } + + /** + * @covers ::normalize + * @dataProvider normalizeProvider + */ + public function testNormalize($input, $expected) { + $entity = $this->prophesize(ConfigEntityInterface::class); + $entity->toArray()->willReturn(['amet' => $input]); + $entity->getCacheContexts()->willReturn([]); + $entity->getCacheTags()->willReturn([]); + $entity->getCacheMaxAge()->willReturn(-1); + $entity->getEntityTypeId()->willReturn(''); + $entity->bundle()->willReturn(''); + $normalized = $this->normalizer->normalize($entity->reveal(), 'api_json', []); + $first = $normalized->getValues(); + $first = reset($first); + $this->assertSame($expected, $first->rasterizeValue()); + } + + /** + * Data provider for the normalize test. + * + * @return array + * The data for the test method. + */ + public function normalizeProvider() { + return [ + ['lorem', 'lorem'], + [ + ['ipsum' => 'dolor', 'ra' => 'foo'], + ['ipsum' => 'dolor', 'ra' => 'foo'], + ], + [['ipsum' => 'dolor'], 'dolor'], + [ + ['lorem' => ['ipsum' => ['dolor' => 'sid', 'amet' => 'ra']]], + ['ipsum' => ['dolor' => 'sid', 'amet' => 'ra']], + ], + ]; + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/EntityReferenceFieldNormalizerTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/EntityReferenceFieldNormalizerTest.php new file mode 100644 index 0000000..144ffc6 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/EntityReferenceFieldNormalizerTest.php @@ -0,0 +1,159 @@ +prophesize(LinkManager::class); + $field_manager = $this->prophesize(EntityFieldManagerInterface::class); + $field_definition = $this->prophesize(FieldConfig::class); + $item_definition = $this->prophesize(FieldItemDataDefinition::class); + $item_definition->getMainPropertyName()->willReturn('bunny'); + $item_definition->getSetting('target_type')->willReturn('fake_entity_type'); + $item_definition->getSetting('handler_settings')->willReturn([ + 'target_bundles' => ['dummy_bundle'], + ]); + $field_definition->getItemDefinition() + ->willReturn($item_definition->reveal()); + $storage_definition = $this->prophesize(FieldStorageDefinitionInterface::class); + $storage_definition->isMultiple()->willReturn(TRUE); + $field_definition->getFieldStorageDefinition()->willReturn($storage_definition->reveal()); + + $field_definition2 = $this->prophesize(FieldConfig::class); + $field_definition2->getItemDefinition() + ->willReturn($item_definition->reveal()); + $storage_definition2 = $this->prophesize(FieldStorageDefinitionInterface::class); + $storage_definition2->isMultiple()->willReturn(FALSE); + $field_definition2->getFieldStorageDefinition()->willReturn($storage_definition2->reveal()); + + $field_manager->getFieldDefinitions('fake_entity_type', 'dummy_bundle') + ->willReturn([ + 'field_dummy' => $field_definition->reveal(), + 'field_dummy_single' => $field_definition2->reveal(), + ]); + $plugin_manager = $this->prophesize(FieldTypePluginManagerInterface::class); + $plugin_manager->createFieldItemList( + Argument::type(FieldableEntityInterface::class), + Argument::type('string'), + Argument::type('array') + )->willReturnArgument(2); + $resource_type_repository = $this->prophesize(ResourceTypeRepository::class); + $resource_type_repository->get('fake_entity_type', 'dummy_bundle') + ->willReturn(new ResourceType('lorem', 'dummy_bundle', NULL)); + + $entity = $this->prophesize(EntityInterface::class); + $entity->uuid()->willReturn('4e6cb61d-4f04-437f-99fe-42c002393658'); + $entity->id()->willReturn(42); + $entity_repository = $this->prophesize(EntityRepositoryInterface::class); + $entity_repository->loadEntityByUuid('lorem', '4e6cb61d-4f04-437f-99fe-42c002393658') + ->willReturn($entity->reveal()); + + $this->normalizer = new EntityReferenceFieldNormalizer( + $link_manager->reveal(), + $field_manager->reveal(), + $plugin_manager->reveal(), + $resource_type_repository->reveal(), + $entity_repository->reveal() + ); + } + + /** + * @covers ::denormalize + * @dataProvider denormalizeProvider + */ + public function testDenormalize($input, $field_name, $expected) { + $entity = $this->prophesize(FieldableEntityInterface::class); + $context = [ + 'resource_type' => new ResourceType('fake_entity_type', 'dummy_bundle', NULL), + 'related' => $field_name, + 'target_entity' => $entity->reveal(), + ]; + $denormalized = $this->normalizer->denormalize($input, NULL, 'api_json', $context); + $this->assertSame($expected, $denormalized); + } + + /** + * Data provider for the denormalize test. + * + * @return array + * The data for the test method. + */ + public function denormalizeProvider() { + return [ + [ + ['data' => [['type' => 'lorem--dummy_bundle', 'id' => '4e6cb61d-4f04-437f-99fe-42c002393658']]], + 'field_dummy', + [['bunny' => 42]], + ], + [ + ['data' => []], + 'field_dummy', + [], + ], + [ + ['data' => NULL], + 'field_dummy_single', + [], + ], + ]; + } + + /** + * @covers ::denormalize + * @expectedException \Drupal\jsonapi\Error\SerializableHttpException + * @dataProvider denormalizeInvalidResourceProvider + */ + public function testDenormalizeInvalidResource($data, $field_name) { + $context = [ + 'resource_type' => new ResourceType('fake_entity_type', 'dummy_bundle', NULL), + 'related' => $field_name, + 'target_entity' => $this->prophesize(FieldableEntityInterface::class)->reveal(), + ]; + $this->normalizer->denormalize($data, NULL, 'api_json', $context); + } + + /** + * Data provider for the denormalize test. + * + * @return array + * The input data for the test method. + */ + public function denormalizeInvalidResourceProvider() { + return [ + [['data' => [['type' => 'invalid', 'id' => '4e6cb61d-4f04-437f-99fe-42c002393658']]], 'field_dummy'], + [['data' => ['type' => 'lorem', 'id' => '4e6cb61d-4f04-437f-99fe-42c002393658']], 'field_dummy'], + [['data' => [['type' => 'lorem', 'id' => '4e6cb61d-4f04-437f-99fe-42c002393658']]], 'field_dummy_single'], + ]; + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/HttpExceptionNormalizerTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/HttpExceptionNormalizerTest.php new file mode 100644 index 0000000..f72db5c --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/HttpExceptionNormalizerTest.php @@ -0,0 +1,45 @@ +prophesize(AccountProxyInterface::class); + $current_user->hasPermission('access site reports')->willReturn(TRUE); + $normalizer = new HttpExceptionNormalizer($current_user->reveal()); + $normalized = $normalizer->normalize($exception, 'api_json'); + $normalized = $normalized->rasterizeValue(); + $error = $normalized[0]; + $this->assertNotEmpty($error['meta']); + $this->assertNotEmpty($error['source']); + $this->assertEquals(13, $error['code']); + $this->assertEquals(403, $error['status']); + $this->assertEquals('Forbidden', $error['title']); + $this->assertEquals('lorem', $error['detail']); + $this->assertNull($error['meta']['trace'][1]['args'][0]); + + $current_user = $this->prophesize(AccountProxyInterface::class); + $current_user->hasPermission('access site reports')->willReturn(FALSE); + $normalizer = new HttpExceptionNormalizer($current_user->reveal()); + $normalized = $normalizer->normalize($exception, 'api_json'); + $normalized = $normalized->rasterizeValue(); + $error = $normalized[0]; + $this->assertTrue(empty($error['meta'])); + $this->assertTrue(empty($error['source'])); + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php new file mode 100644 index 0000000..6d7ca9d --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/JsonApiDocumentTopLevelNormalizerTest.php @@ -0,0 +1,136 @@ +prophesize(LinkManager::class); + $current_context_manager = $this->prophesize(CurrentContext::class); + + $entity_storage = $this->prophesize(EntityStorageInterface::class); + $self = $this; + $uuid_to_id = [ + '76dd5c18-ea1b-4150-9e75-b21958a2b836' => 1, + 'fcce1b61-258e-4054-ae36-244d25a9e04c' => 2, + ]; + $entity_storage->loadByProperties(Argument::type('array')) + ->will(function ($args) use ($self, $uuid_to_id) { + $result = []; + foreach ($args[0]['uuid'] as $uuid) { + $entity = $self->prophesize(EntityInterface::class); + $entity->uuid()->willReturn($uuid); + $entity->id()->willReturn($uuid_to_id[$uuid]); + $result[$uuid] = $entity->reveal(); + } + return $result; + }); + $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class); + $entity_type_manager->getStorage('node') + ->willReturn($entity_storage->reveal()); + + $current_route = $this->prophesize(Route::class); + $current_route->getDefault('_on_relationship')->willReturn(FALSE); + + $current_context_manager->isOnRelationship()->willReturn(FALSE); + + $this->normalizer = new JsonApiDocumentTopLevelNormalizer( + $link_manager->reveal(), + $current_context_manager->reveal(), + $entity_type_manager->reveal() + ); + + $serializer = $this->prophesize(DenormalizerInterface::class); + $serializer->willImplement(SerializerInterface::class); + $serializer->denormalize( + Argument::type('array'), + Argument::type('string'), + Argument::type('string'), + Argument::type('array') + )->willReturnArgument(0); + + $this->normalizer->setSerializer($serializer->reveal()); + } + + /** + * @covers ::denormalize + * @dataProvider denormalizeProvider + */ + public function testDenormalize($input, $expected) { + $context = [ + 'resource_type' => new ResourceType($this->randomMachineName(), $this->randomMachineName(), FieldableEntityInterface::class), + ]; + $denormalized = $this->normalizer->denormalize($input, NULL, 'api_json', $context); + $this->assertSame($expected, $denormalized); + } + + /** + * Data provider for the denormalize test. + * + * @return array + * The data for the test method. + */ + public function denormalizeProvider() { + return [ + [ + [ + 'data' => [ + 'type' => 'lorem', + 'id' => 'e1a613f6-f2b9-4e17-9d33-727eb6509d8b', + 'attributes' => ['title' => 'dummy_title'], + ], + ], + ['title' => 'dummy_title'], + ], + [ + [ + 'data' => [ + 'type' => 'lorem', + 'id' => '0676d1bf-55b3-4bbc-9fbc-3df10f4599d5', + 'relationships' => ['field_dummy' => ['data' => ['type' => 'node', 'id' => '76dd5c18-ea1b-4150-9e75-b21958a2b836']]], + ], + ], + ['field_dummy' => [1]], + ], + [ + [ + 'data' => [ + 'type' => 'lorem', + 'id' => '535ba297-8d79-4fc1-b0d6-dc2f047765a1', + 'relationships' => ['field_dummy' => ['data' => [['type' => 'node', 'id' => '76dd5c18-ea1b-4150-9e75-b21958a2b836'], ['type' => 'node', 'id' => 'fcce1b61-258e-4054-ae36-244d25a9e04c']]]], + ], + ], + ['field_dummy' => [1, 2]], + ], + ]; + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/EntityNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/EntityNormalizerValueTest.php new file mode 100644 index 0000000..7c78146 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/EntityNormalizerValueTest.php @@ -0,0 +1,157 @@ +prophesize(FieldNormalizerValueInterface::class); + $field1->getIncludes()->willReturn([]); + $field1->getPropertyType()->willReturn('attributes'); + $field1->rasterizeValue()->willReturn('dummy_title'); + $field2 = $this->prophesize(RelationshipNormalizerValue::class); + $field2->getPropertyType()->willReturn('relationships'); + $field2->rasterizeValue()->willReturn(['data' => ['type' => 'node', 'id' => 2]]); + $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class); + $included[0]->getIncludes()->willReturn([]); + $included[0]->rasterizeValue()->willReturn([ + 'data' => [ + 'type' => 'node', + 'id' => '199c681d-a9dc-4b6f-a4dc-e3811f24141b', + 'attributes' => ['body' => 'dummy_body1'], + ], + ]); + $included[0]->getCacheContexts()->willReturn(['lorem', 'ipsum']); + // Type & id duplicated on purpose. + $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class); + $included[1]->getIncludes()->willReturn([]); + $included[1]->rasterizeValue()->willReturn([ + 'data' => [ + 'type' => 'node', + 'id' => '199c681d-a9dc-4b6f-a4dc-e3811f24141b', + 'attributes' => ['body' => 'dummy_body2'], + ], + ]); + $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class); + $included[2]->getIncludes()->willReturn([]); + $included[2]->rasterizeValue()->willReturn([ + 'data' => [ + 'type' => 'node', + 'id' => '83771375-a4ba-4d7d-a4d5-6153095bb5c5', + 'attributes' => ['body' => 'dummy_body3'], + ], + ]); + $field2->getIncludes()->willReturn(array_map(function ($included_item) { + return $included_item->reveal(); + }, $included)); + $context = ['resource_type' => new ResourceType('node', 'article', NodeInterface::class)]; + $entity = $this->prophesize(EntityInterface::class); + $entity->uuid()->willReturn('248150b2-79a2-4b44-9f49-bf405a51414a'); + $entity->isNew()->willReturn(FALSE); + $entity->getEntityTypeId()->willReturn('node'); + $entity->bundle()->willReturn('article'); + $link_manager = $this->prophesize(LinkManager::class); + $link_manager + ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string')) + ->willReturn('dummy_entity_link'); + + // Stub the addCacheableDependency on the SUT. We'll test the cacheable + // metadata bubbling using Kernel tests. + $this->object = $this->getMockBuilder(EntityNormalizerValue::class) + ->setMethods(['addCacheableDependency']) + ->setConstructorArgs([ + ['title' => $field1->reveal(), 'field_related' => $field2->reveal()], + $context, + $entity->reveal(), + ['link_manager' => $link_manager->reveal()], + ]) + ->getMock(); + $this->object->method('addCacheableDependency'); + } + + + /** + * @covers ::rasterizeValue + */ + public function testRasterizeValue() { + $this->assertEquals([ + 'type' => 'node--article', + 'id' => '248150b2-79a2-4b44-9f49-bf405a51414a', + 'attributes' => ['title' => 'dummy_title'], + 'relationships' => [ + 'field_related' => ['data' => ['type' => 'node', 'id' => 2]], + ], + 'links' => [ + 'self' => 'dummy_entity_link', + ], + ], $this->object->rasterizeValue()); + } + + /** + * @covers ::rasterizeIncludes + */ + public function testRasterizeIncludes() { + $expected = [ + [ + 'data' => [ + 'type' => 'node', + 'id' => '199c681d-a9dc-4b6f-a4dc-e3811f24141b', + 'attributes' => ['body' => 'dummy_body1'], + ], + ], + [ + 'data' => [ + 'type' => 'node', + 'id' => '199c681d-a9dc-4b6f-a4dc-e3811f24141b', + 'attributes' => ['body' => 'dummy_body2'], + ], + ], + [ + 'data' => [ + 'type' => 'node', + 'id' => '83771375-a4ba-4d7d-a4d5-6153095bb5c5', + 'attributes' => ['body' => 'dummy_body3'], + ], + ], + ]; + $this->assertEquals($expected, $this->object->rasterizeIncludes()); + } + + /** + * @covers ::getIncludes + */ + public function testGetIncludes() { + $includes = $this->object->getIncludes(); + $includes = array_filter($includes, function ($included) { + return $included instanceof JsonApiDocumentTopLevelNormalizerValue; + }); + $this->assertCount(3, $includes); + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldItemNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldItemNormalizerValueTest.php new file mode 100644 index 0000000..60bb2a4 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldItemNormalizerValueTest.php @@ -0,0 +1,46 @@ +assertEquals($expected, $object->rasterizeValue()); + } + + /** + * Provider for testRasterizeValue. + */ + public function rasterizeValueProvider() { + return [ + [['value' => 1], 1], + [['value' => 1, 'safe_value' => 1], ['value' => 1, 'safe_value' => 1]], + [[], []], + [[NULL], NULL], + [ + [ + 'lorem' => [ + 'ipsum' => new FieldItemNormalizerValue([ + 'dolor' => 'sid', + 'amet' => new FieldItemNormalizerValue(['value' => 'ra']), + ]), + ], + ], + ['ipsum' => ['dolor' => 'sid', 'amet' => 'ra']], + ], + ]; + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldNormalizerValueTest.php new file mode 100644 index 0000000..2c94b20 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/FieldNormalizerValueTest.php @@ -0,0 +1,50 @@ +assertEquals($expected, $object->rasterizeValue()); + } + + /** + * Data provider for testRasterizeValue. + */ + public function rasterizeValueProvider() { + $uuid_raw = '4ae99eec-8b0e-41f7-9400-fbd65c174902'; + $uuid_value = $this->prophesize(FieldItemNormalizerValue::class); + $uuid_value->rasterizeValue()->willReturn('4ae99eec-8b0e-41f7-9400-fbd65c174902'); + $uuid_value->getInclude()->willReturn(NULL); + return [ + [[$uuid_value->reveal()], 1, $uuid_raw], + [[$uuid_value->reveal(), $uuid_value->reveal()], -1, [$uuid_raw, $uuid_raw]], + ]; + } + + /** + * @covers ::rasterizeIncludes + */ + public function testRasterizeIncludes() { + $value = $this->prophesize(FieldItemNormalizerValue::class); + $include = $this->prophesize('\Drupal\jsonapi\Normalizer\Value\EntityNormalizerValue'); + $include->rasterizeValue()->willReturn('Lorem'); + $value->getInclude()->willReturn($include->reveal()); + $object = new FieldNormalizerValue([$value->reveal()], 1); + $this->assertEquals(['Lorem'], $object->rasterizeIncludes()); + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValueTest.php new file mode 100644 index 0000000..6731135 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/JsonApiDocumentTopLevelNormalizerValueTest.php @@ -0,0 +1,111 @@ +prophesize(FieldNormalizerValueInterface::class); + $field1->getIncludes()->willReturn([]); + $field1->getPropertyType()->willReturn('attributes'); + $field1->rasterizeValue()->willReturn('dummy_title'); + $field2 = $this->prophesize(RelationshipNormalizerValue::class); + $field2->getPropertyType()->willReturn('relationships'); + $field2->rasterizeValue()->willReturn(['data' => ['type' => 'node', 'id' => 2]]); + $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class); + $included[0]->getIncludes()->willReturn([]); + $included[0]->rasterizeValue()->willReturn([ + 'data' => [ + 'type' => 'node', + 'id' => 3, + 'attributes' => ['body' => 'dummy_body1'], + ], + ]); + $included[0]->getCacheContexts()->willReturn(['lorem:ipsum']); + // Type & id duplicated in purpose. + $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class); + $included[1]->getIncludes()->willReturn([]); + $included[1]->rasterizeValue()->willReturn([ + 'data' => [ + 'type' => 'node', + 'id' => 3, + 'attributes' => ['body' => 'dummy_body2'], + ], + ]); + $included[] = $this->prophesize(JsonApiDocumentTopLevelNormalizerValue::class); + $included[2]->getIncludes()->willReturn([]); + $included[2]->rasterizeValue()->willReturn([ + 'data' => [ + 'type' => 'node', + 'id' => 4, + 'attributes' => ['body' => 'dummy_body3'], + ], + ]); + $field2->getIncludes()->willReturn(array_map(function ($included_item) { + return $included_item->reveal(); + }, $included)); + $context = ['resource_type' => new ResourceType('node', 'article', NodeInterface::class)]; + $entity = $this->prophesize(EntityInterface::class); + $entity->id()->willReturn(1); + $entity->isNew()->willReturn(FALSE); + $entity->getEntityTypeId()->willReturn('node'); + $entity->bundle()->willReturn('article'); + $entity->hasLinkTemplate(Argument::type('string'))->willReturn(TRUE); + $url = $this->prophesize(Url::class); + $url->toString()->willReturn('dummy_entity_link'); + $url->setRouteParameter(Argument::any(), Argument::any())->willReturn($url->reveal()); + $entity->toUrl(Argument::type('string'), Argument::type('array'))->willReturn($url->reveal()); + $link_manager = $this->prophesize(LinkManager::class); + $link_manager + ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string')) + ->willReturn('dummy_entity_link'); + $this->object = $this->getMockBuilder(JsonApiDocumentTopLevelNormalizerValue::class) + ->setMethods(['addCacheableDependency']) + ->setConstructorArgs([ + ['title' => $field1->reveal(), 'field_related' => $field2->reveal()], + $context, + $entity->reveal(), + ['link_manager' => $link_manager->reveal()] + ]) + ->getMock(); + $this->object->method('addCacheableDependency'); + } + + /** + * @covers ::getIncludes + */ + public function testGetIncludes() { + $includes = $this->object->getIncludes(); + $includes = array_filter($includes, function ($included) { + return $included instanceof JsonApiDocumentTopLevelNormalizerValue; + }); + $this->assertCount(2, $includes); + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipItemNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipItemNormalizerValueTest.php new file mode 100644 index 0000000..f9f16a7 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipItemNormalizerValueTest.php @@ -0,0 +1,37 @@ +assertEquals($expected, $object->rasterizeValue()); + } + + /** + * Data provider for testRasterizeValue. + */ + public function rasterizeValueProvider() { + return [ + [['target_id' => 1], 'node', 'article', ['type' => 'node--article', 'id' => 1]], + [['value' => 1], 'node', 'page', ['type' => 'node--page', 'id' => 1]], + [[1], 'node', 'foo', ['type' => 'node--foo', 'id' => 1]], + [[], 'node', 'bar', []], + [[NULL], 'node', 'baz', NULL], + ]; + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipNormalizerValueTest.php b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipNormalizerValueTest.php new file mode 100644 index 0000000..92184cb --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Normalizer/Value/RelationshipNormalizerValueTest.php @@ -0,0 +1,93 @@ +prophesize(LinkManager::class); + $link_manager + ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string')) + ->willReturn('dummy_entity_link'); + $object = new RelationshipNormalizerValue($values, $cardinality, [ + 'link_manager' => $link_manager->reveal(), + 'host_entity_id' => 'lorem', + 'resource_type' => new ResourceType($this->randomMachineName(), $this->randomMachineName(), NULL), + 'field_name' => 'ipsum', + ]); + $this->assertEquals($expected, $object->rasterizeValue()); + } + + /** + * Data provider fortestRasterizeValue. + */ + public function rasterizeValueProvider() { + $uid_raw = 1; + $uid1 = $this->prophesize(RelationshipItemNormalizerValue::class); + $uid1->rasterizeValue()->willReturn(['type' => 'user', 'id' => $uid_raw++]); + $uid1->getInclude()->willReturn(NULL); + $uid2 = $this->prophesize(RelationshipItemNormalizerValue::class); + $uid2->rasterizeValue()->willReturn(['type' => 'user', 'id' => $uid_raw]); + $uid2->getInclude()->willReturn(NULL); + $links = [ + 'self' => 'dummy_entity_link', + 'related' => 'dummy_entity_link', + ]; + return [ + [[$uid1->reveal()], 1, [ + 'data' => ['type' => 'user', 'id' => 1], + 'links' => $links, + ]], + [ + [$uid1->reveal(), $uid2->reveal()], 2, [ + 'data' => [ + ['type' => 'user', 'id' => 1], + ['type' => 'user', 'id' => 2], + ], + 'links' => $links, + ], + ], + ]; + } + + /** + * @covers ::rasterizeValue + * + * @expectedException \RuntimeException + */ + public function testRasterizeValueFails() { + $uid1 = $this->prophesize(FieldItemNormalizerValue::class); + $uid1->rasterizeValue()->willReturn(1); + $uid1->getInclude()->willReturn(NULL); + $link_manager = $this->prophesize(LinkManager::class); + $link_manager + ->getEntityLink(Argument::any(), Argument::any(), Argument::type('array'), Argument::type('string')) + ->willReturn('dummy_entity_link'); + $object = new RelationshipNormalizerValue([$uid1->reveal()], 1, [ + 'link_manager' => $link_manager->reveal(), + 'host_entity_id' => 'lorem', + 'resource_type' => new ResourceType($this->randomMachineName(), $this->randomMachineName(), NULL), + 'field_name' => 'ipsum', + ]); + $object->rasterizeValue(); + // If the exception was not thrown, then the following fails. + $this->assertTrue(FALSE); + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/RequestCacheabilityDependencyTest.php b/core/modules/jsonapi/tests/src/Unit/RequestCacheabilityDependencyTest.php new file mode 100644 index 0000000..f5a1daf --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/RequestCacheabilityDependencyTest.php @@ -0,0 +1,57 @@ +cacheableDependency = new RequestCacheabilityDependency(); + } + + + /** + * @covers ::getCacheContexts + */ + public function testGetCacheContexts() { + $this->assertArrayEquals([ + 'url.query_args:filter', + 'url.query_args:sort', + 'url.query_args:page', + 'url.query_args:fields', + 'url.query_args:include', + ], $this->cacheableDependency->getCacheContexts()); + } + + /** + * @covers ::getCacheContexts + */ + public function testGetCacheTags() { + $this->assertArrayEquals([], $this->cacheableDependency->getCacheTags()); + } + + /** + * @covers ::getCacheContexts + */ + public function testGetCacheMaxAge() { + $this->assertEquals(-1, $this->cacheableDependency->getCacheMaxAge()); + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php b/core/modules/jsonapi/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php new file mode 100644 index 0000000..ba99ae6 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Routing/JsonApiParamEnhancerTest.php @@ -0,0 +1,97 @@ +prophesize(EntityFieldManagerInterface::class)->reveal()); + $route = $this->prophesize(Route::class); + $route->getDefault(RouteObjectInterface::CONTROLLER_NAME)->will(new ReturnPromise([Routes::FRONT_CONTROLLER, 'lorem'])); + + $this->assertTrue($object->applies($route->reveal())); + $this->assertFalse($object->applies($route->reveal())); + } + + /** + * @covers ::enhance + */ + public function testEnhanceFilter() { + $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal()); + $request = $this->prophesize(Request::class); + $query = $this->prophesize(ParameterBag::class); + $query->get('filter')->willReturn(['filed1' => 'lorem']); + $query->has(Argument::type('string'))->willReturn(FALSE); + $query->has('filter')->willReturn(TRUE); + $request->query = $query->reveal(); + + $route = $this->prophesize(Route::class); + $route->getRequirement('_entity_type')->willReturn('dolor'); + $defaults = $object->enhance([ + RouteObjectInterface::ROUTE_OBJECT => $route->reveal() + ], $request->reveal()); + $this->assertInstanceOf(Filter::class, $defaults['_json_api_params']['filter']); + $this->assertInstanceOf(OffsetPage::class, $defaults['_json_api_params']['page']); + $this->assertTrue(empty($defaults['_json_api_params']['sort'])); + } + + /** + * @covers ::enhance + */ + public function testEnhancePage() { + $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal()); + $request = $this->prophesize(Request::class); + $query = $this->prophesize(ParameterBag::class); + $query->get('page')->willReturn(['cursor' => 'lorem']); + $query->has(Argument::type('string'))->willReturn(FALSE); + $query->has('page')->willReturn(TRUE); + $request->query = $query->reveal(); + + $defaults = $object->enhance([], $request->reveal()); + $this->assertInstanceOf(OffsetPage::class, $defaults['_json_api_params']['page']); + $this->assertTrue(empty($defaults['_json_api_params']['filter'])); + $this->assertTrue(empty($defaults['_json_api_params']['sort'])); + } + + /** + * @covers ::enhance + */ + public function testEnhanceSort() { + $object = new JsonApiParamEnhancer($this->prophesize(EntityFieldManagerInterface::class)->reveal()); + $request = $this->prophesize(Request::class); + $query = $this->prophesize(ParameterBag::class); + $query->get('sort')->willReturn('-lorem'); + $query->has(Argument::type('string'))->willReturn(FALSE); + $query->has('sort')->willReturn(TRUE); + $request->query = $query->reveal(); + + $defaults = $object->enhance([], $request->reveal()); + $this->assertInstanceOf(Sort::class, $defaults['_json_api_params']['sort']); + $this->assertInstanceOf(OffsetPage::class, $defaults['_json_api_params']['page']); + $this->assertTrue(empty($defaults['_json_api_params']['filter'])); + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Routing/Param/FilterTest.php b/core/modules/jsonapi/tests/src/Unit/Routing/Param/FilterTest.php new file mode 100644 index 0000000..ebddb20 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Routing/Param/FilterTest.php @@ -0,0 +1,101 @@ +prophesize(EntityFieldManagerInterface::class)->reveal()); + $this->assertEquals($expected, $pager->get()); + } + + /** + * Data provider for testGet. + */ + public function getProvider() { + return [ + [ // Tests filter[0][field]=foo&filter[0][value]=bar + [['path' => 'foo', 'value' => 'bar']], + [['condition' => [ 'path' => 'foo', 'value' => 'bar', 'operator' => '=']]], + ], + [ // Tests filter[foo][value]=bar + ['foo' => ['value' => 'bar']], + ['foo' => ['condition' => [ 'path' => 'foo', 'value' => 'bar', 'operator' => '=']]], + ], + [ // Tests filter[foo][value]=bar&filter[foo][operator]=> + ['foo' => ['value' => 'bar', 'operator' => '>']], + ['foo' => ['condition' => [ 'path' => 'foo', 'value' => 'bar', 'operator' => '>']]], + ], + [ // Tests filter[foo][value][]=1&filter[foo][value][]=2&filter[foo][value][]=3&filter[foo][operator]=NOT IN + ['foo' => ['value' => ['1', '2', '3'], 'operator' => 'NOT IN']], + ['foo' => ['condition' => [ 'path' => 'foo', 'value' => ['1', '2', '3'], 'operator' => 'NOT IN']]], + ], + [ // Tests filter[foo][value][]=1&filter[foo][value][]=10&filter[foo][operator]=BETWEEN + ['foo' => ['value' => ['1', '10'], 'operator' => 'BETWEEN']], + ['foo' => ['condition' => [ 'path' => 'foo', 'value' => ['1', '10'], 'operator' => 'BETWEEN']]], + ], + [ // Tests filter[0][field]=foo&filter[0][value]=1&filter[0][operator]=> + [['path' => 'foo', 'value' => '1', 'operator' => '>']], + [['condition' => [ 'path' => 'foo', 'value' => '1', 'operator' => '>']]], + ], + [ // Tests filter[0][condition][field]=foo&filter[0][condition][value]=1&filter[0][condition][operator]=> + [['condition' => [ 'path' => 'foo', 'value' => '1', 'operator' => '>']]], + [['condition' => [ 'path' => 'foo', 'value' => '1', 'operator' => '>']]], + ], + [ // Tests filter[0][field]=foo&filter[0][value][]=bar&filter[0][value][]=baz + [['path' => 'foo', 'value' => ['bar', 'baz']]], + [['condition' => [ 'path' => 'foo', 'value' => ['bar', 'baz'], 'operator' => '=']]], + ], + [ + [ // Tests filter[0][field]=foo&filter[0][value]=bar&filter[1][condition][field]=baz&filter[1][condition][value]=zab&filter[1][condition][operator]=<> + 0 => ['path' => 'foo', 'value' => 'bar'], + 1 => ['condition' => [ 'path' => 'baz', 'value' => 'zab', 'operator' => '<>']], + ], + [ + 0 => ['condition' => [ 'path' => 'foo', 'value' => 'bar', 'operator' => '=']], + 1 => ['condition' => [ 'path' => 'baz', 'value' => 'zab', 'operator' => '<>']], + ], + ], + [ + [ // Tests filter[zero][field]=foo&filter[zero][value]=bar&filter[one][condition][field]=baz&filter[one][condition][value]=zab&filter[one][condition][operator]=<> + 'zero' => ['path' => 'foo', 'value' => 'bar'], + 'one' => ['condition' => [ 'path' => 'baz', 'value' => 'zab', 'operator' => '<>']], + ], + [ + 'zero' => ['condition' => [ 'path' => 'foo', 'value' => 'bar', 'operator' => '=']], + 'one' => ['condition' => [ 'path' => 'baz', 'value' => 'zab', 'operator' => '<>']], + ], + ], + ]; + } + + /** + * @covers ::get + * @expectedException \Drupal\jsonapi\Error\SerializableHttpException + */ + public function testGetFail() { + $pager = new Filter( + 'lorem', + 'ipsum', + $this->prophesize(EntityFieldManagerInterface::class)->reveal() + ); + $pager->get(); + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Routing/Param/OffsetPageTest.php b/core/modules/jsonapi/tests/src/Unit/Routing/Param/OffsetPageTest.php new file mode 100644 index 0000000..a5fb407 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Routing/Param/OffsetPageTest.php @@ -0,0 +1,45 @@ +assertEquals($expected, $pager->get()); + } + + /** + * Data provider for testGet. + */ + public function getProvider() { + return [ + [['offset' => 12, 'limit' => 20], 50, ['offset' => 12, 'limit' => 20]], + [['offset' => 12, 'limit' => 60], 50, ['offset' => 12, 'limit' => 50]], + [['offset' => 12], 50, ['offset' => 12, 'limit' => 50]], + [['offset' => 0], 50, ['offset' => 0, 'limit' => 50]], + [[], 50, ['limit' => 50]], + ]; + } + + /** + * @covers ::get + * @expectedException \Drupal\jsonapi\Error\SerializableHttpException + */ + public function testGetFail() { + $pager = new OffsetPage('lorem'); + $pager->get(); + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Routing/Param/SortTest.php b/core/modules/jsonapi/tests/src/Unit/Routing/Param/SortTest.php new file mode 100644 index 0000000..5774029 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Routing/Param/SortTest.php @@ -0,0 +1,72 @@ +assertEquals($expected, $sort->get()); + } + + /** + * Data provider for testGet. + */ + public function getProvider() { + return [ + ['lorem', [['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL]]], + ['-lorem', [['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL]]], + ['-lorem,ipsum', [ + ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL], + ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => NULL] + ]], + ['-lorem,-ipsum', [ + ['path' => 'lorem', 'direction' => 'DESC', 'langcode' => NULL], + ['path' => 'ipsum', 'direction' => 'DESC', 'langcode' => NULL] + ]], + [[ + ['path' => 'lorem', 'langcode' => NULL], + ['path' => 'ipsum', 'langcode' => 'ca'], + ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'], + ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'], + ], [ + ['path' => 'lorem', 'direction' => 'ASC', 'langcode' => NULL], + ['path' => 'ipsum', 'direction' => 'ASC', 'langcode' => 'ca'], + ['path' => 'dolor', 'direction' => 'ASC', 'langcode' => 'ca'], + ['path' => 'sit', 'direction' => 'DESC', 'langcode' => 'ca'], + ]], + ]; + } + + /** + * @covers ::get + * @dataProvider getFailProvider + * @expectedException \Drupal\jsonapi\Error\SerializableHttpException + */ + public function testGetFail($input) { + $sort = new Sort($input); + $sort->get(); + } + + /** + * Data provider for testGetFail. + */ + public function getFailProvider() { + return [ + [[['lorem']]], + [''], + ]; + } + +} diff --git a/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php b/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php new file mode 100644 index 0000000..c7d4476 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Unit/Routing/RoutesTest.php @@ -0,0 +1,129 @@ +prophesize(ResourceTypeRepository::class); + $resource_type_repository->all()->willReturn([new ResourceType('entity_type_1', 'bundle_1_1', EntityInterface::class)]); + $container = $this->prophesize(ContainerInterface::class); + $container->get('jsonapi.resource_type.repository')->willReturn($resource_type_repository->reveal()); + $auth_collector = $this->prophesize(AuthenticationCollectorInterface::class); + $auth_collector->getSortedProviders()->willReturn([ + 'lorem' => [], + 'ipsum' => [], + ]); + $container->get('authentication_collector')->willReturn($auth_collector->reveal()); + + $this->routes['ok'] = Routes::create($container->reveal()); + } + + + /** + * @covers ::routes + */ + public function testRoutesCollection() { + // Get the route collection and start making assertions. + $routes = $this->routes['ok']->routes(); + + // Make sure that there are 4 routes for each resource. + $this->assertEquals(4, $routes->count()); + + $iterator = $routes->getIterator(); + // Check the collection route. + /** @var \Symfony\Component\Routing\Route $route */ + $route = $iterator->offsetGet('jsonapi.entity_type_1--bundle_1_1.collection'); + $this->assertSame('/jsonapi/entity_type_1/bundle_1_1', $route->getPath()); + $this->assertSame('entity_type_1', $route->getRequirement('_entity_type')); + $this->assertSame('bundle_1_1', $route->getRequirement('_bundle')); + $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth')); + $this->assertEquals(['GET', 'POST'], $route->getMethods()); + $this->assertSame(Routes::FRONT_CONTROLLER, $route->getDefault(RouteObjectInterface::CONTROLLER_NAME)); + $this->assertSame('Drupal\jsonapi\Resource\JsonApiDocumentTopLevel', $route->getOption('serialization_class')); + } + + /** + * @covers ::routes + */ + public function testRoutesIndividual() { + // Get the route collection and start making assertions. + $iterator = $this->routes['ok']->routes()->getIterator(); + + // Check the individual route. + /** @var \Symfony\Component\Routing\Route $route */ + $route = $iterator->offsetGet('jsonapi.entity_type_1--bundle_1_1.individual'); + $this->assertSame('/jsonapi/entity_type_1/bundle_1_1/{entity_type_1}', $route->getPath()); + $this->assertSame('entity_type_1', $route->getRequirement('_entity_type')); + $this->assertSame('bundle_1_1', $route->getRequirement('_bundle')); + $this->assertEquals(['GET', 'PATCH', 'DELETE'], $route->getMethods()); + $this->assertSame(Routes::FRONT_CONTROLLER, $route->getDefault(RouteObjectInterface::CONTROLLER_NAME)); + $this->assertSame('Drupal\jsonapi\Resource\JsonApiDocumentTopLevel', $route->getOption('serialization_class')); + $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth')); + $this->assertEquals(['entity_type_1' => ['type' => 'entity:entity_type_1']], $route->getOption('parameters')); + } + + /** + * @covers ::routes + */ + public function testRoutesRelated() { + // Get the route collection and start making assertions. + $iterator = $this->routes['ok']->routes()->getIterator(); + + // Check the related route. + /** @var \Symfony\Component\Routing\Route $route */ + $route = $iterator->offsetGet('jsonapi.entity_type_1--bundle_1_1.related'); + $this->assertSame('/jsonapi/entity_type_1/bundle_1_1/{entity_type_1}/{related}', $route->getPath()); + $this->assertSame('entity_type_1', $route->getRequirement('_entity_type')); + $this->assertSame('bundle_1_1', $route->getRequirement('_bundle')); + $this->assertEquals(['GET'], $route->getMethods()); + $this->assertSame(Routes::FRONT_CONTROLLER, $route->getDefault(RouteObjectInterface::CONTROLLER_NAME)); + $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth')); + $this->assertEquals(['entity_type_1' => ['type' => 'entity:entity_type_1']], $route->getOption('parameters')); + } + + /** + * @covers ::routes + */ + public function testRoutesRelationships() { + // Get the route collection and start making assertions. + $iterator = $this->routes['ok']->routes()->getIterator(); + + // Check the relationships route. + /** @var \Symfony\Component\Routing\Route $route */ + $route = $iterator->offsetGet('jsonapi.entity_type_1--bundle_1_1.relationship'); + $this->assertSame('/jsonapi/entity_type_1/bundle_1_1/{entity_type_1}/relationships/{related}', $route->getPath()); + $this->assertSame('entity_type_1', $route->getRequirement('_entity_type')); + $this->assertSame('bundle_1_1', $route->getRequirement('_bundle')); + $this->assertEquals(['GET', 'POST', 'PATCH', 'DELETE'], $route->getMethods()); + $this->assertSame(Routes::FRONT_CONTROLLER, $route->getDefault(RouteObjectInterface::CONTROLLER_NAME)); + $this->assertSame(['lorem', 'ipsum'], $route->getOption('_auth')); + $this->assertEquals(['entity_type_1' => ['type' => 'entity:entity_type_1']], $route->getOption('parameters')); + $this->assertSame('Drupal\Core\Field\EntityReferenceFieldItemList', $route->getOption('serialization_class')); + } + +}