Chris@18: entityTypeManager = $entity_type_manager; Chris@18: $this->fieldManager = $field_manager; Chris@18: $this->resourceTypeRepository = $resource_type_repository; Chris@18: $this->renderer = $renderer; Chris@18: $this->entityRepository = $entity_repository; Chris@18: $this->includeResolver = $include_resolver; Chris@18: $this->entityAccessChecker = $entity_access_checker; Chris@18: $this->fieldResolver = $field_resolver; Chris@18: $this->serializer = $serializer; Chris@18: $this->time = $time; Chris@18: $this->user = $user; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets the individual entity. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The loaded entity. Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The response. Chris@18: * Chris@18: * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException Chris@18: * Thrown when access to the entity is not allowed. Chris@18: */ Chris@18: public function getIndividual(EntityInterface $entity, Request $request) { Chris@18: $resource_object = $this->entityAccessChecker->getAccessCheckedResourceObject($entity); Chris@18: if ($resource_object instanceof EntityAccessDeniedHttpException) { Chris@18: throw $resource_object; Chris@18: } Chris@18: $primary_data = new ResourceObjectData([$resource_object], 1); Chris@18: $response = $this->buildWrappedResponse($primary_data, $request, $this->getIncludes($request, $primary_data)); Chris@18: return $response; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Creates an individual entity. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The JSON:API resource type for the request to be served. Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The response. Chris@18: * Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException Chris@18: * Thrown when the entity already exists. Chris@18: * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException Chris@18: * Thrown when the entity does not pass validation. Chris@18: */ Chris@18: public function createIndividual(ResourceType $resource_type, Request $request) { Chris@18: $parsed_entity = $this->deserialize($resource_type, $request, JsonApiDocumentTopLevel::class); Chris@18: Chris@18: if ($parsed_entity instanceof FieldableEntityInterface) { Chris@18: // Only check 'edit' permissions for fields that were actually submitted Chris@18: // by the user. Field access makes no distinction between 'create' and Chris@18: // 'update', so the 'edit' operation is used here. Chris@18: $document = Json::decode($request->getContent()); Chris@18: foreach (['attributes', 'relationships'] as $data_member_name) { Chris@18: if (isset($document['data'][$data_member_name])) { Chris@18: $valid_names = array_filter(array_map(function ($public_field_name) use ($resource_type) { Chris@18: return $resource_type->getInternalName($public_field_name); Chris@18: }, array_keys($document['data'][$data_member_name])), function ($internal_field_name) use ($resource_type) { Chris@18: return $resource_type->hasField($internal_field_name); Chris@18: }); Chris@18: foreach ($valid_names as $field_name) { Chris@18: $field_access = $parsed_entity->get($field_name)->access('edit', NULL, TRUE); Chris@18: if (!$field_access->isAllowed()) { Chris@18: $public_field_name = $resource_type->getPublicName($field_name); Chris@18: throw new EntityAccessDeniedHttpException(NULL, $field_access, "/data/$data_member_name/$public_field_name", sprintf('The current user is not allowed to POST the selected field (%s).', $public_field_name)); Chris@18: } Chris@18: } Chris@18: } Chris@18: } Chris@18: } Chris@18: Chris@18: static::validate($parsed_entity); Chris@18: Chris@18: // Return a 409 Conflict response in accordance with the JSON:API spec. See Chris@18: // http://jsonapi.org/format/#crud-creating-responses-409. Chris@18: if ($this->entityExists($parsed_entity)) { Chris@18: throw new ConflictHttpException('Conflict: Entity already exists.'); Chris@18: } Chris@18: Chris@18: $parsed_entity->save(); Chris@18: Chris@18: // Build response object. Chris@18: $resource_object = ResourceObject::createFromEntity($resource_type, $parsed_entity); Chris@18: $primary_data = new ResourceObjectData([$resource_object], 1); Chris@18: $response = $this->buildWrappedResponse($primary_data, $request, $this->getIncludes($request, $primary_data), 201); Chris@18: Chris@18: // According to JSON:API specification, when a new entity was created Chris@18: // we should send "Location" header to the frontend. Chris@18: if ($resource_type->isLocatable()) { Chris@18: $url = $resource_object->toUrl()->setAbsolute()->toString(TRUE); Chris@18: $response->addCacheableDependency($url); Chris@18: $response->headers->set('Location', $url->getGeneratedUrl()); Chris@18: } Chris@18: Chris@18: // Return response object with updated headers info. Chris@18: return $response; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Patches an individual entity. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The JSON:API resource type for the request to be served. Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The loaded entity. Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The response. Chris@18: * Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException Chris@18: * Thrown when the selected entity does not match the id in th payload. Chris@18: * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException Chris@18: * Thrown when the patched entity does not pass validation. Chris@18: */ Chris@18: public function patchIndividual(ResourceType $resource_type, EntityInterface $entity, Request $request) { Chris@18: if ($entity instanceof RevisionableInterface && !($entity->isLatestRevision() && $entity->isDefaultRevision())) { Chris@18: throw new BadRequestHttpException('Updating a resource object that has a working copy is not yet supported. See https://www.drupal.org/project/jsonapi/issues/2795279.'); Chris@18: } Chris@18: Chris@18: $parsed_entity = $this->deserialize($resource_type, $request, JsonApiDocumentTopLevel::class); Chris@18: Chris@18: $body = Json::decode($request->getContent()); Chris@18: $data = $body['data']; Chris@18: if ($data['id'] != $entity->uuid()) { Chris@18: throw new BadRequestHttpException(sprintf( Chris@18: 'The selected entity (%s) does not match the ID in the payload (%s).', Chris@18: $entity->uuid(), Chris@18: $data['id'] Chris@18: )); Chris@18: } Chris@18: $data += ['attributes' => [], 'relationships' => []]; Chris@18: $field_names = array_merge(array_keys($data['attributes']), array_keys($data['relationships'])); Chris@18: Chris@18: array_reduce($field_names, function (EntityInterface $destination, $field_name) use ($resource_type, $parsed_entity) { Chris@18: $this->updateEntityField($resource_type, $parsed_entity, $destination, $field_name); Chris@18: return $destination; Chris@18: }, $entity); Chris@18: Chris@18: static::validate($entity, $field_names); Chris@18: Chris@18: // Set revision data details for revisionable entities. Chris@18: if ($entity->getEntityType()->isRevisionable()) { Chris@18: if ($bundle_entity_type = $entity->getEntityType()->getBundleEntityType()) { Chris@18: $bundle_entity = $this->entityTypeManager->getStorage($bundle_entity_type)->load($entity->bundle()); Chris@18: if ($bundle_entity instanceof RevisionableEntityBundleInterface) { Chris@18: $entity->setNewRevision($bundle_entity->shouldCreateNewRevision()); Chris@18: } Chris@18: } Chris@18: if ($entity instanceof RevisionLogInterface && $entity->isNewRevision()) { Chris@18: $entity->setRevisionUserId($this->user->id()); Chris@18: $entity->setRevisionCreationTime($this->time->getRequestTime()); Chris@18: } Chris@18: } Chris@18: Chris@18: $entity->save(); Chris@18: $primary_data = new ResourceObjectData([ResourceObject::createFromEntity($resource_type, $entity)], 1); Chris@18: return $this->buildWrappedResponse($primary_data, $request, $this->getIncludes($request, $primary_data)); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Deletes an individual entity. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The loaded entity. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The response. Chris@18: */ Chris@18: public function deleteIndividual(EntityInterface $entity) { Chris@18: $entity->delete(); Chris@18: return new ResourceResponse(NULL, 204); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets the collection of entities. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The JSON:API resource type for the request to be served. Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The response. Chris@18: * Chris@18: * @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException Chris@18: * Thrown when filtering on a config entity which does not support it. Chris@18: */ Chris@18: public function getCollection(ResourceType $resource_type, Request $request) { Chris@18: // Instantiate the query for the filtering. Chris@18: $entity_type_id = $resource_type->getEntityTypeId(); Chris@18: Chris@18: $params = $this->getJsonApiParams($request, $resource_type); Chris@18: $query_cacheability = new CacheableMetadata(); Chris@18: $query = $this->getCollectionQuery($resource_type, $params, $query_cacheability); Chris@18: Chris@18: // If the request is for the latest revision, toggle it on entity query. Chris@18: if ($request->get(ResourceVersionRouteEnhancer::WORKING_COPIES_REQUESTED, FALSE)) { Chris@18: $query->latestRevision(); Chris@18: } Chris@18: Chris@18: try { Chris@18: $results = $this->executeQueryInRenderContext( Chris@18: $query, Chris@18: $query_cacheability Chris@18: ); Chris@18: } Chris@18: catch (\LogicException $e) { Chris@18: // Ensure good DX when an entity query involves a config entity type. Chris@18: // For example: getting users with a particular role, which is a config Chris@18: // entity type: https://www.drupal.org/project/jsonapi/issues/2959445. Chris@18: // @todo Remove the message parsing in https://www.drupal.org/project/drupal/issues/3028967. Chris@18: if (strpos($e->getMessage(), 'Getting the base fields is not supported for entity type') === 0) { Chris@18: preg_match('/entity type (.*)\./', $e->getMessage(), $matches); Chris@18: $config_entity_type_id = $matches[1]; Chris@18: $cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', 'url.query_args:filter']); Chris@18: throw new CacheableBadRequestHttpException($cacheability, sprintf("Filtering on config entities is not supported by Drupal's entity API. You tried to filter on a %s config entity.", $config_entity_type_id)); Chris@18: } Chris@18: else { Chris@18: throw $e; Chris@18: } Chris@18: } Chris@18: Chris@18: $storage = $this->entityTypeManager->getStorage($entity_type_id); Chris@18: // We request N+1 items to find out if there is a next page for the pager. Chris@18: // We may need to remove that extra item before loading the entities. Chris@18: $pager_size = $query->getMetaData('pager_size'); Chris@18: if ($has_next_page = $pager_size < count($results)) { Chris@18: // Drop the last result. Chris@18: array_pop($results); Chris@18: } Chris@18: // Each item of the collection data contains an array with 'entity' and Chris@18: // 'access' elements. Chris@18: $collection_data = $this->loadEntitiesWithAccess($storage, $results, $request->get(ResourceVersionRouteEnhancer::WORKING_COPIES_REQUESTED, FALSE)); Chris@18: $primary_data = new ResourceObjectData($collection_data); Chris@18: $primary_data->setHasNextPage($has_next_page); Chris@18: Chris@18: // Calculate all the results and pass into a JSON:API Data object. Chris@18: $count_query_cacheability = new CacheableMetadata(); Chris@18: if ($resource_type->includeCount()) { Chris@18: $count_query = $this->getCollectionCountQuery($resource_type, $params, $count_query_cacheability); Chris@18: $total_results = $this->executeQueryInRenderContext( Chris@18: $count_query, Chris@18: $count_query_cacheability Chris@18: ); Chris@18: Chris@18: $primary_data->setTotalCount($total_results); Chris@18: } Chris@18: Chris@18: $response = $this->respondWithCollection($primary_data, $this->getIncludes($request, $primary_data), $request, $resource_type, $params[OffsetPage::KEY_NAME]); Chris@18: Chris@18: $response->addCacheableDependency($query_cacheability); Chris@18: $response->addCacheableDependency($count_query_cacheability); Chris@18: $response->addCacheableDependency((new CacheableMetadata()) Chris@18: ->addCacheContexts([ Chris@18: 'url.query_args:filter', Chris@18: 'url.query_args:sort', Chris@18: 'url.query_args:page', Chris@18: ])); Chris@18: Chris@18: if ($resource_type->isVersionable()) { Chris@18: $response->addCacheableDependency((new CacheableMetadata())->addCacheContexts([ResourceVersionRouteEnhancer::CACHE_CONTEXT])); Chris@18: } Chris@18: Chris@18: return $response; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Executes the query in a render context, to catch bubbled cacheability. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\Query\QueryInterface $query Chris@18: * The query to execute to get the return results. Chris@18: * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability Chris@18: * The value object to carry the query cacheability. Chris@18: * Chris@18: * @return int|array Chris@18: * Returns an integer for count queries or an array of IDs. The values of Chris@18: * the array are always entity IDs. The keys will be revision IDs if the Chris@18: * entity supports revision and entity IDs if not. Chris@18: * Chris@18: * @see node_query_node_access_alter() Chris@18: * @see https://www.drupal.org/project/drupal/issues/2557815 Chris@18: * @see https://www.drupal.org/project/drupal/issues/2794385 Chris@18: * @todo Remove this after https://www.drupal.org/project/drupal/issues/3028976 is fixed. Chris@18: */ Chris@18: protected function executeQueryInRenderContext(QueryInterface $query, CacheableMetadata $query_cacheability) { Chris@18: $context = new RenderContext(); Chris@18: $results = $this->renderer->executeInRenderContext($context, function () use ($query) { Chris@18: return $query->execute(); Chris@18: }); Chris@18: if (!$context->isEmpty()) { Chris@18: $query_cacheability->addCacheableDependency($context->pop()); Chris@18: } Chris@18: return $results; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets the related resource. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The JSON:API resource type for the request to be served. Chris@18: * @param \Drupal\Core\Entity\FieldableEntityInterface $entity Chris@18: * The requested entity. Chris@18: * @param string $related Chris@18: * The related field name. Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The response. Chris@18: */ Chris@18: public function getRelated(ResourceType $resource_type, FieldableEntityInterface $entity, $related, Request $request) { Chris@18: /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */ Chris@18: $field_list = $entity->get($resource_type->getInternalName($related)); Chris@18: Chris@18: // Remove the entities pointing to a resource that may be disabled. Even Chris@18: // though the normalizer skips disabled references, we can avoid unnecessary Chris@18: // work by checking here too. Chris@18: /* @var \Drupal\Core\Entity\EntityInterface[] $referenced_entities */ Chris@18: $referenced_entities = array_filter( Chris@18: $field_list->referencedEntities(), Chris@18: function (EntityInterface $entity) { Chris@18: return (bool) $this->resourceTypeRepository->get( Chris@18: $entity->getEntityTypeId(), Chris@18: $entity->bundle() Chris@18: ); Chris@18: } Chris@18: ); Chris@18: $collection_data = []; Chris@18: foreach ($referenced_entities as $referenced_entity) { Chris@18: $collection_data[] = $this->entityAccessChecker->getAccessCheckedResourceObject($referenced_entity); Chris@18: } Chris@18: $primary_data = new ResourceObjectData($collection_data, $field_list->getFieldDefinition()->getFieldStorageDefinition()->getCardinality()); Chris@18: $response = $this->buildWrappedResponse($primary_data, $request, $this->getIncludes($request, $primary_data)); Chris@18: Chris@18: // $response does not contain the entity list cache tag. We add the Chris@18: // cacheable metadata for the finite list of entities in the relationship. Chris@18: $response->addCacheableDependency($entity); Chris@18: Chris@18: return $response; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets the relationship of an entity. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The base JSON:API resource type for the request to be served. Chris@18: * @param \Drupal\Core\Entity\FieldableEntityInterface $entity Chris@18: * The requested entity. Chris@18: * @param string $related Chris@18: * The related field name. Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * @param int $response_code Chris@18: * The response code. Defaults to 200. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The response. Chris@18: */ Chris@18: public function getRelationship(ResourceType $resource_type, FieldableEntityInterface $entity, $related, Request $request, $response_code = 200) { Chris@18: /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */ Chris@18: $field_list = $entity->get($resource_type->getInternalName($related)); Chris@18: // Access will have already been checked by the RelationshipFieldAccess Chris@18: // service, so we don't need to call ::getAccessCheckedResourceObject(). Chris@18: $resource_object = ResourceObject::createFromEntity($resource_type, $entity); Chris@18: $relationship_object_urls = EntityReferenceFieldNormalizer::getRelationshipLinks($resource_object, $related); Chris@18: $response = $this->buildWrappedResponse($field_list, $request, $this->getIncludes($request, $resource_object), $response_code, [], array_reduce(array_keys($relationship_object_urls), function (LinkCollection $links, $key) use ($relationship_object_urls) { Chris@18: return $links->withLink($key, new Link(new CacheableMetadata(), $relationship_object_urls[$key], [$key])); Chris@18: }, new LinkCollection([]))); Chris@18: // Add the host entity as a cacheable dependency. Chris@18: $response->addCacheableDependency($entity); Chris@18: return $response; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Adds a relationship to a to-many relationship. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The base JSON:API resource type for the request to be served. Chris@18: * @param \Drupal\Core\Entity\FieldableEntityInterface $entity Chris@18: * The requested entity. Chris@18: * @param string $related Chris@18: * The related field name. Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The response. Chris@18: * Chris@18: * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException Chris@18: * Thrown when the current user is not allowed to PATCH the selected Chris@18: * field(s). Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException Chris@18: * Thrown when POSTing to a "to-one" relationship. Chris@18: * @throws \Drupal\Core\Entity\EntityStorageException Chris@18: * Thrown when the underlying entity cannot be saved. Chris@18: * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException Chris@18: * Thrown when the updated entity does not pass validation. Chris@18: */ Chris@18: public function addToRelationshipData(ResourceType $resource_type, FieldableEntityInterface $entity, $related, Request $request) { Chris@18: $resource_identifiers = $this->deserialize($resource_type, $request, ResourceIdentifier::class, $related); Chris@18: $related = $resource_type->getInternalName($related); Chris@18: // According to the specification, you are only allowed to POST to a Chris@18: // relationship if it is a to-many relationship. Chris@18: /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */ Chris@18: $field_list = $entity->{$related}; Chris@18: /* @var \Drupal\field\Entity\FieldConfig $field_definition */ Chris@18: $field_definition = $field_list->getFieldDefinition(); Chris@18: $is_multiple = $field_definition->getFieldStorageDefinition()->isMultiple(); Chris@18: if (!$is_multiple) { Chris@18: throw new ConflictHttpException(sprintf('You can only POST to to-many relationships. %s is a to-one relationship.', $related)); Chris@18: } Chris@18: Chris@18: $original_resource_identifiers = ResourceIdentifier::toResourceIdentifiersWithArityRequired($field_list); Chris@18: $new_resource_identifiers = array_udiff( Chris@18: ResourceIdentifier::deduplicate(array_merge($original_resource_identifiers, $resource_identifiers)), Chris@18: $original_resource_identifiers, Chris@18: [ResourceIdentifier::class, 'compare'] Chris@18: ); Chris@18: Chris@18: // There are no relationships that need to be added so we can exit early. Chris@18: if (empty($new_resource_identifiers)) { Chris@18: $status = static::relationshipResponseRequiresBody($resource_identifiers, $original_resource_identifiers) ? 200 : 204; Chris@18: return $this->getRelationship($resource_type, $entity, $related, $request, $status); Chris@18: } Chris@18: Chris@18: $main_property_name = $field_definition->getItemDefinition()->getMainPropertyName(); Chris@18: foreach ($new_resource_identifiers as $new_resource_identifier) { Chris@18: $new_field_value = [$main_property_name => $this->getEntityFromResourceIdentifier($new_resource_identifier)->id()]; Chris@18: // Remove `arity` from the received extra properties, otherwise this Chris@18: // will fail field validation. Chris@18: $new_field_value += array_diff_key($new_resource_identifier->getMeta(), array_flip([ResourceIdentifier::ARITY_KEY])); Chris@18: $field_list->appendItem($new_field_value); Chris@18: } Chris@18: Chris@18: $this->validate($entity); Chris@18: $entity->save(); Chris@18: Chris@18: $final_resource_identifiers = ResourceIdentifier::toResourceIdentifiersWithArityRequired($field_list); Chris@18: $status = static::relationshipResponseRequiresBody($resource_identifiers, $final_resource_identifiers) ? 200 : 204; Chris@18: return $this->getRelationship($resource_type, $entity, $related, $request, $status); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Updates the relationship of an entity. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The base JSON:API resource type for the request to be served. Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The requested entity. Chris@18: * @param string $related Chris@18: * The related field name. Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The response. Chris@18: * Chris@18: * @throws \Drupal\Core\Entity\EntityStorageException Chris@18: * Thrown when the underlying entity cannot be saved. Chris@18: * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException Chris@18: * Thrown when the updated entity does not pass validation. Chris@18: */ Chris@18: public function replaceRelationshipData(ResourceType $resource_type, EntityInterface $entity, $related, Request $request) { Chris@18: $resource_identifiers = $this->deserialize($resource_type, $request, ResourceIdentifier::class, $related); Chris@18: $related = $resource_type->getInternalName($related); Chris@18: /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $resource_identifiers */ Chris@18: // According to the specification, PATCH works a little bit different if the Chris@18: // relationship is to-one or to-many. Chris@18: /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */ Chris@18: $field_list = $entity->{$related}; Chris@18: $field_definition = $field_list->getFieldDefinition(); Chris@18: $is_multiple = $field_definition->getFieldStorageDefinition()->isMultiple(); Chris@18: $method = $is_multiple ? 'doPatchMultipleRelationship' : 'doPatchIndividualRelationship'; Chris@18: $this->{$method}($entity, $resource_identifiers, $field_definition); Chris@18: $this->validate($entity); Chris@18: $entity->save(); Chris@18: $requires_response = static::relationshipResponseRequiresBody($resource_identifiers, ResourceIdentifier::toResourceIdentifiersWithArityRequired($field_list)); Chris@18: return $this->getRelationship($resource_type, $entity, $related, $request, $requires_response ? 200 : 204); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Update a to-one relationship. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The requested entity. Chris@18: * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $resource_identifiers Chris@18: * The client-sent resource identifiers which should be set on the given Chris@18: * entity. Should be an empty array or an array with a single value. Chris@18: * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition Chris@18: * The field definition of the entity field to be updated. Chris@18: * Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException Chris@18: * Thrown when a "to-one" relationship is not provided. Chris@18: */ Chris@18: protected function doPatchIndividualRelationship(EntityInterface $entity, array $resource_identifiers, FieldDefinitionInterface $field_definition) { Chris@18: if (count($resource_identifiers) > 1) { Chris@18: throw new BadRequestHttpException(sprintf('Provide a single relationship so to-one relationship fields (%s).', $field_definition->getName())); Chris@18: } Chris@18: $this->doPatchMultipleRelationship($entity, $resource_identifiers, $field_definition); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Update a to-many relationship. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The requested entity. Chris@18: * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $resource_identifiers Chris@18: * The client-sent resource identifiers which should be set on the given Chris@18: * entity. Chris@18: * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition Chris@18: * The field definition of the entity field to be updated. Chris@18: */ Chris@18: protected function doPatchMultipleRelationship(EntityInterface $entity, array $resource_identifiers, FieldDefinitionInterface $field_definition) { Chris@18: $main_property_name = $field_definition->getItemDefinition()->getMainPropertyName(); Chris@18: $entity->{$field_definition->getName()} = array_map(function (ResourceIdentifier $resource_identifier) use ($main_property_name) { Chris@18: $field_properties = [$main_property_name => $this->getEntityFromResourceIdentifier($resource_identifier)->id()]; Chris@18: // Remove `arity` from the received extra properties, otherwise this Chris@18: // will fail field validation. Chris@18: $field_properties += array_diff_key($resource_identifier->getMeta(), array_flip([ResourceIdentifier::ARITY_KEY])); Chris@18: return $field_properties; Chris@18: }, $resource_identifiers); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Deletes the relationship of an entity. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The base JSON:API resource type for the request to be served. Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The requested entity. Chris@18: * @param string $related Chris@18: * The related field name. Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The response. Chris@18: * Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException Chris@18: * Thrown when not body was provided for the DELETE operation. Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\ConflictHttpException Chris@18: * Thrown when deleting a "to-one" relationship. Chris@18: * @throws \Drupal\Core\Entity\EntityStorageException Chris@18: * Thrown when the underlying entity cannot be saved. Chris@18: */ Chris@18: public function removeFromRelationshipData(ResourceType $resource_type, EntityInterface $entity, $related, Request $request) { Chris@18: $resource_identifiers = $this->deserialize($resource_type, $request, ResourceIdentifier::class, $related); Chris@18: /* @var \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field_list */ Chris@18: $field_list = $entity->{$related}; Chris@18: $is_multiple = $field_list->getFieldDefinition() Chris@18: ->getFieldStorageDefinition() Chris@18: ->isMultiple(); Chris@18: if (!$is_multiple) { Chris@18: throw new ConflictHttpException(sprintf('You can only DELETE from to-many relationships. %s is a to-one relationship.', $related)); Chris@18: } Chris@18: Chris@18: // Compute the list of current values and remove the ones in the payload. Chris@18: $original_resource_identifiers = ResourceIdentifier::toResourceIdentifiersWithArityRequired($field_list); Chris@18: $removed_resource_identifiers = array_uintersect($resource_identifiers, $original_resource_identifiers, [ResourceIdentifier::class, 'compare']); Chris@18: $deltas_to_be_removed = []; Chris@18: foreach ($removed_resource_identifiers as $removed_resource_identifier) { Chris@18: foreach ($original_resource_identifiers as $delta => $existing_resource_identifier) { Chris@18: // Identify the field item deltas which should be removed. Chris@18: if (ResourceIdentifier::isDuplicate($removed_resource_identifier, $existing_resource_identifier)) { Chris@18: $deltas_to_be_removed[] = $delta; Chris@18: } Chris@18: } Chris@18: } Chris@18: // Field item deltas are reset when an item is removed. This removes Chris@18: // items in descending order so that the deltas yet to be removed will Chris@18: // continue to exist. Chris@18: rsort($deltas_to_be_removed); Chris@18: foreach ($deltas_to_be_removed as $delta) { Chris@18: $field_list->removeItem($delta); Chris@18: } Chris@18: Chris@18: // Save the entity and return the response object. Chris@18: static::validate($entity); Chris@18: $entity->save(); Chris@18: return $this->getRelationship($resource_type, $entity, $related, $request, 204); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Deserializes a request body, if any. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The JSON:API resource type for the current request. Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * @param string $class Chris@18: * The class into which the request data needs to be deserialized. Chris@18: * @param string $relationship_field_name Chris@18: * The public relationship field name of the data to be deserialized if the Chris@18: * incoming request is for a relationship update. Not required for non- Chris@18: * relationship requests. Chris@18: * Chris@18: * @return array Chris@18: * An object normalization. Chris@18: * Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException Chris@18: * Thrown if the request body cannot be decoded, or when no request body was Chris@18: * provided with a POST or PATCH request. Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException Chris@18: * Thrown if the request body cannot be denormalized. Chris@18: */ Chris@18: protected function deserialize(ResourceType $resource_type, Request $request, $class, $relationship_field_name = NULL) { Chris@18: assert($class === JsonApiDocumentTopLevel::class || $class === ResourceIdentifier::class && !empty($relationship_field_name) && is_string($relationship_field_name)); Chris@18: $received = (string) $request->getContent(); Chris@18: if (!$received) { Chris@18: assert($request->isMethod('POST') || $request->isMethod('PATCH') || $request->isMethod('DELETE')); Chris@18: if ($request->isMethod('DELETE') && $relationship_field_name) { Chris@18: throw new BadRequestHttpException(sprintf('You need to provide a body for DELETE operations on a relationship (%s).', $relationship_field_name)); Chris@18: } Chris@18: else { Chris@18: throw new BadRequestHttpException('Empty request body.'); Chris@18: } Chris@18: } Chris@18: // First decode the request data. We can then determine if the serialized Chris@18: // data was malformed. Chris@18: try { Chris@18: $decoded = $this->serializer->decode($received, 'api_json'); Chris@18: } Chris@18: catch (UnexpectedValueException $e) { Chris@18: // If an exception was thrown at this stage, there was a problem decoding Chris@18: // the data. Throw a 400 HTTP exception. Chris@18: throw new BadRequestHttpException($e->getMessage()); Chris@18: } Chris@18: Chris@18: try { Chris@18: $context = ['resource_type' => $resource_type]; Chris@18: if ($relationship_field_name) { Chris@18: $context['related'] = $resource_type->getInternalName($relationship_field_name); Chris@18: } Chris@18: return $this->serializer->denormalize($decoded, $class, 'api_json', $context); Chris@18: } Chris@18: // These two serialization exception types mean there was a problem with Chris@18: // the structure of the decoded data and it's not valid. Chris@18: catch (UnexpectedValueException $e) { Chris@18: throw new UnprocessableHttpEntityException($e->getMessage()); Chris@18: } Chris@18: catch (InvalidArgumentException $e) { Chris@18: throw new UnprocessableHttpEntityException($e->getMessage()); Chris@18: } Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets a basic query for a collection. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The base JSON:API resource type for the query. Chris@18: * @param array $params Chris@18: * The parameters for the query. Chris@18: * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability Chris@18: * Collects cacheability for the query. Chris@18: * Chris@18: * @return \Drupal\Core\Entity\Query\QueryInterface Chris@18: * A new query. Chris@18: */ Chris@18: protected function getCollectionQuery(ResourceType $resource_type, array $params, CacheableMetadata $query_cacheability) { Chris@18: $entity_type = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId()); Chris@18: $entity_storage = $this->entityTypeManager->getStorage($resource_type->getEntityTypeId()); Chris@18: Chris@18: $query = $entity_storage->getQuery(); Chris@18: Chris@18: // Ensure that access checking is performed on the query. Chris@18: $query->accessCheck(TRUE); Chris@18: Chris@18: // Compute and apply an entity query condition from the filter parameter. Chris@18: if (isset($params[Filter::KEY_NAME]) && $filter = $params[Filter::KEY_NAME]) { Chris@18: $query->condition($filter->queryCondition($query)); Chris@18: TemporaryQueryGuard::setFieldManager($this->fieldManager); Chris@18: TemporaryQueryGuard::setModuleHandler(\Drupal::moduleHandler()); Chris@18: TemporaryQueryGuard::applyAccessControls($filter, $query, $query_cacheability); Chris@18: } Chris@18: Chris@18: // Apply any sorts to the entity query. Chris@18: if (isset($params[Sort::KEY_NAME]) && $sort = $params[Sort::KEY_NAME]) { Chris@18: foreach ($sort->fields() as $field) { Chris@18: $path = $this->fieldResolver->resolveInternalEntityQueryPath($resource_type->getEntityTypeId(), $resource_type->getBundle(), $field[Sort::PATH_KEY]); Chris@18: $direction = isset($field[Sort::DIRECTION_KEY]) ? $field[Sort::DIRECTION_KEY] : 'ASC'; Chris@18: $langcode = isset($field[Sort::LANGUAGE_KEY]) ? $field[Sort::LANGUAGE_KEY] : NULL; Chris@18: $query->sort($path, $direction, $langcode); Chris@18: } Chris@18: } Chris@18: Chris@18: // Apply any pagination options to the query. Chris@18: if (isset($params[OffsetPage::KEY_NAME])) { Chris@18: $pagination = $params[OffsetPage::KEY_NAME]; Chris@18: } Chris@18: else { Chris@18: $pagination = new OffsetPage(OffsetPage::DEFAULT_OFFSET, OffsetPage::SIZE_MAX); Chris@18: } Chris@18: // Add one extra element to the page to see if there are more pages needed. Chris@18: $query->range($pagination->getOffset(), $pagination->getSize() + 1); Chris@18: $query->addMetaData('pager_size', (int) $pagination->getSize()); Chris@18: Chris@18: // Limit this query to the bundle type for this resource. Chris@18: $bundle = $resource_type->getBundle(); Chris@18: if ($bundle && ($bundle_key = $entity_type->getKey('bundle'))) { Chris@18: $query->condition( Chris@18: $bundle_key, $bundle Chris@18: ); Chris@18: } Chris@18: Chris@18: return $query; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets a basic query for a collection count. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The base JSON:API resource type for the query. Chris@18: * @param array $params Chris@18: * The parameters for the query. Chris@18: * @param \Drupal\Core\Cache\CacheableMetadata $query_cacheability Chris@18: * Collects cacheability for the query. Chris@18: * Chris@18: * @return \Drupal\Core\Entity\Query\QueryInterface Chris@18: * A new query. Chris@18: */ Chris@18: protected function getCollectionCountQuery(ResourceType $resource_type, array $params, CacheableMetadata $query_cacheability) { Chris@18: // Reset the range to get all the available results. Chris@18: return $this->getCollectionQuery($resource_type, $params, $query_cacheability)->range()->count(); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Loads the entity targeted by a resource identifier. Chris@18: * Chris@18: * @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $resource_identifier Chris@18: * A resource identifier. Chris@18: * Chris@18: * @return \Drupal\Core\Entity\EntityInterface Chris@18: * The entity targeted by a resource identifier. Chris@18: * Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException Chris@18: * Thrown if the given resource identifier targets a resource type or Chris@18: * resource which does not exist. Chris@18: */ Chris@18: protected function getEntityFromResourceIdentifier(ResourceIdentifier $resource_identifier) { Chris@18: $resource_type_name = $resource_identifier->getTypeName(); Chris@18: if (!($target_resource_type = $this->resourceTypeRepository->getByTypeName($resource_type_name))) { Chris@18: throw new BadRequestHttpException("The resource type `{$resource_type_name}` does not exist."); Chris@18: } Chris@18: $id = $resource_identifier->getId(); Chris@18: if (!($targeted_resource = $this->entityRepository->loadEntityByUuid($target_resource_type->getEntityTypeId(), $id))) { Chris@18: throw new BadRequestHttpException("The targeted `{$resource_type_name}` resource with ID `{$id}` does not exist."); Chris@18: } Chris@18: return $targeted_resource; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Determines if the client needs to be updated with new relationship data. Chris@18: * Chris@18: * @param array $received_resource_identifiers Chris@18: * The array of resource identifiers given by the client. Chris@18: * @param array $final_resource_identifiers Chris@18: * The final array of resource identifiers after applying the requested Chris@18: * changes. Chris@18: * Chris@18: * @return bool Chris@18: * Whether the final array of resource identifiers is different than the Chris@18: * client-sent data. Chris@18: */ Chris@18: protected static function relationshipResponseRequiresBody(array $received_resource_identifiers, array $final_resource_identifiers) { Chris@18: return !empty(array_udiff($final_resource_identifiers, $received_resource_identifiers, [ResourceIdentifier::class, 'compare'])); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Builds a response with the appropriate wrapped document. Chris@18: * Chris@18: * @param mixed $data Chris@18: * The data to wrap. Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * @param \Drupal\jsonapi\JsonApiResource\IncludedData $includes Chris@18: * The resources to be included in the document. Use NullData if Chris@18: * there should be no included resources in the document. Chris@18: * @param int $response_code Chris@18: * The response code. Chris@18: * @param array $headers Chris@18: * An array of response headers. Chris@18: * @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links Chris@18: * The URLs to which to link. A 'self' link is added automatically. Chris@18: * @param array $meta Chris@18: * (optional) The top-level metadata. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The response. Chris@18: */ Chris@18: protected function buildWrappedResponse($data, Request $request, IncludedData $includes, $response_code = 200, array $headers = [], LinkCollection $links = NULL, array $meta = []) { Chris@18: assert($data instanceof Data || $data instanceof FieldItemListInterface); Chris@18: $links = ($links ?: new LinkCollection([])); Chris@18: if (!$links->hasLinkWithKey('self')) { Chris@18: $self_link = new Link(new CacheableMetadata(), self::getRequestLink($request), ['self']); Chris@18: $links = $links->withLink('self', $self_link); Chris@18: } Chris@18: $response = new ResourceResponse(new JsonApiDocumentTopLevel($data, $includes, $links, $meta), $response_code, $headers); Chris@18: $cacheability = (new CacheableMetadata())->addCacheContexts([ Chris@18: // Make sure that different sparse fieldsets are cached differently. Chris@18: 'url.query_args:fields', Chris@18: // Make sure that different sets of includes are cached differently. Chris@18: 'url.query_args:include', Chris@18: ]); Chris@18: $response->addCacheableDependency($cacheability); Chris@18: return $response; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Respond with an entity collection. Chris@18: * Chris@18: * @param \Drupal\jsonapi\JsonApiResource\ResourceObjectData $primary_data Chris@18: * The collection of entities. Chris@18: * @param \Drupal\jsonapi\JsonApiResource\IncludedData|\Drupal\jsonapi\JsonApiResource\NullIncludedData $includes Chris@18: * The resources to be included in the document. Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The base JSON:API resource type for the request to be served. Chris@18: * @param \Drupal\jsonapi\Query\OffsetPage $page_param Chris@18: * The pagination parameter for the requested collection. Chris@18: * Chris@18: * @return \Drupal\jsonapi\ResourceResponse Chris@18: * The response. Chris@18: */ Chris@18: protected function respondWithCollection(ResourceObjectData $primary_data, Data $includes, Request $request, ResourceType $resource_type, OffsetPage $page_param) { Chris@18: assert(Inspector::assertAllObjects([$includes], IncludedData::class, NullIncludedData::class)); Chris@18: $link_context = [ Chris@18: 'has_next_page' => $primary_data->hasNextPage(), Chris@18: ]; Chris@18: $meta = []; Chris@18: if ($resource_type->includeCount()) { Chris@18: $link_context['total_count'] = $meta['count'] = $primary_data->getTotalCount(); Chris@18: } Chris@18: $collection_links = self::getPagerLinks($request, $page_param, $link_context); Chris@18: $response = $this->buildWrappedResponse($primary_data, $request, $includes, 200, [], $collection_links, $meta); Chris@18: Chris@18: // When a new change to any entity in the resource happens, we cannot ensure Chris@18: // the validity of this cached list. Add the list tag to deal with that. Chris@18: $list_tag = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId()) Chris@18: ->getListCacheTags(); Chris@18: $response->getCacheableMetadata()->addCacheTags($list_tag); Chris@18: foreach ($primary_data as $entity) { Chris@18: $response->addCacheableDependency($entity); Chris@18: } Chris@18: return $response; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Takes a field from the origin entity and puts it to the destination entity. Chris@18: * Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The JSON:API resource type of the entity to be updated. Chris@18: * @param \Drupal\Core\Entity\EntityInterface $origin Chris@18: * The entity that contains the field values. Chris@18: * @param \Drupal\Core\Entity\EntityInterface $destination Chris@18: * The entity that needs to be updated. Chris@18: * @param string $field_name Chris@18: * The name of the field to extract and update. Chris@18: * Chris@18: * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException Chris@18: * Thrown when the serialized and destination entities are of different Chris@18: * types. Chris@18: */ Chris@18: protected function updateEntityField(ResourceType $resource_type, EntityInterface $origin, EntityInterface $destination, $field_name) { Chris@18: // The update is different for configuration entities and content entities. Chris@18: if ($origin instanceof ContentEntityInterface && $destination instanceof ContentEntityInterface) { Chris@18: // First scenario: both are content entities. Chris@18: $field_name = $resource_type->getInternalName($field_name); Chris@18: $destination_field_list = $destination->get($field_name); Chris@18: Chris@18: $origin_field_list = $origin->get($field_name); Chris@18: if ($this->checkPatchFieldAccess($destination_field_list, $origin_field_list)) { Chris@18: $destination->set($field_name, $origin_field_list->getValue()); Chris@18: } Chris@18: } Chris@18: elseif ($origin instanceof ConfigEntityInterface && $destination instanceof ConfigEntityInterface) { Chris@18: // Second scenario: both are config entities. Chris@18: $destination->set($field_name, $origin->get($field_name)); Chris@18: } Chris@18: else { Chris@18: throw new BadRequestHttpException('The serialized entity and the destination entity are of different types.'); Chris@18: } Chris@18: } Chris@18: Chris@18: /** Chris@18: * Gets includes for the given response data. Chris@18: * Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * @param \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\ResourceObjectData $data Chris@18: * The response data from which to resolve includes. Chris@18: * Chris@18: * @return \Drupal\jsonapi\JsonApiResource\Data Chris@18: * A Data object to be included or a NullData object if the request does not Chris@18: * specify any include paths. Chris@18: * Chris@18: * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException Chris@18: * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException Chris@18: */ Chris@18: public function getIncludes(Request $request, $data) { Chris@18: assert($data instanceof ResourceObject || $data instanceof ResourceObjectData); Chris@18: return $request->query->has('include') && ($include_parameter = $request->query->get('include')) && !empty($include_parameter) Chris@18: ? $this->includeResolver->resolve($data, $include_parameter) Chris@18: : new NullIncludedData(); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Checks whether the given field should be PATCHed. Chris@18: * Chris@18: * @param \Drupal\Core\Field\FieldItemListInterface $original_field Chris@18: * The original (stored) value for the field. Chris@18: * @param \Drupal\Core\Field\FieldItemListInterface $received_field Chris@18: * The received value for the field. Chris@18: * Chris@18: * @return bool Chris@18: * Whether the field should be PATCHed or not. Chris@18: * Chris@18: * @throws \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException Chris@18: * Thrown when the user sending the request is not allowed to update the Chris@18: * field. Only thrown when the user could not abuse this information to Chris@18: * determine the stored value. Chris@18: * Chris@18: * @internal Chris@18: * Chris@18: * @see \Drupal\rest\Plugin\rest\resource\EntityResource::checkPatchFieldAccess() Chris@18: */ Chris@18: protected function checkPatchFieldAccess(FieldItemListInterface $original_field, FieldItemListInterface $received_field) { Chris@18: // If the user is allowed to edit the field, it is always safe to set the Chris@18: // received value. We may be setting an unchanged value, but that is ok. Chris@18: $field_edit_access = $original_field->access('edit', NULL, TRUE); Chris@18: if ($field_edit_access->isAllowed()) { Chris@18: return TRUE; Chris@18: } Chris@18: Chris@18: // The user might not have access to edit the field, but still needs to Chris@18: // submit the current field value as part of the PATCH request. For Chris@18: // example, the entity keys required by denormalizers. Therefore, if the Chris@18: // received value equals the stored value, return FALSE without throwing an Chris@18: // exception. But only for fields that the user has access to view, because Chris@18: // the user has no legitimate way of knowing the current value of fields Chris@18: // that they are not allowed to view, and we must not make the presence or Chris@18: // absence of a 403 response a way to find that out. Chris@18: if ($original_field->access('view') && $original_field->equals($received_field)) { Chris@18: return FALSE; Chris@18: } Chris@18: Chris@18: // It's helpful and safe to let the user know when they are not allowed to Chris@18: // update a field. Chris@18: $field_name = $received_field->getName(); Chris@18: throw new EntityAccessDeniedHttpException($original_field->getEntity(), $field_edit_access, '/data/attributes/' . $field_name, sprintf('The current user is not allowed to PATCH the selected field (%s).', $field_name)); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Build a collection of the entities to respond with and access objects. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityStorageInterface $storage Chris@18: * The entity storage to load the entities from. Chris@18: * @param int[] $ids Chris@18: * An array of entity IDs, keyed by revision ID if the entity type is Chris@18: * revisionable. Chris@18: * @param bool $load_latest_revisions Chris@18: * Whether to load the latest revisions instead of the defaults. Chris@18: * Chris@18: * @return array Chris@18: * An array of loaded entities and/or an access exceptions. Chris@18: */ Chris@18: protected function loadEntitiesWithAccess(EntityStorageInterface $storage, array $ids, $load_latest_revisions) { Chris@18: $output = []; Chris@18: if ($load_latest_revisions) { Chris@18: assert($storage instanceof RevisionableStorageInterface); Chris@18: $entities = $storage->loadMultipleRevisions(array_keys($ids)); Chris@18: } Chris@18: else { Chris@18: $entities = $storage->loadMultiple($ids); Chris@18: } Chris@18: foreach ($entities as $entity) { Chris@18: $output[$entity->id()] = $this->entityAccessChecker->getAccessCheckedResourceObject($entity); Chris@18: } Chris@18: return array_values($output); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Checks if the given entity exists. Chris@18: * Chris@18: * @param \Drupal\Core\Entity\EntityInterface $entity Chris@18: * The entity for which to test existence. Chris@18: * Chris@18: * @return bool Chris@18: * Whether the entity already has been created. Chris@18: */ Chris@18: protected function entityExists(EntityInterface $entity) { Chris@18: $entity_storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId()); Chris@18: return !empty($entity_storage->loadByProperties([ Chris@18: 'uuid' => $entity->uuid(), Chris@18: ])); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Extracts JSON:API query parameters from the request. Chris@18: * Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type Chris@18: * The JSON:API resource type. Chris@18: * Chris@18: * @return array Chris@18: * An array of JSON:API parameters like `sort` and `filter`. Chris@18: */ Chris@18: protected function getJsonApiParams(Request $request, ResourceType $resource_type) { Chris@18: if ($request->query->has('filter')) { Chris@18: $params[Filter::KEY_NAME] = Filter::createFromQueryParameter($request->query->get('filter'), $resource_type, $this->fieldResolver); Chris@18: } Chris@18: if ($request->query->has('sort')) { Chris@18: $params[Sort::KEY_NAME] = Sort::createFromQueryParameter($request->query->get('sort')); Chris@18: } Chris@18: if ($request->query->has('page')) { Chris@18: $params[OffsetPage::KEY_NAME] = OffsetPage::createFromQueryParameter($request->query->get('page')); Chris@18: } Chris@18: else { Chris@18: $params[OffsetPage::KEY_NAME] = OffsetPage::createFromQueryParameter(['page' => ['offset' => OffsetPage::DEFAULT_OFFSET, 'limit' => OffsetPage::SIZE_MAX]]); Chris@18: } Chris@18: return $params; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Get the full URL for a given request object. Chris@18: * Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * @param array|null $query Chris@18: * The query parameters to use. Leave it empty to get the query from the Chris@18: * request object. Chris@18: * Chris@18: * @return \Drupal\Core\Url Chris@18: * The full URL. Chris@18: */ Chris@18: protected static function getRequestLink(Request $request, $query = NULL) { Chris@18: if ($query === NULL) { Chris@18: return Url::fromUri($request->getUri()); Chris@18: } Chris@18: Chris@18: $uri_without_query_string = $request->getSchemeAndHttpHost() . $request->getBaseUrl() . $request->getPathInfo(); Chris@18: return Url::fromUri($uri_without_query_string)->setOption('query', $query); Chris@18: } Chris@18: Chris@18: /** Chris@18: * Get the pager links for a given request object. Chris@18: * Chris@18: * @param \Symfony\Component\HttpFoundation\Request $request Chris@18: * The request object. Chris@18: * @param \Drupal\jsonapi\Query\OffsetPage $page_param Chris@18: * The current pagination parameter for the requested collection. Chris@18: * @param array $link_context Chris@18: * An associative array with extra data to build the links. Chris@18: * Chris@18: * @return \Drupal\jsonapi\JsonApiResource\LinkCollection Chris@18: * An LinkCollection, with: Chris@18: * - a 'next' key if it is not the last page; Chris@18: * - 'prev' and 'first' keys if it's not the first page. Chris@18: */ Chris@18: protected static function getPagerLinks(Request $request, OffsetPage $page_param, array $link_context = []) { Chris@18: $pager_links = new LinkCollection([]); Chris@18: if (!empty($link_context['total_count']) && !$total = (int) $link_context['total_count']) { Chris@18: return $pager_links; Chris@18: } Chris@18: /* @var \Drupal\jsonapi\Query\OffsetPage $page_param */ Chris@18: $offset = $page_param->getOffset(); Chris@18: $size = $page_param->getSize(); Chris@18: if ($size <= 0) { Chris@18: $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:page']); Chris@18: throw new CacheableBadRequestHttpException($cacheability, sprintf('The page size needs to be a positive integer.')); Chris@18: } Chris@18: $query = (array) $request->query->getIterator(); Chris@18: // Check if this is not the last page. Chris@18: if ($link_context['has_next_page']) { Chris@18: $next_url = static::getRequestLink($request, static::getPagerQueries('next', $offset, $size, $query)); Chris@18: $pager_links = $pager_links->withLink('next', new Link(new CacheableMetadata(), $next_url, ['next'])); Chris@18: Chris@18: if (!empty($total)) { Chris@18: $last_url = static::getRequestLink($request, static::getPagerQueries('last', $offset, $size, $query, $total)); Chris@18: $pager_links = $pager_links->withLink('last', new Link(new CacheableMetadata(), $last_url, ['last'])); Chris@18: } Chris@18: } Chris@18: Chris@18: // Check if this is not the first page. Chris@18: if ($offset > 0) { Chris@18: $first_url = static::getRequestLink($request, static::getPagerQueries('first', $offset, $size, $query)); Chris@18: $pager_links = $pager_links->withLink('first', new Link(new CacheableMetadata(), $first_url, ['first'])); Chris@18: $prev_url = static::getRequestLink($request, static::getPagerQueries('prev', $offset, $size, $query)); Chris@18: $pager_links = $pager_links->withLink('prev', new Link(new CacheableMetadata(), $prev_url, ['prev'])); Chris@18: } Chris@18: Chris@18: return $pager_links; Chris@18: } Chris@18: Chris@18: /** Chris@18: * Get the query param array. Chris@18: * Chris@18: * @param string $link_id Chris@18: * The name of the pagination link requested. Chris@18: * @param int $offset Chris@18: * The starting index. Chris@18: * @param int $size Chris@18: * The pagination page size. Chris@18: * @param array $query Chris@18: * The query parameters. Chris@18: * @param int $total Chris@18: * The total size of the collection. Chris@18: * Chris@18: * @return array Chris@18: * The pagination query param array. Chris@18: */ Chris@18: protected static function getPagerQueries($link_id, $offset, $size, array $query = [], $total = 0) { Chris@18: $extra_query = []; Chris@18: switch ($link_id) { Chris@18: case 'next': Chris@18: $extra_query = [ Chris@18: 'page' => [ Chris@18: 'offset' => $offset + $size, Chris@18: 'limit' => $size, Chris@18: ], Chris@18: ]; Chris@18: break; Chris@18: Chris@18: case 'first': Chris@18: $extra_query = [ Chris@18: 'page' => [ Chris@18: 'offset' => 0, Chris@18: 'limit' => $size, Chris@18: ], Chris@18: ]; Chris@18: break; Chris@18: Chris@18: case 'last': Chris@18: if ($total) { Chris@18: $extra_query = [ Chris@18: 'page' => [ Chris@18: 'offset' => (ceil($total / $size) - 1) * $size, Chris@18: 'limit' => $size, Chris@18: ], Chris@18: ]; Chris@18: } Chris@18: break; Chris@18: Chris@18: case 'prev': Chris@18: $extra_query = [ Chris@18: 'page' => [ Chris@18: 'offset' => max($offset - $size, 0), Chris@18: 'limit' => $size, Chris@18: ], Chris@18: ]; Chris@18: break; Chris@18: } Chris@18: return array_merge($query, $extra_query); Chris@18: } Chris@18: Chris@18: }