first commit
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Access;
|
||||
|
||||
use Drupal\content_moderation\Access\LatestRevisionCheck;
|
||||
use Drupal\Core\Access\AccessResult;
|
||||
use Drupal\Core\Access\AccessResultReasonInterface;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\EntityRepositoryInterface;
|
||||
use Drupal\Core\Entity\RevisionableInterface;
|
||||
use Drupal\Core\Routing\RouteMatch;
|
||||
use Drupal\Core\Session\AccountInterface;
|
||||
use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
|
||||
use Drupal\jsonapi\JsonApiResource\LabelOnlyResourceObject;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceObject;
|
||||
use Drupal\jsonapi\JsonApiSpec;
|
||||
use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
|
||||
use Drupal\media\Access\MediaRevisionAccessCheck;
|
||||
use Drupal\media\MediaInterface;
|
||||
use Drupal\node\Access\NodeRevisionAccessCheck;
|
||||
use Drupal\node\NodeInterface;
|
||||
use Symfony\Component\Routing\RouterInterface;
|
||||
|
||||
/**
|
||||
* Checks access to entities.
|
||||
*
|
||||
* JSON:API needs to check access to every single entity type. Some entity types
|
||||
* have non-standard access checking logic. This class centralizes entity access
|
||||
* checking logic.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class EntityAccessChecker {
|
||||
|
||||
/**
|
||||
* The JSON:API resource type repository.
|
||||
*
|
||||
* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
|
||||
*/
|
||||
protected $resourceTypeRepository;
|
||||
|
||||
/**
|
||||
* The router.
|
||||
*
|
||||
* @var \Symfony\Component\Routing\RouterInterface
|
||||
*/
|
||||
protected $router;
|
||||
|
||||
/**
|
||||
* The current user.
|
||||
*
|
||||
* @var \Drupal\Core\Session\AccountInterface
|
||||
*/
|
||||
protected $currentUser;
|
||||
|
||||
/**
|
||||
* The entity repository.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityRepositoryInterface
|
||||
*/
|
||||
protected $entityRepository;
|
||||
|
||||
/**
|
||||
* The node revision access check service.
|
||||
*
|
||||
* This will be NULL unless the node module is installed.
|
||||
*
|
||||
* @var \Drupal\node\Access\NodeRevisionAccessCheck|null
|
||||
*/
|
||||
protected $nodeRevisionAccessCheck = NULL;
|
||||
|
||||
/**
|
||||
* The media revision access check service.
|
||||
*
|
||||
* This will be NULL unless the media module is installed.
|
||||
*
|
||||
* @var \Drupal\media\Access\MediaRevisionAccessCheck|null
|
||||
*/
|
||||
protected $mediaRevisionAccessCheck = NULL;
|
||||
|
||||
/**
|
||||
* The latest revision check service.
|
||||
*
|
||||
* This will be NULL unless the content_moderation module is installed. This
|
||||
* is a temporary measure. JSON:API should not need to be aware of the
|
||||
* Content Moderation module.
|
||||
*
|
||||
* @var \Drupal\content_moderation\Access\LatestRevisionCheck
|
||||
*/
|
||||
protected $latestRevisionCheck = NULL;
|
||||
|
||||
/**
|
||||
* EntityAccessChecker constructor.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
|
||||
* The JSON:API resource type repository.
|
||||
* @param \Symfony\Component\Routing\RouterInterface $router
|
||||
* The router.
|
||||
* @param \Drupal\Core\Session\AccountInterface $account
|
||||
* The current user.
|
||||
* @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
|
||||
* The entity repository.
|
||||
*/
|
||||
public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, RouterInterface $router, AccountInterface $account, EntityRepositoryInterface $entity_repository) {
|
||||
$this->resourceTypeRepository = $resource_type_repository;
|
||||
$this->router = $router;
|
||||
$this->currentUser = $account;
|
||||
$this->entityRepository = $entity_repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the node revision access check service.
|
||||
*
|
||||
* This is only called when node module is installed.
|
||||
*
|
||||
* @param \Drupal\node\Access\NodeRevisionAccessCheck $node_revision_access_check
|
||||
* The node revision access check service.
|
||||
*/
|
||||
public function setNodeRevisionAccessCheck(NodeRevisionAccessCheck $node_revision_access_check) {
|
||||
$this->nodeRevisionAccessCheck = $node_revision_access_check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the media revision access check service.
|
||||
*
|
||||
* This is only called when media module is installed.
|
||||
*
|
||||
* @param \Drupal\media\Access\MediaRevisionAccessCheck $media_revision_access_check
|
||||
* The media revision access check service.
|
||||
*/
|
||||
public function setMediaRevisionAccessCheck(MediaRevisionAccessCheck $media_revision_access_check) {
|
||||
$this->mediaRevisionAccessCheck = $media_revision_access_check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the media revision access check service.
|
||||
*
|
||||
* This is only called when content_moderation module is installed.
|
||||
*
|
||||
* @param \Drupal\content_moderation\Access\LatestRevisionCheck $latest_revision_check
|
||||
* The latest revision access check service provided by the
|
||||
* content_moderation module.
|
||||
*
|
||||
* @see self::$latestRevisionCheck
|
||||
*/
|
||||
public function setLatestRevisionCheck(LatestRevisionCheck $latest_revision_check) {
|
||||
$this->latestRevisionCheck = $latest_revision_check;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param \Drupal\Core\Session\AccountInterface $account
|
||||
* (optional) The account with which access should be checked. Defaults to
|
||||
* the current user.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\LabelOnlyResourceObject|\Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
|
||||
* The ResourceObject, a LabelOnlyResourceObject or an
|
||||
* EntityAccessDeniedHttpException object if neither is accessible. All
|
||||
* three possible return values carry the access result cacheability.
|
||||
*/
|
||||
public function getAccessCheckedResourceObject(EntityInterface $entity, AccountInterface $account = NULL) {
|
||||
$account = $account ?: $this->currentUser;
|
||||
$resource_type = $this->resourceTypeRepository->get($entity->getEntityTypeId(), $entity->bundle());
|
||||
$entity = $this->entityRepository->getTranslationFromContext($entity, NULL, ['operation' => 'entity_upcast']);
|
||||
$access = $this->checkEntityAccess($entity, 'view', $account);
|
||||
$entity->addCacheableDependency($access);
|
||||
if (!$access->isAllowed()) {
|
||||
// If this is the default revision or the entity is not revisionable, then
|
||||
// check access to the entity label. Revision support is all or nothing.
|
||||
if (!$entity->getEntityType()->isRevisionable() || $entity->isDefaultRevision()) {
|
||||
$label_access = $entity->access('view label', NULL, TRUE);
|
||||
$entity->addCacheableDependency($label_access);
|
||||
if ($label_access->isAllowed()) {
|
||||
return LabelOnlyResourceObject::createFromEntity($resource_type, $entity);
|
||||
}
|
||||
$access = $access->orIf($label_access);
|
||||
}
|
||||
return new EntityAccessDeniedHttpException($entity, $access, '/data', 'The current user is not allowed to GET the selected resource.');
|
||||
}
|
||||
return ResourceObject::createFromEntity($resource_type, $entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks access to the given entity.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity for which access should be evaluated.
|
||||
* @param string $operation
|
||||
* The entity operation for which access should be evaluated.
|
||||
* @param \Drupal\Core\Session\AccountInterface $account
|
||||
* (optional) The account with which access should be checked. Defaults to
|
||||
* the current user.
|
||||
*
|
||||
* @return \Drupal\Core\Access\AccessResultInterface|\Drupal\Core\Access\AccessResultReasonInterface
|
||||
* The access check result.
|
||||
*/
|
||||
public function checkEntityAccess(EntityInterface $entity, $operation, AccountInterface $account) {
|
||||
$access = $entity->access($operation, $account, TRUE);
|
||||
if ($entity->getEntityType()->isRevisionable()) {
|
||||
$access = AccessResult::neutral()->addCacheContexts(['url.query_args:' . JsonApiSpec::VERSION_QUERY_PARAMETER])->orIf($access);
|
||||
if (!$entity->isDefaultRevision()) {
|
||||
assert($operation === 'view', 'JSON:API does not yet support mutable operations on revisions.');
|
||||
$revision_access = $this->checkRevisionViewAccess($entity, $account);
|
||||
$access = $access->andIf($revision_access);
|
||||
// The revision access reason should trump the primary access reason.
|
||||
if (!$access->isAllowed()) {
|
||||
$reason = $access instanceof AccessResultReasonInterface ? $access->getReason() : '';
|
||||
$access->setReason(trim('The user does not have access to the requested version. ' . $reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
return $access;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks access to the given revision entity.
|
||||
*
|
||||
* This should only be called for non-default revisions.
|
||||
*
|
||||
* There is no standardized API for revision access checking in Drupal core
|
||||
* and this method shims that missing API.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The revised entity for which to check access.
|
||||
* @param \Drupal\Core\Session\AccountInterface $account
|
||||
* (optional) The account with which access should be checked. Defaults to
|
||||
* the current user.
|
||||
*
|
||||
* @return \Drupal\Core\Access\AccessResultInterface|\Drupal\Core\Access\AccessResultReasonInterface
|
||||
* The access check result.
|
||||
*
|
||||
* @todo: remove when a generic revision access API exists in Drupal core, and
|
||||
* also remove the injected "node" and "media" services.
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818386
|
||||
*/
|
||||
protected function checkRevisionViewAccess(EntityInterface $entity, AccountInterface $account) {
|
||||
assert($entity instanceof RevisionableInterface);
|
||||
assert(!$entity->isDefaultRevision(), 'It is not necessary to check revision access when the entity is the default revision.');
|
||||
$entity_type = $entity->getEntityType();
|
||||
switch ($entity_type->id()) {
|
||||
case 'node':
|
||||
assert($entity instanceof NodeInterface);
|
||||
$access = AccessResult::allowedIf($this->nodeRevisionAccessCheck->checkAccess($entity, $account, 'view'))->cachePerPermissions()->addCacheableDependency($entity);
|
||||
break;
|
||||
|
||||
case 'media':
|
||||
assert($entity instanceof MediaInterface);
|
||||
$access = AccessResult::allowedIf($this->mediaRevisionAccessCheck->checkAccess($entity, $account, 'view'))->cachePerPermissions()->addCacheableDependency($entity);
|
||||
break;
|
||||
|
||||
default:
|
||||
$reason = 'Only node and media revisions are supported by JSON:API.';
|
||||
$reason .= ' For context, see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818258.';
|
||||
$reason .= ' To contribute, see https://www.drupal.org/project/drupal/issues/2350939 and https://www.drupal.org/project/drupal/issues/2809177.';
|
||||
$access = AccessResult::neutral($reason);
|
||||
}
|
||||
// Apply content_moderation's additional access logic.
|
||||
// @see \Drupal\content_moderation\Access\LatestRevisionCheck::access()
|
||||
if ($entity_type->getLinkTemplate('latest-version') && $entity->isLatestRevision() && isset($this->latestRevisionCheck)) {
|
||||
// The latest revision access checker only expects to be invoked by the
|
||||
// routing system, which makes it necessary to fake a route match.
|
||||
$routes = $this->router->getRouteCollection();
|
||||
$resource_type = $this->resourceTypeRepository->get($entity->getEntityTypeId(), $entity->bundle());
|
||||
$route_name = sprintf('jsonapi.%s.individual', $resource_type->getTypeName());
|
||||
$route = $routes->get($route_name);
|
||||
$route->setOption('_content_moderation_entity_type', 'entity');
|
||||
$route_match = new RouteMatch($route_name, $route, ['entity' => $entity], ['entity' => $entity->uuid()]);
|
||||
$moderation_access_result = $this->latestRevisionCheck->access($route, $route_match, $account);
|
||||
$access = $access->andIf($moderation_access_result);
|
||||
}
|
||||
return $access;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Access;
|
||||
|
||||
use Drupal\Core\Access\AccessResult;
|
||||
use Drupal\Core\Access\AccessResultReasonInterface;
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Entity\FieldableEntityInterface;
|
||||
use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
|
||||
use Drupal\Core\Routing\Access\AccessInterface;
|
||||
use Drupal\Core\Session\AccountInterface;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Drupal\jsonapi\Routing\Routes;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Route;
|
||||
|
||||
/**
|
||||
* Defines a class to check access to related and relationship routes.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class RelationshipFieldAccess implements AccessInterface {
|
||||
|
||||
/**
|
||||
* The route requirement key for this access check.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const ROUTE_REQUIREMENT_KEY = '_jsonapi_relationship_field_access';
|
||||
|
||||
/**
|
||||
* The JSON:API entity access checker.
|
||||
*
|
||||
* @var \Drupal\jsonapi\Access\EntityAccessChecker
|
||||
*/
|
||||
protected $entityAccessChecker;
|
||||
|
||||
/**
|
||||
* RelationshipFieldAccess constructor.
|
||||
*
|
||||
* @param \Drupal\jsonapi\Access\EntityAccessChecker $entity_access_checker
|
||||
* The JSON:API entity access checker.
|
||||
*/
|
||||
public function __construct(EntityAccessChecker $entity_access_checker) {
|
||||
$this->entityAccessChecker = $entity_access_checker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks access to the relationship field on the given route.
|
||||
*
|
||||
* @param \Symfony\Component\HttpFoundation\Request $request
|
||||
* The incoming HTTP request object.
|
||||
* @param \Symfony\Component\Routing\Route $route
|
||||
* The route to check against.
|
||||
* @param \Drupal\Core\Session\AccountInterface $account
|
||||
* The currently logged in account.
|
||||
*
|
||||
* @return \Drupal\Core\Access\AccessResultInterface
|
||||
* The access result.
|
||||
*/
|
||||
public function access(Request $request, Route $route, AccountInterface $account) {
|
||||
$relationship_field_name = $route->getRequirement(static::ROUTE_REQUIREMENT_KEY);
|
||||
$field_operation = $request->isMethodCacheable() ? 'view' : 'edit';
|
||||
$entity_operation = $request->isMethodCacheable() ? 'view' : 'update';
|
||||
if ($resource_type = $request->get(Routes::RESOURCE_TYPE_KEY)) {
|
||||
assert($resource_type instanceof ResourceType);
|
||||
$entity = $request->get('entity');
|
||||
$internal_name = $resource_type->getInternalName($relationship_field_name);
|
||||
if ($entity instanceof FieldableEntityInterface && $entity->hasField($internal_name)) {
|
||||
$entity_access = $this->entityAccessChecker->checkEntityAccess($entity, $entity_operation, $account);
|
||||
$field_access = $entity->get($internal_name)->access($field_operation, $account, TRUE);
|
||||
// Ensure that access is respected for different entity revisions.
|
||||
$access_result = $entity_access->andIf($field_access);
|
||||
if (!$access_result->isAllowed()) {
|
||||
$reason = "The current user is not allowed to {$field_operation} this relationship.";
|
||||
$access_reason = $access_result instanceof AccessResultReasonInterface ? $access_result->getReason() : NULL;
|
||||
$detailed_reason = empty($access_reason) ? $reason : $reason . " {$access_reason}";
|
||||
$access_result->setReason($detailed_reason);
|
||||
if ($request->isMethodCacheable()) {
|
||||
throw new CacheableAccessDeniedHttpException(CacheableMetadata::createFromObject($access_result), $detailed_reason);
|
||||
}
|
||||
}
|
||||
return $access_result;
|
||||
}
|
||||
}
|
||||
return AccessResult::neutral();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Access;
|
||||
|
||||
use Drupal\Core\Access\AccessResult;
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
|
||||
use Drupal\Core\Entity\EntityFieldManagerInterface;
|
||||
use Drupal\Core\Entity\EntityTypeInterface;
|
||||
use Drupal\Core\Entity\Query\QueryInterface;
|
||||
use Drupal\Core\Extension\ModuleHandlerInterface;
|
||||
use Drupal\Core\Field\FieldStorageDefinitionInterface;
|
||||
use Drupal\Core\Session\AccountInterface;
|
||||
use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
|
||||
use Drupal\jsonapi\Query\EntityCondition;
|
||||
use Drupal\jsonapi\Query\EntityConditionGroup;
|
||||
use Drupal\jsonapi\Query\Filter;
|
||||
|
||||
/**
|
||||
* Adds sufficient access control to collection queries.
|
||||
*
|
||||
* This class will be removed when new Drupal core APIs have been put in place
|
||||
* to make it obsolete.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @todo These additional security measures should eventually reside in the
|
||||
* Entity API subsystem but were introduced here to address a security
|
||||
* vulnerability. The following two issues should obsolesce this class:
|
||||
*
|
||||
* @see https://www.drupal.org/project/drupal/issues/2809177
|
||||
* @see https://www.drupal.org/project/drupal/issues/777578
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class TemporaryQueryGuard {
|
||||
|
||||
/**
|
||||
* The entity field manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
|
||||
*/
|
||||
protected static $fieldManager;
|
||||
|
||||
/**
|
||||
* The module handler.
|
||||
*
|
||||
* @var \Drupal\Core\Extension\ModuleHandlerInterface
|
||||
*/
|
||||
protected static $moduleHandler;
|
||||
|
||||
/**
|
||||
* Sets the entity field manager.
|
||||
*
|
||||
* This must be called before calling ::applyAccessControls().
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
|
||||
* The entity field manager.
|
||||
*/
|
||||
public static function setFieldManager(EntityFieldManagerInterface $field_manager) {
|
||||
static::$fieldManager = $field_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the module handler.
|
||||
*
|
||||
* This must be called before calling ::applyAccessControls().
|
||||
*
|
||||
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
|
||||
* The module handler.
|
||||
*/
|
||||
public static function setModuleHandler(ModuleHandlerInterface $module_handler) {
|
||||
static::$moduleHandler = $module_handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies access controls to an entity query.
|
||||
*
|
||||
* @param \Drupal\jsonapi\Query\Filter $filter
|
||||
* The filters applicable to the query.
|
||||
* @param \Drupal\Core\Entity\Query\QueryInterface $query
|
||||
* The query to which access controls should be applied.
|
||||
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
|
||||
* Collects cacheability for the query.
|
||||
*/
|
||||
public static function applyAccessControls(Filter $filter, QueryInterface $query, CacheableMetadata $cacheability) {
|
||||
assert(static::$fieldManager !== NULL);
|
||||
assert(static::$moduleHandler !== NULL);
|
||||
$filtered_fields = static::collectFilteredFields($filter->root());
|
||||
$field_specifiers = array_map(function ($field) {
|
||||
return explode('.', $field);
|
||||
}, $filtered_fields);
|
||||
static::secureQuery($query, $query->getEntityTypeId(), static::buildTree($field_specifiers), $cacheability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies tags, metadata and conditions to secure an entity query.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\Query\QueryInterface $query
|
||||
* The query to be secured.
|
||||
* @param string $entity_type_id
|
||||
* An entity type ID.
|
||||
* @param array $tree
|
||||
* A tree of field specifiers in an entity query condition. The tree is a
|
||||
* multi-dimensional array where the keys are field specifiers and the
|
||||
* values are multi-dimensional array of the same form, containing only
|
||||
* subsequent specifiers. @see ::buildTree().
|
||||
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
|
||||
* Collects cacheability for the query.
|
||||
* @param string|null $field_prefix
|
||||
* Internal use only. Contains a string representation of the previously
|
||||
* visited field specifiers.
|
||||
* @param \Drupal\Core\Field\FieldStorageDefinitionInterface $field_storage_definition
|
||||
* Internal use only. The current field storage definition, if known.
|
||||
*
|
||||
* @see \Drupal\Core\Database\Query\AlterableInterface::addTag()
|
||||
* @see \Drupal\Core\Database\Query\AlterableInterface::addMetaData()
|
||||
* @see \Drupal\Core\Database\Query\ConditionInterface
|
||||
*/
|
||||
protected static function secureQuery(QueryInterface $query, $entity_type_id, array $tree, CacheableMetadata $cacheability, $field_prefix = NULL, FieldStorageDefinitionInterface $field_storage_definition = NULL) {
|
||||
$entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id);
|
||||
// Config entity types are not fieldable, therefore they do not have field
|
||||
// access restrictions, nor entity references to other entity types.
|
||||
if ($entity_type instanceof ConfigEntityTypeInterface) {
|
||||
return;
|
||||
}
|
||||
foreach ($tree as $specifier => $children) {
|
||||
// The field path reconstructs the entity condition fields.
|
||||
// E.g. `uid.0` would become `uid.0.name` if $specifier === 'name'.
|
||||
$child_prefix = (is_null($field_prefix)) ? $specifier : "$field_prefix.$specifier";
|
||||
if (is_null($field_storage_definition)) {
|
||||
// When the field storage definition is NULL, this specifier is the
|
||||
// first specifier in an entity query field path or the previous
|
||||
// specifier was a data reference that has been traversed. In both
|
||||
// cases, the specifier must be a field name.
|
||||
$field_storage_definitions = static::$fieldManager->getFieldStorageDefinitions($entity_type_id);
|
||||
static::secureQuery($query, $entity_type_id, $children, $cacheability, $child_prefix, $field_storage_definitions[$specifier]);
|
||||
// When $field_prefix is NULL, this must be the first specifier in the
|
||||
// entity query field path and a condition for the query's base entity
|
||||
// type must be applied.
|
||||
if (is_null($field_prefix)) {
|
||||
static::applyAccessConditions($query, $entity_type_id, NULL, $cacheability);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// When the specifier is an entity reference, it can contain an entity
|
||||
// type specifier, like so: `entity:node`. This extracts the `entity`
|
||||
// portion. JSON:API will have already validated that the property
|
||||
// exists.
|
||||
$split_specifier = explode(':', $specifier, 2);
|
||||
list($property_name, $target_entity_type_id) = array_merge($split_specifier, count($split_specifier) === 2 ? [] : [NULL]);
|
||||
// The specifier is either a field property or a delta. If it is a data
|
||||
// reference or a delta, then it needs to be traversed to the next
|
||||
// specifier. However, if the specific is a simple field property, i.e.
|
||||
// it is neither a data reference nor a delta, then there is no need to
|
||||
// evaluate the remaining specifiers.
|
||||
$property_definition = $field_storage_definition->getPropertyDefinition($property_name);
|
||||
if ($property_definition instanceof DataReferenceDefinitionInterface) {
|
||||
// Because the filter is following an entity reference, ensure
|
||||
// access is respected on those targeted entities.
|
||||
// Examples:
|
||||
// - node_query_node_access_alter()
|
||||
$target_entity_type_id = $target_entity_type_id ?: $field_storage_definition->getSetting('target_type');
|
||||
$query->addTag("{$target_entity_type_id}_access");
|
||||
static::applyAccessConditions($query, $target_entity_type_id, $child_prefix, $cacheability);
|
||||
// Keep descending the tree.
|
||||
static::secureQuery($query, $target_entity_type_id, $children, $cacheability, $child_prefix);
|
||||
}
|
||||
elseif (is_null($property_definition)) {
|
||||
assert(is_numeric($property_name), 'The specifier is not a property name, it must be a delta.');
|
||||
// Keep descending the tree.
|
||||
static::secureQuery($query, $entity_type_id, $children, $cacheability, $child_prefix, $field_storage_definition);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies access conditions to ensure 'view' access is respected.
|
||||
*
|
||||
* Since the given entity type might not be the base entity type of the query,
|
||||
* the field prefix should be applied to ensure that the conditions are
|
||||
* applied to the right subset of entities in the query.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\Query\QueryInterface $query
|
||||
* The query to which access conditions should be applied.
|
||||
* @param string $entity_type_id
|
||||
* The entity type for which to access conditions should be applied.
|
||||
* @param string|null $field_prefix
|
||||
* A prefix to add before any query condition fields. NULL if no prefix
|
||||
* should be added.
|
||||
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
|
||||
* Collects cacheability for the query.
|
||||
*/
|
||||
protected static function applyAccessConditions(QueryInterface $query, $entity_type_id, $field_prefix, CacheableMetadata $cacheability) {
|
||||
$access_condition = static::getAccessCondition($entity_type_id, $cacheability);
|
||||
if ($access_condition) {
|
||||
$prefixed_condition = !is_null($field_prefix)
|
||||
? static::addConditionFieldPrefix($access_condition, $field_prefix)
|
||||
: $access_condition;
|
||||
$filter = new Filter($prefixed_condition);
|
||||
$query->condition($filter->queryCondition($query));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefixes all fields in an EntityConditionGroup.
|
||||
*/
|
||||
protected static function addConditionFieldPrefix(EntityConditionGroup $group, $field_prefix) {
|
||||
$prefixed = [];
|
||||
foreach ($group->members() as $member) {
|
||||
if ($member instanceof EntityConditionGroup) {
|
||||
$prefixed[] = static::addConditionFieldPrefix($member, $field_prefix);
|
||||
}
|
||||
else {
|
||||
$field = !empty($field_prefix) ? "{$field_prefix}." . $member->field() : $member->field();
|
||||
$prefixed[] = new EntityCondition($field, $member->value(), $member->operator());
|
||||
}
|
||||
}
|
||||
return new EntityConditionGroup($group->conjunction(), $prefixed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an EntityConditionGroup that filters out inaccessible entities.
|
||||
*
|
||||
* @param string $entity_type_id
|
||||
* The entity type ID for which to get an EntityConditionGroup.
|
||||
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
|
||||
* Collects cacheability for the query.
|
||||
*
|
||||
* @return \Drupal\jsonapi\Query\EntityConditionGroup|null
|
||||
* An EntityConditionGroup or NULL if no conditions need to be applied to
|
||||
* secure an entity query.
|
||||
*/
|
||||
protected static function getAccessCondition($entity_type_id, CacheableMetadata $cacheability) {
|
||||
$current_user = \Drupal::currentUser();
|
||||
$entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id);
|
||||
|
||||
// Get the condition that handles generic restrictions, such as published
|
||||
// and owner.
|
||||
$generic_condition = static::getAccessConditionForKnownSubsets($entity_type, $current_user, $cacheability);
|
||||
|
||||
// Some entity types require additional conditions. We don't know what
|
||||
// contrib entity types require, so they are responsible for implementing
|
||||
// hook_query_ENTITY_TYPE_access_alter(). Some core entity types have
|
||||
// logic in their access control handler that isn't mirrored in
|
||||
// hook_query_ENTITY_TYPE_access_alter(), so we duplicate that here until
|
||||
// that's resolved.
|
||||
$specific_condition = NULL;
|
||||
switch ($entity_type_id) {
|
||||
case 'block_content':
|
||||
// Allow access only to reusable blocks.
|
||||
// @see \Drupal\block_content\BlockContentAccessControlHandler::checkAccess()
|
||||
if (isset(static::$fieldManager->getBaseFieldDefinitions($entity_type_id)['reusable'])) {
|
||||
$specific_condition = new EntityCondition('reusable', 1);
|
||||
$cacheability->addCacheTags($entity_type->getListCacheTags());
|
||||
}
|
||||
break;
|
||||
|
||||
case 'comment':
|
||||
// @see \Drupal\comment\CommentAccessControlHandler::checkAccess()
|
||||
$specific_condition = static::getCommentAccessCondition($entity_type, $current_user, $cacheability);
|
||||
break;
|
||||
|
||||
case 'entity_test':
|
||||
// This case is only necessary for testing comment access controls.
|
||||
// @see \Drupal\jsonapi\Tests\Functional\CommentTest::testCollectionFilterAccess()
|
||||
$blacklist = \Drupal::state()->get('jsonapi__entity_test_filter_access_blacklist', []);
|
||||
$cacheability->addCacheTags(['state:jsonapi__entity_test_filter_access_blacklist']);
|
||||
$specific_conditions = [];
|
||||
foreach ($blacklist as $id) {
|
||||
$specific_conditions[] = new EntityCondition('id', $id, '<>');
|
||||
}
|
||||
if ($specific_conditions) {
|
||||
$specific_condition = new EntityConditionGroup('AND', $specific_conditions);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file':
|
||||
// Allow access only to public files and files uploaded by the current
|
||||
// user.
|
||||
// @see \Drupal\file\FileAccessControlHandler::checkAccess()
|
||||
$specific_condition = new EntityConditionGroup('OR', [
|
||||
new EntityCondition('uri', 'public://', 'STARTS_WITH'),
|
||||
new EntityCondition('uid', $current_user->id()),
|
||||
]);
|
||||
$cacheability->addCacheTags($entity_type->getListCacheTags());
|
||||
break;
|
||||
|
||||
case 'shortcut':
|
||||
// Unless the user can administer shortcuts, allow access only to the
|
||||
// user's currently displayed shortcut set.
|
||||
// @see \Drupal\shortcut\ShortcutAccessControlHandler::checkAccess()
|
||||
if (!$current_user->hasPermission('administer shortcuts')) {
|
||||
$specific_condition = new EntityCondition('shortcut_set', shortcut_current_displayed_set()->id());
|
||||
$cacheability->addCacheContexts(['user']);
|
||||
$cacheability->addCacheTags($entity_type->getListCacheTags());
|
||||
}
|
||||
break;
|
||||
|
||||
case 'user':
|
||||
// Disallow querying values of the anonymous user.
|
||||
// @see \Drupal\user\UserAccessControlHandler::checkAccess()
|
||||
$specific_condition = new EntityCondition('uid', '0', '!=');
|
||||
break;
|
||||
}
|
||||
|
||||
// Return a combined condition.
|
||||
if ($generic_condition && $specific_condition) {
|
||||
return new EntityConditionGroup('AND', [$generic_condition, $specific_condition]);
|
||||
}
|
||||
elseif ($generic_condition) {
|
||||
return $generic_condition instanceof EntityConditionGroup ? $generic_condition : new EntityConditionGroup('AND', [$generic_condition]);
|
||||
}
|
||||
elseif ($specific_condition) {
|
||||
return $specific_condition instanceof EntityConditionGroup ? $specific_condition : new EntityConditionGroup('AND', [$specific_condition]);
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an access condition for the allowed JSONAPI_FILTER_AMONG_* subsets.
|
||||
*
|
||||
* If access is allowed for the JSONAPI_FILTER_AMONG_ALL subset, then no
|
||||
* conditions are returned. Otherwise, if access is allowed for
|
||||
* JSONAPI_FILTER_AMONG_PUBLISHED, JSONAPI_FILTER_AMONG_ENABLED, or
|
||||
* JSONAPI_FILTER_AMONG_OWN, then a condition group is returned for the union
|
||||
* of allowed subsets. If no subsets are allowed, then static::alwaysFalse()
|
||||
* is returned.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
|
||||
* The entity type for which to check filter access.
|
||||
* @param \Drupal\Core\Session\AccountInterface $account
|
||||
* The account for which to check access.
|
||||
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
|
||||
* Collects cacheability for the query.
|
||||
*
|
||||
* @return \Drupal\jsonapi\Query\EntityConditionGroup|null
|
||||
* An EntityConditionGroup or NULL if no conditions need to be applied to
|
||||
* secure an entity query.
|
||||
*/
|
||||
protected static function getAccessConditionForKnownSubsets(EntityTypeInterface $entity_type, AccountInterface $account, CacheableMetadata $cacheability) {
|
||||
// Get the combined access results for each JSONAPI_FILTER_AMONG_* subset.
|
||||
$access_results = static::getAccessResultsFromEntityFilterHook($entity_type, $account);
|
||||
|
||||
// No conditions are needed if access is allowed for all entities.
|
||||
$cacheability->addCacheableDependency($access_results[JSONAPI_FILTER_AMONG_ALL]);
|
||||
if ($access_results[JSONAPI_FILTER_AMONG_ALL]->isAllowed()) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// If filtering is not allowed across all entities, but is allowed for
|
||||
// certain subsets, then add conditions that reflect those subsets. These
|
||||
// will be grouped in an OR to reflect that access may be granted to
|
||||
// more than one subset. If no conditions are added below, then
|
||||
// static::alwaysFalse() is returned.
|
||||
$conditions = [];
|
||||
|
||||
// The "published" subset.
|
||||
$published_field_name = $entity_type->getKey('published');
|
||||
if ($published_field_name) {
|
||||
$access_result = $access_results[JSONAPI_FILTER_AMONG_PUBLISHED];
|
||||
$cacheability->addCacheableDependency($access_result);
|
||||
if ($access_result->isAllowed()) {
|
||||
$conditions[] = new EntityCondition($published_field_name, 1);
|
||||
$cacheability->addCacheTags($entity_type->getListCacheTags());
|
||||
}
|
||||
}
|
||||
|
||||
// The "enabled" subset.
|
||||
// @todo Remove ternary when the 'status' key is added to the User entity type.
|
||||
$status_field_name = $entity_type->id() === 'user' ? 'status' : $entity_type->getKey('status');
|
||||
if ($status_field_name) {
|
||||
$access_result = $access_results[JSONAPI_FILTER_AMONG_ENABLED];
|
||||
$cacheability->addCacheableDependency($access_result);
|
||||
if ($access_result->isAllowed()) {
|
||||
$conditions[] = new EntityCondition($status_field_name, 1);
|
||||
$cacheability->addCacheTags($entity_type->getListCacheTags());
|
||||
}
|
||||
}
|
||||
|
||||
// The "owner" subset.
|
||||
// @todo Remove ternary when the 'uid' key is added to the User entity type.
|
||||
$owner_field_name = $entity_type->id() === 'user' ? 'uid' : $entity_type->getKey('owner');
|
||||
if ($owner_field_name) {
|
||||
$access_result = $access_results[JSONAPI_FILTER_AMONG_OWN];
|
||||
$cacheability->addCacheableDependency($access_result);
|
||||
if ($access_result->isAllowed()) {
|
||||
$cacheability->addCacheContexts(['user']);
|
||||
if ($account->isAuthenticated()) {
|
||||
$conditions[] = new EntityCondition($owner_field_name, $account->id());
|
||||
$cacheability->addCacheTags($entity_type->getListCacheTags());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no conditions were added above, then access wasn't granted to any
|
||||
// subset, so return alwaysFalse().
|
||||
if (empty($conditions)) {
|
||||
return static::alwaysFalse($entity_type);
|
||||
}
|
||||
|
||||
// If more than one condition was added above, then access was granted to
|
||||
// more than one subset, so combine them with an OR.
|
||||
if (count($conditions) > 1) {
|
||||
return new EntityConditionGroup('OR', $conditions);
|
||||
}
|
||||
|
||||
// Otherwise return the single condition.
|
||||
return $conditions[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the combined access result for each JSONAPI_FILTER_AMONG_* subset.
|
||||
*
|
||||
* This invokes hook_jsonapi_entity_filter_access() and
|
||||
* hook_jsonapi_ENTITY_TYPE_filter_access() and combines the results from all
|
||||
* of the modules into a single set of results.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
|
||||
* The entity type for which to check filter access.
|
||||
* @param \Drupal\Core\Session\AccountInterface $account
|
||||
* The account for which to check access.
|
||||
*
|
||||
* @return \Drupal\Core\Access\AccessResultInterface[]
|
||||
* The array of access results, keyed by subset. See
|
||||
* hook_jsonapi_entity_filter_access() for details.
|
||||
*/
|
||||
protected static function getAccessResultsFromEntityFilterHook(EntityTypeInterface $entity_type, AccountInterface $account) {
|
||||
/* @var \Drupal\Core\Access\AccessResultInterface[] $combined_access_results */
|
||||
$combined_access_results = [
|
||||
JSONAPI_FILTER_AMONG_ALL => AccessResult::neutral(),
|
||||
JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::neutral(),
|
||||
JSONAPI_FILTER_AMONG_ENABLED => AccessResult::neutral(),
|
||||
JSONAPI_FILTER_AMONG_OWN => AccessResult::neutral(),
|
||||
];
|
||||
|
||||
// Invoke hook_jsonapi_entity_filter_access() and
|
||||
// hook_jsonapi_ENTITY_TYPE_filter_access() for each module and merge its
|
||||
// results with the combined results.
|
||||
foreach (['jsonapi_entity_filter_access', 'jsonapi_' . $entity_type->id() . '_filter_access'] as $hook) {
|
||||
foreach (static::$moduleHandler->getImplementations($hook) as $module) {
|
||||
$module_access_results = static::$moduleHandler->invoke($module, $hook, [$entity_type, $account]);
|
||||
if ($module_access_results) {
|
||||
foreach ($module_access_results as $subset => $access_result) {
|
||||
$combined_access_results[$subset] = $combined_access_results[$subset]->orIf($access_result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $combined_access_results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an access condition for a comment entity.
|
||||
*
|
||||
* Unlike all other core entity types, Comment entities' access control
|
||||
* depends on access to a referenced entity. More challenging yet, that entity
|
||||
* reference field may target different entity types depending on the comment
|
||||
* bundle. This makes the query access conditions sufficiently complex to
|
||||
* merit a dedicated method.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeInterface $comment_entity_type
|
||||
* The comment entity type object.
|
||||
* @param \Drupal\Core\Session\AccountInterface $current_user
|
||||
* The current user.
|
||||
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
|
||||
* Collects cacheability for the query.
|
||||
* @param int $depth
|
||||
* Internal use only. The recursion depth. It is possible to have comments
|
||||
* on comments, but since comment access is dependent on access to the
|
||||
* entity on which they live, this method can recurse endlessly.
|
||||
*
|
||||
* @return \Drupal\jsonapi\Query\EntityConditionGroup|null
|
||||
* An EntityConditionGroup or NULL if no conditions need to be applied to
|
||||
* secure an entity query.
|
||||
*/
|
||||
protected static function getCommentAccessCondition(EntityTypeInterface $comment_entity_type, AccountInterface $current_user, CacheableMetadata $cacheability, $depth = 1) {
|
||||
// If a comment is assigned to another entity or author the cache needs to
|
||||
// be invalidated.
|
||||
$cacheability->addCacheTags($comment_entity_type->getListCacheTags());
|
||||
// Constructs a big EntityConditionGroup which will filter comments based on
|
||||
// the current user's access to the entities on which each comment lives.
|
||||
// This is especially complex because comments of different bundles can
|
||||
// live on entities of different entity types.
|
||||
$comment_entity_type_id = $comment_entity_type->id();
|
||||
$field_map = static::$fieldManager->getFieldMapByFieldType('entity_reference');
|
||||
assert(isset($field_map[$comment_entity_type_id]['entity_id']['bundles']), 'Every comment has an `entity_id` field.');
|
||||
$bundle_ids_by_target_entity_type_id = [];
|
||||
foreach ($field_map[$comment_entity_type_id]['entity_id']['bundles'] as $bundle_id) {
|
||||
$field_definitions = static::$fieldManager->getFieldDefinitions($comment_entity_type_id, $bundle_id);
|
||||
$commented_entity_field_definition = $field_definitions['entity_id'];
|
||||
// Each commented entity field definition has a setting which indicates
|
||||
// the entity type of the commented entity reference field. This differs
|
||||
// per bundle.
|
||||
$target_entity_type_id = $commented_entity_field_definition->getSetting('target_type');
|
||||
$bundle_ids_by_target_entity_type_id[$target_entity_type_id][] = $bundle_id;
|
||||
}
|
||||
$bundle_specific_access_conditions = [];
|
||||
foreach ($bundle_ids_by_target_entity_type_id as $target_entity_type_id => $bundle_ids) {
|
||||
// Construct a field specifier prefix which targets the commented entity.
|
||||
$condition_field_prefix = "entity_id.entity:$target_entity_type_id";
|
||||
// Ensure that for each possible commented entity type (which varies per
|
||||
// bundle), a condition is created that restricts access based on access
|
||||
// to the commented entity.
|
||||
$bundle_condition = new EntityCondition($comment_entity_type->getKey('bundle'), $bundle_ids, 'IN');
|
||||
// Comments on comments can create an infinite recursion! If the target
|
||||
// entity type ID is comment, we need special behavior.
|
||||
if ($target_entity_type_id === $comment_entity_type_id) {
|
||||
$nested_comment_condition = $depth <= 3
|
||||
? static::getCommentAccessCondition($comment_entity_type, $current_user, $cacheability, $depth + 1)
|
||||
: static::alwaysFalse($comment_entity_type);
|
||||
$prefixed_comment_condition = static::addConditionFieldPrefix($nested_comment_condition, $condition_field_prefix);
|
||||
$bundle_specific_access_conditions[$target_entity_type_id] = new EntityConditionGroup('AND', [$bundle_condition, $prefixed_comment_condition]);
|
||||
}
|
||||
else {
|
||||
$target_condition = static::getAccessCondition($target_entity_type_id, $cacheability);
|
||||
$bundle_specific_access_conditions[$target_entity_type_id] = !is_null($target_condition)
|
||||
? new EntityConditionGroup('AND', [
|
||||
$bundle_condition,
|
||||
static::addConditionFieldPrefix($target_condition, $condition_field_prefix),
|
||||
])
|
||||
: $bundle_condition;
|
||||
}
|
||||
}
|
||||
|
||||
// This condition ensures that the user is only permitted to see the
|
||||
// comments for which the user is also able to view the entity on which each
|
||||
// comment lives.
|
||||
$commented_entity_condition = new EntityConditionGroup('OR', array_values($bundle_specific_access_conditions));
|
||||
return $commented_entity_condition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an always FALSE entity condition group for the given entity type.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
|
||||
* The entity type for which to construct an impossible condition.
|
||||
*
|
||||
* @return \Drupal\jsonapi\Query\EntityConditionGroup
|
||||
* An EntityConditionGroup which cannot evaluate to TRUE.
|
||||
*/
|
||||
protected static function alwaysFalse(EntityTypeInterface $entity_type) {
|
||||
return new EntityConditionGroup('AND', [
|
||||
new EntityCondition($entity_type->getKey('id'), 1, '<'),
|
||||
new EntityCondition($entity_type->getKey('id'), 1, '>'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collects all entity query condition fields.
|
||||
*
|
||||
* Entity conditions can be nested within AND and OR groups. This recursively
|
||||
* finds all unique fields in an entity query condition.
|
||||
*
|
||||
* @param \Drupal\jsonapi\Query\EntityConditionGroup $group
|
||||
* The root entity condition group.
|
||||
* @param array $fields
|
||||
* Internal use only.
|
||||
*
|
||||
* @return array
|
||||
* An array of entity query condition field names.
|
||||
*/
|
||||
protected static function collectFilteredFields(EntityConditionGroup $group, array $fields = []) {
|
||||
foreach ($group->members() as $member) {
|
||||
if ($member instanceof EntityConditionGroup) {
|
||||
$fields = static::collectFilteredFields($member, $fields);
|
||||
}
|
||||
else {
|
||||
$fields[] = $member->field();
|
||||
}
|
||||
}
|
||||
return array_unique($fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied from \Drupal\jsonapi\IncludeResolver.
|
||||
*
|
||||
* @see \Drupal\jsonapi\IncludeResolver::buildTree()
|
||||
*/
|
||||
protected static function buildTree(array $paths) {
|
||||
$merged = [];
|
||||
foreach ($paths as $parts) {
|
||||
// This complex expression is needed to handle the string, "0", which
|
||||
// would be evaluated as FALSE.
|
||||
if (!is_null(($field_name = array_shift($parts)))) {
|
||||
$previous = isset($merged[$field_name]) ? $merged[$field_name] : [];
|
||||
$merged[$field_name] = array_merge($previous, [$parts]);
|
||||
}
|
||||
}
|
||||
return !empty($merged) ? array_map([static::class, __FUNCTION__], $merged) : $merged;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Context;
|
||||
|
||||
use Drupal\Component\Utility\NestedArray;
|
||||
use Drupal\Core\Access\AccessResult;
|
||||
use Drupal\Core\Access\AccessResultInterface;
|
||||
use Drupal\Core\Access\AccessResultReasonInterface;
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Entity\EntityFieldManagerInterface;
|
||||
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
|
||||
use Drupal\Core\Entity\EntityTypeManagerInterface;
|
||||
use Drupal\Core\Entity\FieldableEntityInterface;
|
||||
use Drupal\Core\Entity\TypedData\EntityDataDefinitionInterface;
|
||||
use Drupal\Core\Extension\ModuleHandlerInterface;
|
||||
use Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface;
|
||||
use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
|
||||
use Drupal\Core\TypedData\ComplexDataDefinitionInterface;
|
||||
use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
|
||||
use Drupal\Core\TypedData\DataReferenceTargetDefinition;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Drupal\jsonapi\ResourceType\ResourceTypeRelationship;
|
||||
use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
|
||||
use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
|
||||
|
||||
/**
|
||||
* A service that evaluates external path expressions against Drupal fields.
|
||||
*
|
||||
* This class performs 3 essential functions, path resolution, path validation
|
||||
* and path expansion.
|
||||
*
|
||||
* Path resolution:
|
||||
* Path resolution refers to the ability to map a set of external field names to
|
||||
* their internal counterparts. This is necessary because a resource type can
|
||||
* provide aliases for its field names. For example, the resource type @code
|
||||
* node--article @endcode might "alias" the internal field name @code
|
||||
* uid @endcode to the external field name @code author @endcode. This permits
|
||||
* an API consumer to request @code
|
||||
* /jsonapi/node/article?include=author @endcode for a better developer
|
||||
* experience.
|
||||
*
|
||||
* Path validation:
|
||||
* Path validation refers to the ability to ensure that a requested path
|
||||
* corresponds to a valid set of internal fields. For example, if an API
|
||||
* consumer may send a @code GET @endcode request to @code
|
||||
* /jsonapi/node/article?sort=author.field_first_name @endcode. The field
|
||||
* resolver ensures that @code uid @endcode (which would have been resolved
|
||||
* from @code author @endcode) exists on article nodes and that @code
|
||||
* field_first_name @endcode exists on user entities. However, in the case of
|
||||
* an @code include @endcode path, the field resolver would raise a client error
|
||||
* because @code field_first_name @endcode is not an entity reference field,
|
||||
* meaning it does not identify any related resources that can be included in a
|
||||
* compound document.
|
||||
*
|
||||
* Path expansion:
|
||||
* Path expansion refers to the ability to expand a path to an entity query
|
||||
* compatible field expression. For example, a request URL might have a query
|
||||
* string like @code ?filter[field_tags.name]=aviation @endcode, before
|
||||
* constructing the appropriate entity query, the entity query system needs the
|
||||
* path expression to be "expanded" into @code field_tags.entity.name @endcode.
|
||||
* In some rare cases, the entity query system needs this to be expanded to
|
||||
* @code field_tags.entity:taxonomy_term.name @endcode; the field resolver
|
||||
* simply does this by default for every path.
|
||||
*
|
||||
* *Note:* path expansion is *not* performed for @code include @endcode paths.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class FieldResolver {
|
||||
|
||||
/**
|
||||
* The entity type manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
|
||||
*/
|
||||
protected $entityTypeManager;
|
||||
|
||||
/**
|
||||
* The field manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
|
||||
*/
|
||||
protected $fieldManager;
|
||||
|
||||
/**
|
||||
* The entity type bundle information service.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
|
||||
*/
|
||||
protected $entityTypeBundleInfo;
|
||||
|
||||
/**
|
||||
* The JSON:API resource type repository service.
|
||||
*
|
||||
* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
|
||||
*/
|
||||
protected $resourceTypeRepository;
|
||||
|
||||
/**
|
||||
* The module handler.
|
||||
*
|
||||
* @var \Drupal\Core\Extension\ModuleHandlerInterface
|
||||
*/
|
||||
protected $moduleHandler;
|
||||
|
||||
/**
|
||||
* Creates a FieldResolver instance.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
|
||||
* The entity type manager.
|
||||
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
|
||||
* The field manager.
|
||||
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
|
||||
* The bundle info service.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
|
||||
* The resource type repository.
|
||||
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
|
||||
* The module handler.
|
||||
*/
|
||||
public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, ResourceTypeRepositoryInterface $resource_type_repository, ModuleHandlerInterface $module_handler) {
|
||||
$this->entityTypeManager = $entity_type_manager;
|
||||
$this->fieldManager = $field_manager;
|
||||
$this->entityTypeBundleInfo = $entity_type_bundle_info;
|
||||
$this->resourceTypeRepository = $resource_type_repository;
|
||||
$this->moduleHandler = $module_handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and resolves an include path into its internal possibilities.
|
||||
*
|
||||
* Each resource type may define its own external names for its internal
|
||||
* field names. As a result, a single external include path may target
|
||||
* multiple internal paths.
|
||||
*
|
||||
* This can happen when an entity reference field has different allowed entity
|
||||
* types *per bundle* (as is possible with comment entities) or when
|
||||
* different resource types share an external field name but resolve to
|
||||
* different internal fields names.
|
||||
*
|
||||
* Example 1:
|
||||
* An installation may have three comment types for three different entity
|
||||
* types, two of which have a file field and one of which does not. In that
|
||||
* case, a path like @code field_comments.entity_id.media @endcode might be
|
||||
* resolved to both @code field_comments.entity_id.field_audio @endcode
|
||||
* and @code field_comments.entity_id.field_image @endcode.
|
||||
*
|
||||
* Example 2:
|
||||
* A path of @code field_author_profile.account @endcode might
|
||||
* resolve to @code field_author_profile.uid @endcode and @code
|
||||
* field_author_profile.field_user @endcode if @code
|
||||
* field_author_profile @endcode can relate to two different JSON:API resource
|
||||
* types (like `node--profile` and `node--migrated_profile`) which have the
|
||||
* external field name @code account @endcode aliased to different internal
|
||||
* field names.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The resource type for which the path should be validated.
|
||||
* @param string[] $path_parts
|
||||
* The include path as an array of strings. For example, the include query
|
||||
* parameter string of @code field_tags.uid @endcode should be given
|
||||
* as @code ['field_tags', 'uid'] @endcode.
|
||||
* @param int $depth
|
||||
* (internal) Used to track recursion depth in order to generate better
|
||||
* exception messages.
|
||||
*
|
||||
* @return string[]
|
||||
* The resolved internal include paths.
|
||||
*
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
|
||||
* Thrown if the path contains invalid specifiers.
|
||||
*/
|
||||
public static function resolveInternalIncludePath(ResourceType $resource_type, array $path_parts, $depth = 0) {
|
||||
$cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:include']);
|
||||
if (empty($path_parts[0])) {
|
||||
throw new CacheableBadRequestHttpException($cacheability, 'Empty include path.');
|
||||
}
|
||||
$public_field_name = $path_parts[0];
|
||||
$internal_field_name = $resource_type->getInternalName($public_field_name);
|
||||
$relatable_resource_types = $resource_type->getRelatableResourceTypesByField($public_field_name);
|
||||
if (empty($relatable_resource_types)) {
|
||||
$message = "`$public_field_name` is not a valid relationship field name.";
|
||||
if (!empty(($possible = implode(', ', array_keys($resource_type->getRelatableResourceTypes()))))) {
|
||||
$message .= " Possible values: $possible.";
|
||||
}
|
||||
throw new CacheableBadRequestHttpException($cacheability, $message);
|
||||
}
|
||||
$remaining_parts = array_slice($path_parts, 1);
|
||||
if (empty($remaining_parts)) {
|
||||
return [[$internal_field_name]];
|
||||
}
|
||||
$exceptions = [];
|
||||
$resolved = [];
|
||||
foreach ($relatable_resource_types as $relatable_resource_type) {
|
||||
try {
|
||||
// Each resource type may resolve the path differently and may return
|
||||
// multiple possible resolutions.
|
||||
$resolved = array_merge($resolved, static::resolveInternalIncludePath($relatable_resource_type, $remaining_parts, $depth + 1));
|
||||
}
|
||||
catch (CacheableBadRequestHttpException $e) {
|
||||
$exceptions[] = $e;
|
||||
}
|
||||
}
|
||||
if (!empty($exceptions) && count($exceptions) === count($relatable_resource_types)) {
|
||||
$previous_messages = implode(' ', array_unique(array_map(function (CacheableBadRequestHttpException $e) {
|
||||
return $e->getMessage();
|
||||
}, $exceptions)));
|
||||
// Only add the full include path on the first level of recursion so that
|
||||
// the invalid path phrase isn't repeated at every level.
|
||||
throw new CacheableBadRequestHttpException($cacheability, $depth === 0
|
||||
? sprintf("`%s` is not a valid include path. $previous_messages", implode('.', $path_parts))
|
||||
: $previous_messages
|
||||
);
|
||||
}
|
||||
// Remove duplicates by converting to strings and then using array_unique().
|
||||
$resolved_as_strings = array_map(function ($possibility) {
|
||||
return implode('.', $possibility);
|
||||
}, $resolved);
|
||||
$resolved_as_strings = array_unique($resolved_as_strings);
|
||||
|
||||
// The resolved internal paths do not include the current field name because
|
||||
// resolution happens in a recursive process. Convert back from strings.
|
||||
return array_map(function ($possibility) use ($internal_field_name) {
|
||||
return array_merge([$internal_field_name], explode('.', $possibility));
|
||||
}, $resolved_as_strings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves external field expressions into entity query compatible paths.
|
||||
*
|
||||
* It is often required to reference data which may exist across a
|
||||
* relationship. For example, you may want to sort a list of articles by
|
||||
* a field on the article author's representative entity. Or you may wish
|
||||
* to filter a list of content by the name of referenced taxonomy terms.
|
||||
*
|
||||
* In an effort to simplify the referenced paths and align them with the
|
||||
* structure of JSON:API responses and the structure of the hypothetical
|
||||
* "reference document" (see link), it is possible to alias field names and
|
||||
* elide the "entity" keyword from them (this word is used by the entity query
|
||||
* system to traverse entity references).
|
||||
*
|
||||
* This method takes this external field expression and attempts to resolve
|
||||
* any aliases and/or abbreviations into a field expression that will be
|
||||
* compatible with the entity query system.
|
||||
*
|
||||
* @link http://jsonapi.org/recommendations/#urls-reference-document
|
||||
*
|
||||
* Example:
|
||||
* 'uid.field_first_name' -> 'uid.entity.field_first_name'.
|
||||
* 'author.firstName' -> 'field_author.entity.field_first_name'
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type from which to resolve the field name.
|
||||
* @param string $external_field_name
|
||||
* The public field name to map to a Drupal field name.
|
||||
*
|
||||
* @return string
|
||||
* The mapped field name.
|
||||
*
|
||||
* @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException
|
||||
*/
|
||||
public function resolveInternalEntityQueryPath($resource_type, $external_field_name) {
|
||||
$function_args = func_get_args();
|
||||
// @todo Remove this conditional block in drupal:9.0.0 and add a type hint
|
||||
// to the first argument of this method.
|
||||
// @see https://www.drupal.org/project/drupal/issues/3078045
|
||||
if (count($function_args) === 3) {
|
||||
@trigger_error('Passing the entity type ID and bundle to ' . __METHOD__ . ' is deprecated in drupal:8.8.0 and will throw a fatal error in drupal:9.0.0. Pass a JSON:API resource type instead. See https://www.drupal.org/node/3078036', E_USER_DEPRECATED);
|
||||
list($entity_type_id, $bundle, $external_field_name) = $function_args;
|
||||
$resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle);
|
||||
}
|
||||
elseif (!$resource_type instanceof ResourceType) {
|
||||
throw new \InvalidArgumentException("The first argument to " . __METHOD__ . " should be an instance of \Drupal\jsonapi\ResourceType\ResourceType, " . gettype($resource_type) . " given.");
|
||||
}
|
||||
|
||||
$cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:filter', 'url.query_args:sort']);
|
||||
if (empty($external_field_name)) {
|
||||
throw new CacheableBadRequestHttpException($cacheability, 'No field name was provided for the filter.');
|
||||
}
|
||||
|
||||
// Turns 'uid.categories.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);
|
||||
$unresolved_path_parts = $parts;
|
||||
$reference_breadcrumbs = [];
|
||||
/* @var \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types */
|
||||
$resource_types = [$resource_type];
|
||||
// This complex expression is needed to handle the string, "0", which would
|
||||
// otherwise be evaluated as FALSE.
|
||||
while (!is_null(($part = array_shift($parts)))) {
|
||||
if (!$this->isMemberFilterable($part, $resource_types)) {
|
||||
throw new CacheableBadRequestHttpException($cacheability, sprintf(
|
||||
'Invalid nested filtering. The field `%s`, given in the path `%s`, does not exist.',
|
||||
$part,
|
||||
$external_field_name
|
||||
));
|
||||
}
|
||||
|
||||
$field_name = $this->getInternalName($part, $resource_types);
|
||||
|
||||
// If none of the resource types are traversable, assume that the
|
||||
// remaining path parts are targeting field deltas and/or field
|
||||
// properties.
|
||||
if (!$this->resourceTypesAreTraversable($resource_types)) {
|
||||
$reference_breadcrumbs[] = $field_name;
|
||||
return $this->constructInternalPath($reference_breadcrumbs, $parts);
|
||||
}
|
||||
|
||||
// Different resource types have different field definitions.
|
||||
$candidate_definitions = $this->getFieldItemDefinitions($resource_types, $field_name);
|
||||
assert(!empty($candidate_definitions));
|
||||
|
||||
// We have a valid field, so add it to the validated trail of path parts.
|
||||
$reference_breadcrumbs[] = $field_name;
|
||||
|
||||
// Remove resource types which do not have a candidate definition.
|
||||
$resource_types = array_filter($resource_types, function (ResourceType $resource_type) use ($candidate_definitions) {
|
||||
return isset($candidate_definitions[$resource_type->getTypeName()]);
|
||||
});
|
||||
|
||||
// Check access to execute a query for each field per resource type since
|
||||
// field definitions are bundle-specific.
|
||||
foreach ($resource_types as $resource_type) {
|
||||
$field_access = $this->getFieldAccess($resource_type, $field_name);
|
||||
$cacheability->addCacheableDependency($field_access);
|
||||
if (!$field_access->isAllowed()) {
|
||||
$message = sprintf('The current user is not authorized to filter by the `%s` field, given in the path `%s`.', $field_name, implode('.', $reference_breadcrumbs));
|
||||
if ($field_access instanceof AccessResultReasonInterface && ($reason = $field_access->getReason()) && !empty($reason)) {
|
||||
$message .= ' ' . $reason;
|
||||
}
|
||||
throw new CacheableAccessDeniedHttpException($cacheability, $message);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all of the referenceable resource types.
|
||||
$resource_types = $this->getRelatableResourceTypes($resource_types, $candidate_definitions);
|
||||
|
||||
$at_least_one_entity_reference_field = FALSE;
|
||||
$candidate_property_names = array_unique(NestedArray::mergeDeepArray(array_map(function (FieldItemDataDefinitionInterface $definition) use (&$at_least_one_entity_reference_field) {
|
||||
$property_definitions = $definition->getPropertyDefinitions();
|
||||
return array_reduce(array_keys($property_definitions), function ($property_names, $property_name) use ($property_definitions, &$at_least_one_entity_reference_field) {
|
||||
$property_definition = $property_definitions[$property_name];
|
||||
$is_data_reference_definition = $property_definition instanceof DataReferenceTargetDefinition;
|
||||
if (!$property_definition->isInternal()) {
|
||||
// Entity reference fields are special: their reference property
|
||||
// (usually `target_id`) is never exposed in the JSON:API
|
||||
// representation. Hence it must also not be exposed in 400
|
||||
// responses' error messages.
|
||||
$property_names[] = $is_data_reference_definition ? 'id' : $property_name;
|
||||
}
|
||||
if ($is_data_reference_definition) {
|
||||
$at_least_one_entity_reference_field = TRUE;
|
||||
}
|
||||
return $property_names;
|
||||
}, []);
|
||||
}, $candidate_definitions)));
|
||||
|
||||
// Determine if the specified field has one property or many in its
|
||||
// JSON:API representation, or if it is an relationship (an entity
|
||||
// reference field), in which case the `id` of the related resource must
|
||||
// always be specified.
|
||||
$property_specifier_needed = $at_least_one_entity_reference_field || count($candidate_property_names) > 1;
|
||||
|
||||
// If there are no remaining path parts, the process is finished unless
|
||||
// the field has multiple properties, in which case one must be specified.
|
||||
if (empty($parts)) {
|
||||
if ($property_specifier_needed) {
|
||||
$possible_specifiers = array_map(function ($specifier) use ($at_least_one_entity_reference_field) {
|
||||
return $at_least_one_entity_reference_field && $specifier !== 'id' ? "meta.$specifier" : $specifier;
|
||||
}, $candidate_property_names);
|
||||
throw new CacheableBadRequestHttpException($cacheability, sprintf('Invalid nested filtering. The field `%s`, given in the path `%s` is incomplete, it must end with one of the following specifiers: `%s`.', $part, $external_field_name, implode('`, `', $possible_specifiers)));
|
||||
}
|
||||
return $this->constructInternalPath($reference_breadcrumbs);
|
||||
}
|
||||
|
||||
// If the next part is a delta, as in "body.0.value", then we add it to
|
||||
// the breadcrumbs and remove it from the parts that still must be
|
||||
// processed.
|
||||
if (static::isDelta($parts[0])) {
|
||||
$reference_breadcrumbs[] = array_shift($parts);
|
||||
}
|
||||
|
||||
// If there are no remaining path parts, the process is finished.
|
||||
if (empty($parts)) {
|
||||
return $this->constructInternalPath($reference_breadcrumbs);
|
||||
}
|
||||
|
||||
// JSON:API outputs entity reference field properties under a meta object
|
||||
// on a relationship. If the filter specifies one of these properties, it
|
||||
// must prefix the property name with `meta`. The only exception is if the
|
||||
// next path part is the same as the name for the reference property
|
||||
// (typically `entity`), this is permitted to disambiguate the case of a
|
||||
// field name on the target entity which is the same a property name on
|
||||
// the entity reference field.
|
||||
if ($at_least_one_entity_reference_field && $parts[0] !== 'id') {
|
||||
if ($parts[0] === 'meta') {
|
||||
array_shift($parts);
|
||||
}
|
||||
elseif (in_array($parts[0], $candidate_property_names) && !static::isCandidateDefinitionReferenceProperty($parts[0], $candidate_definitions)) {
|
||||
throw new CacheableBadRequestHttpException($cacheability, sprintf('Invalid nested filtering. The property `%s`, given in the path `%s` belongs to the meta object of a relationship and must be preceded by `meta`.', $parts[0], $external_field_name));
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if the next part is not a property of $field_name.
|
||||
if (!static::isCandidateDefinitionProperty($parts[0], $candidate_definitions) && !empty(static::getAllDataReferencePropertyNames($candidate_definitions))) {
|
||||
// The next path part is neither a delta nor a field property, so it
|
||||
// must be a field on a targeted resource type. We need to guess the
|
||||
// intermediate reference property since one was not provided.
|
||||
//
|
||||
// For example, the path `uid.name` for a `node--article` resource type
|
||||
// will be resolved into `uid.entity.name`.
|
||||
$reference_breadcrumbs[] = static::getDataReferencePropertyName($candidate_definitions, $parts, $unresolved_path_parts);
|
||||
}
|
||||
else {
|
||||
// If the property is not a reference property, then all
|
||||
// remaining parts must be further property specifiers.
|
||||
if (!static::isCandidateDefinitionReferenceProperty($parts[0], $candidate_definitions)) {
|
||||
// If a field property is specified on a field with only one property
|
||||
// defined, throw an error because in the JSON:API output, it does not
|
||||
// exist. This is because JSON:API elides single-value properties;
|
||||
// respecting it would leak this Drupalism out.
|
||||
if (count($candidate_property_names) === 1) {
|
||||
throw new CacheableBadRequestHttpException($cacheability, sprintf('Invalid nested filtering. The property `%s`, given in the path `%s`, does not exist. Filter by `%s`, not `%s` (the JSON:API module elides property names from single-property fields).', $parts[0], $external_field_name, substr($external_field_name, 0, strlen($external_field_name) - strlen($parts[0]) - 1), $external_field_name));
|
||||
}
|
||||
elseif (!in_array($parts[0], $candidate_property_names, TRUE)) {
|
||||
throw new CacheableBadRequestHttpException($cacheability, sprintf('Invalid nested filtering. The property `%s`, given in the path `%s`, does not exist. Must be one of the following property names: `%s`.', $parts[0], $external_field_name, implode('`, `', $candidate_property_names)));
|
||||
}
|
||||
return $this->constructInternalPath($reference_breadcrumbs, $parts);
|
||||
}
|
||||
// The property is a reference, so add it to the breadcrumbs and
|
||||
// continue resolving fields.
|
||||
$reference_breadcrumbs[] = array_shift($parts);
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct the full path to the final reference field.
|
||||
return $this->constructInternalPath($reference_breadcrumbs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands the internal path with the "entity" keyword.
|
||||
*
|
||||
* @param string[] $references
|
||||
* The resolved internal field names of all entity references.
|
||||
* @param string[] $property_path
|
||||
* (optional) A sub-property path for the last field in the path.
|
||||
*
|
||||
* @return string
|
||||
* The expanded and imploded path.
|
||||
*/
|
||||
protected function constructInternalPath(array $references, array $property_path = []) {
|
||||
// Reconstruct the path parts that are referencing sub-properties.
|
||||
$field_path = implode('.', $property_path);
|
||||
|
||||
// This rebuilds the path from the real, internal field names that have
|
||||
// been traversed so far. It joins them with the "entity" keyword as
|
||||
// required by the entity query system.
|
||||
$entity_path = implode('.', $references);
|
||||
|
||||
// Reconstruct the full path to the final reference field.
|
||||
return (empty($field_path)) ? $entity_path : $entity_path . '.' . $field_path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all item definitions from a set of resources types by a field name.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
|
||||
* The resource types on which the field might exist.
|
||||
* @param string $field_name
|
||||
* The field for which to retrieve field item definitions.
|
||||
*
|
||||
* @return \Drupal\Core\TypedData\ComplexDataDefinitionInterface[]
|
||||
* The found field item definitions.
|
||||
*/
|
||||
protected function getFieldItemDefinitions(array $resource_types, $field_name) {
|
||||
return array_reduce($resource_types, function ($result, ResourceType $resource_type) use ($field_name) {
|
||||
/* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
|
||||
$entity_type = $resource_type->getEntityTypeId();
|
||||
$bundle = $resource_type->getBundle();
|
||||
$definitions = $this->fieldManager->getFieldDefinitions($entity_type, $bundle);
|
||||
if (isset($definitions[$field_name])) {
|
||||
$result[$resource_type->getTypeName()] = $definitions[$field_name]->getItemDefinition();
|
||||
}
|
||||
return $result;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the UUID field name for a resource type.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The resource type for which to get the UUID field name.
|
||||
*
|
||||
* @return string
|
||||
* The resolved internal name.
|
||||
*/
|
||||
protected function getIdFieldName(ResourceType $resource_type) {
|
||||
$entity_type = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId());
|
||||
return $entity_type->getKey('uuid');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the internal field name based on a collection of resource types.
|
||||
*
|
||||
* @param string $field_name
|
||||
* The external field name.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
|
||||
* The resource types from which to get an internal name.
|
||||
*
|
||||
* @return string
|
||||
* The resolved internal name.
|
||||
*/
|
||||
protected function getInternalName($field_name, array $resource_types) {
|
||||
return array_reduce($resource_types, function ($carry, ResourceType $resource_type) use ($field_name) {
|
||||
if ($carry != $field_name) {
|
||||
// We already found the internal name.
|
||||
return $carry;
|
||||
}
|
||||
return $field_name === 'id' ? $this->getIdFieldName($resource_type) : $resource_type->getInternalName($field_name);
|
||||
}, $field_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the given field or member name is filterable.
|
||||
*
|
||||
* @param string $external_name
|
||||
* The external field or member name.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
|
||||
* The resource types to test.
|
||||
*
|
||||
* @return bool
|
||||
* Whether the given field is present as a filterable member of the targeted
|
||||
* resource objects.
|
||||
*/
|
||||
protected function isMemberFilterable($external_name, array $resource_types) {
|
||||
return array_reduce($resource_types, function ($carry, ResourceType $resource_type) use ($external_name) {
|
||||
// @todo: remove the next line and uncomment the following one in https://www.drupal.org/project/jsonapi/issues/3017047.
|
||||
return $carry ?: $external_name === 'id' || $resource_type->isFieldEnabled($resource_type->getInternalName($external_name));
|
||||
/*return $carry ?: in_array($external_name, ['id', 'type']) || $resource_type->isFieldEnabled($resource_type->getInternalName($external_name));*/
|
||||
}, FALSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the referenceable ResourceTypes for a set of field definitions.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
|
||||
* The resource types on which the reference field might exist.
|
||||
* @param \Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface[] $definitions
|
||||
* The field item definitions of targeted fields, keyed by the resource
|
||||
* type name on which they reside.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceType[]
|
||||
* The referenceable target resource types.
|
||||
*/
|
||||
protected function getRelatableResourceTypes(array $resource_types, array $definitions) {
|
||||
$relatable_resource_types = [];
|
||||
foreach ($resource_types as $resource_type) {
|
||||
$definition = $definitions[$resource_type->getTypeName()];
|
||||
$resource_type_field = $resource_type->getFieldByInternalName($definition->getFieldDefinition()->getName());
|
||||
if ($resource_type_field instanceof ResourceTypeRelationship) {
|
||||
foreach ($resource_type_field->getRelatableResourceTypes() as $relatable_resource_type) {
|
||||
$relatable_resource_types[$relatable_resource_type->getTypeName()] = $relatable_resource_type;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $relatable_resource_types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given resources can be traversed to other resources.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
|
||||
* The resources types to evaluate.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if any one of the given resource types is traversable.
|
||||
*
|
||||
* @todo This class shouldn't be aware of entity types and their definitions.
|
||||
* Whether a resource can have relationships to other resources is information
|
||||
* we ought to be able to discover on the ResourceType. However, we cannot
|
||||
* reliably determine this information with existing APIs. Entities may be
|
||||
* backed by various storages that are unable to perform queries across
|
||||
* references and certain storages may not be able to store references at all.
|
||||
*/
|
||||
protected function resourceTypesAreTraversable(array $resource_types) {
|
||||
foreach ($resource_types as $resource_type) {
|
||||
$entity_type_definition = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId());
|
||||
if ($entity_type_definition->entityClassImplements(FieldableEntityInterface::class)) {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all unique reference property names from the given field definitions.
|
||||
*
|
||||
* @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
|
||||
* A list of targeted field item definitions specified by the path.
|
||||
*
|
||||
* @return string[]
|
||||
* The reference property names, if any.
|
||||
*/
|
||||
protected static function getAllDataReferencePropertyNames(array $candidate_definitions) {
|
||||
$reference_property_names = array_reduce($candidate_definitions, function (array $reference_property_names, ComplexDataDefinitionInterface $definition) {
|
||||
$property_definitions = $definition->getPropertyDefinitions();
|
||||
foreach ($property_definitions as $property_name => $property_definition) {
|
||||
if ($property_definition instanceof DataReferenceDefinitionInterface) {
|
||||
$target_definition = $property_definition->getTargetDefinition();
|
||||
assert($target_definition instanceof EntityDataDefinitionInterface, 'Entity reference fields should only be able to reference entities.');
|
||||
$reference_property_names[] = $property_name . ':' . $target_definition->getEntityTypeId();
|
||||
}
|
||||
}
|
||||
return $reference_property_names;
|
||||
}, []);
|
||||
return array_unique($reference_property_names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the reference property name for the remaining unresolved parts.
|
||||
*
|
||||
* @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
|
||||
* A list of targeted field item definitions specified by the path.
|
||||
* @param string[] $remaining_parts
|
||||
* The remaining path parts.
|
||||
* @param string[] $unresolved_path_parts
|
||||
* The unresolved path parts.
|
||||
*
|
||||
* @return string
|
||||
* The reference name.
|
||||
*/
|
||||
protected static function getDataReferencePropertyName(array $candidate_definitions, array $remaining_parts, array $unresolved_path_parts) {
|
||||
$unique_reference_names = static::getAllDataReferencePropertyNames($candidate_definitions);
|
||||
if (count($unique_reference_names) > 1) {
|
||||
$choices = array_map(function ($reference_name) use ($unresolved_path_parts, $remaining_parts) {
|
||||
$prior_parts = array_slice($unresolved_path_parts, 0, count($unresolved_path_parts) - count($remaining_parts));
|
||||
return implode('.', array_merge($prior_parts, [$reference_name], $remaining_parts));
|
||||
}, $unique_reference_names);
|
||||
// @todo Add test coverage for this in https://www.drupal.org/project/jsonapi/issues/2971281
|
||||
$message = sprintf('Ambiguous path. Try one of the following: %s, in place of the given path: %s', implode(', ', $choices), implode('.', $unresolved_path_parts));
|
||||
$cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:filter', 'url.query_args:sort']);
|
||||
throw new CacheableBadRequestHttpException($cacheability, $message);
|
||||
}
|
||||
return $unique_reference_names[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a path part targets a specific field delta.
|
||||
*
|
||||
* @param string $part
|
||||
* The path part.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the part is an integer, FALSE otherwise.
|
||||
*/
|
||||
protected static function isDelta($part) {
|
||||
return (bool) preg_match('/^[0-9]+$/', $part);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a path part targets a field property, not a subsequent field.
|
||||
*
|
||||
* @param string $part
|
||||
* The path part.
|
||||
* @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
|
||||
* A list of targeted field item definitions which are specified by the
|
||||
* path.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the part is a property of one of the candidate definitions, FALSE
|
||||
* otherwise.
|
||||
*/
|
||||
protected static function isCandidateDefinitionProperty($part, array $candidate_definitions) {
|
||||
$part = static::getPathPartPropertyName($part);
|
||||
foreach ($candidate_definitions as $definition) {
|
||||
if ($definition->getPropertyDefinition($part)) {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a path part targets a reference property.
|
||||
*
|
||||
* @param string $part
|
||||
* The path part.
|
||||
* @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
|
||||
* A list of targeted field item definitions which are specified by the
|
||||
* path.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the part is a property of one of the candidate definitions, FALSE
|
||||
* otherwise.
|
||||
*/
|
||||
protected static function isCandidateDefinitionReferenceProperty($part, array $candidate_definitions) {
|
||||
$part = static::getPathPartPropertyName($part);
|
||||
foreach ($candidate_definitions as $definition) {
|
||||
$property = $definition->getPropertyDefinition($part);
|
||||
if ($property && $property instanceof DataReferenceDefinitionInterface) {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the property name from an entity typed or untyped path part.
|
||||
*
|
||||
* A path part may contain an entity type specifier like `entity:node`. This
|
||||
* extracts the actual property name. If an entity type is not specified, then
|
||||
* the path part is simply returned. For example, both `foo` and `foo:bar`
|
||||
* will return `foo`.
|
||||
*
|
||||
* @param string $part
|
||||
* A path part.
|
||||
*
|
||||
* @return string
|
||||
* The property name from a path part.
|
||||
*/
|
||||
protected static function getPathPartPropertyName($part) {
|
||||
return strpos($part, ':') !== FALSE ? explode(':', $part)[0] : $part;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the field access result for the 'view' operation.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type on which the field exists.
|
||||
* @param string $internal_field_name
|
||||
* The field name for which access should be checked.
|
||||
*
|
||||
* @return \Drupal\Core\Access\AccessResultInterface
|
||||
* The 'view' access result.
|
||||
*/
|
||||
protected function getFieldAccess(ResourceType $resource_type, $internal_field_name) {
|
||||
$definitions = $this->fieldManager->getFieldDefinitions($resource_type->getEntityTypeId(), $resource_type->getBundle());
|
||||
assert(isset($definitions[$internal_field_name]), 'The field name should have already been validated.');
|
||||
$field_definition = $definitions[$internal_field_name];
|
||||
$filter_access_results = $this->moduleHandler->invokeAll('jsonapi_entity_field_filter_access', [$field_definition, \Drupal::currentUser()]);
|
||||
$filter_access_result = array_reduce($filter_access_results, function (AccessResultInterface $combined_result, AccessResultInterface $result) {
|
||||
return $combined_result->orIf($result);
|
||||
}, AccessResult::neutral());
|
||||
if (!$filter_access_result->isNeutral()) {
|
||||
return $filter_access_result;
|
||||
}
|
||||
$entity_access_control_handler = $this->entityTypeManager->getAccessControlHandler($resource_type->getEntityTypeId());
|
||||
$field_access = $entity_access_control_handler->fieldAccess('view', $field_definition, NULL, NULL, TRUE);
|
||||
return $filter_access_result->orIf($field_access);
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Controller;
|
||||
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Controller\ControllerBase;
|
||||
use Drupal\Core\Session\AccountInterface;
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
|
||||
use Drupal\jsonapi\JsonApiResource\LinkCollection;
|
||||
use Drupal\jsonapi\JsonApiResource\NullIncludedData;
|
||||
use Drupal\jsonapi\JsonApiResource\Link;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceObjectData;
|
||||
use Drupal\jsonapi\ResourceResponse;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
use Symfony\Component\Routing\Exception\RouteNotFoundException;
|
||||
|
||||
/**
|
||||
* Controller for the API entry point.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class EntryPoint extends ControllerBase {
|
||||
|
||||
/**
|
||||
* The JSON:API resource type repository.
|
||||
*
|
||||
* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
|
||||
*/
|
||||
protected $resourceTypeRepository;
|
||||
|
||||
/**
|
||||
* The account object.
|
||||
*
|
||||
* @var \Drupal\Core\Session\AccountInterface
|
||||
*/
|
||||
protected $user;
|
||||
|
||||
/**
|
||||
* EntryPoint constructor.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
|
||||
* The resource type repository.
|
||||
* @param \Drupal\Core\Session\AccountInterface $user
|
||||
* The current user.
|
||||
*/
|
||||
public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, AccountInterface $user) {
|
||||
$this->resourceTypeRepository = $resource_type_repository;
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function create(ContainerInterface $container) {
|
||||
return new static(
|
||||
$container->get('jsonapi.resource_type.repository'),
|
||||
$container->get('current_user')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller to list all the resources.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceResponse
|
||||
* The response object.
|
||||
*/
|
||||
public function index() {
|
||||
$cacheability = (new CacheableMetadata())
|
||||
->addCacheContexts(['user.roles:authenticated'])
|
||||
->addCacheTags(['jsonapi_resource_types']);
|
||||
|
||||
// Only build URLs for exposed resources.
|
||||
$resources = array_filter($this->resourceTypeRepository->all(), function ($resource) {
|
||||
return !$resource->isInternal();
|
||||
});
|
||||
|
||||
$self_link = new Link(new CacheableMetadata(), Url::fromRoute('jsonapi.resource_list'), 'self');
|
||||
$urls = array_reduce($resources, function (LinkCollection $carry, ResourceType $resource_type) {
|
||||
if ($resource_type->isLocatable() || $resource_type->isMutable()) {
|
||||
$route_suffix = $resource_type->isLocatable() ? 'collection' : 'collection.post';
|
||||
$url = Url::fromRoute(sprintf('jsonapi.%s.%s', $resource_type->getTypeName(), $route_suffix))->setAbsolute();
|
||||
// Using a resource type name in place of a link relation type is not
|
||||
// technically valid. However, since it matches the link key, it will
|
||||
// not actually be serialized since the rel is omitted if it matches the
|
||||
// link key; because of that no client can rely on it. Once an extension
|
||||
// relation type is implemented for links to a collection, that should
|
||||
// be used instead. Unfortunately, the `collection` link relation type
|
||||
// would not be semantically correct since it would imply that the
|
||||
// entrypoint is a *member* of the link target.
|
||||
// @todo: implement an extension relation type to signal that this is a primary collection resource.
|
||||
$link_relation_type = $resource_type->getTypeName();
|
||||
return $carry->withLink($resource_type->getTypeName(), new Link(new CacheableMetadata(), $url, $link_relation_type));
|
||||
}
|
||||
return $carry;
|
||||
}, new LinkCollection(['self' => $self_link]));
|
||||
|
||||
$meta = [];
|
||||
if ($this->user->isAuthenticated()) {
|
||||
$current_user_uuid = $this->entityTypeManager()->getStorage('user')->load($this->user->id())->uuid();
|
||||
$meta['links']['me'] = ['meta' => ['id' => $current_user_uuid]];
|
||||
$cacheability->addCacheContexts(['user']);
|
||||
try {
|
||||
$me_url = Url::fromRoute(
|
||||
'jsonapi.user--user.individual',
|
||||
['entity' => $current_user_uuid]
|
||||
)
|
||||
->setAbsolute()
|
||||
->toString(TRUE);
|
||||
$meta['links']['me']['href'] = $me_url->getGeneratedUrl();
|
||||
// The cacheability of the `me` URL is the cacheability of that URL
|
||||
// itself and the currently authenticated user.
|
||||
$cacheability = $cacheability->merge($me_url);
|
||||
}
|
||||
catch (RouteNotFoundException $e) {
|
||||
// Do not add the link if the route is disabled or marked as internal.
|
||||
}
|
||||
}
|
||||
|
||||
$response = new ResourceResponse(new JsonApiDocumentTopLevel(new ResourceObjectData([]), new NullIncludedData(), $urls, $meta));
|
||||
return $response->addCacheableDependency($cacheability);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Controller;
|
||||
|
||||
use Drupal\Component\Render\PlainTextOutput;
|
||||
use Drupal\Core\Access\AccessResultReasonInterface;
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Entity\EntityConstraintViolationListInterface;
|
||||
use Drupal\Core\Entity\EntityFieldManagerInterface;
|
||||
use Drupal\Core\Entity\FieldableEntityInterface;
|
||||
use Drupal\Core\Field\FieldDefinitionInterface;
|
||||
use Drupal\Core\Session\AccountInterface;
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\jsonapi\Entity\EntityValidationTrait;
|
||||
use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
|
||||
use Drupal\jsonapi\JsonApiResource\Link;
|
||||
use Drupal\jsonapi\JsonApiResource\LinkCollection;
|
||||
use Drupal\jsonapi\JsonApiResource\NullIncludedData;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceObject;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceObjectData;
|
||||
use Drupal\jsonapi\ResourceResponse;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\Validator\ConstraintViolationInterface;
|
||||
|
||||
/**
|
||||
* Handles file upload requests.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class FileUpload {
|
||||
|
||||
use EntityValidationTrait;
|
||||
|
||||
/**
|
||||
* The current user making the request.
|
||||
*
|
||||
* @var \Drupal\Core\Session\AccountInterface
|
||||
*/
|
||||
protected $currentUser;
|
||||
|
||||
/**
|
||||
* The field manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
|
||||
*/
|
||||
protected $fieldManager;
|
||||
|
||||
/**
|
||||
* The file uploader.
|
||||
*
|
||||
* @var \Drupal\jsonapi\Controller\TemporaryJsonapiFileFieldUploader
|
||||
*/
|
||||
protected $fileUploader;
|
||||
|
||||
/**
|
||||
* An HTTP kernel for making subrequests.
|
||||
*
|
||||
* @var \Symfony\Component\HttpKernel\HttpKernelInterface
|
||||
*/
|
||||
protected $httpKernel;
|
||||
|
||||
/**
|
||||
* Creates a new FileUpload instance.
|
||||
*
|
||||
* @param \Drupal\Core\Session\AccountInterface $current_user
|
||||
* The current user.
|
||||
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
|
||||
* The entity field manager.
|
||||
* @param \Drupal\jsonapi\Controller\TemporaryJsonapiFileFieldUploader $file_uploader
|
||||
* The file uploader.
|
||||
* @param \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel
|
||||
* An HTTP kernel for making subrequests.
|
||||
*/
|
||||
public function __construct(AccountInterface $current_user, EntityFieldManagerInterface $field_manager, TemporaryJsonapiFileFieldUploader $file_uploader, HttpKernelInterface $http_kernel) {
|
||||
$this->currentUser = $current_user;
|
||||
$this->fieldManager = $field_manager;
|
||||
$this->fileUploader = $file_uploader;
|
||||
$this->httpKernel = $http_kernel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles JSON:API file upload requests.
|
||||
*
|
||||
* @param \Symfony\Component\HttpFoundation\Request $request
|
||||
* The HTTP request object.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type for the current request.
|
||||
* @param string $file_field_name
|
||||
* The file field for which the file is to be uploaded.
|
||||
* @param \Drupal\Core\Entity\FieldableEntityInterface $entity
|
||||
* The entity for which the file is to be uploaded.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceResponse
|
||||
* The response object.
|
||||
*
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
|
||||
* Thrown when there are validation errors.
|
||||
* @throws \Drupal\Core\Entity\EntityStorageException
|
||||
* Thrown if the upload's target resource could not be saved.
|
||||
* @throws \Exception
|
||||
* Thrown if an exception occurs during a subrequest to fetch the newly
|
||||
* created file entity.
|
||||
*/
|
||||
public function handleFileUploadForExistingResource(Request $request, ResourceType $resource_type, $file_field_name, FieldableEntityInterface $entity) {
|
||||
$field_definition = $this->validateAndLoadFieldDefinition($resource_type->getEntityTypeId(), $resource_type->getBundle(), $file_field_name);
|
||||
|
||||
static::ensureFileUploadAccess($this->currentUser, $field_definition, $entity);
|
||||
|
||||
$filename = $this->fileUploader->validateAndParseContentDispositionHeader($request);
|
||||
$file = $this->fileUploader->handleFileUploadForField($field_definition, $filename, $this->currentUser);
|
||||
|
||||
if ($file instanceof EntityConstraintViolationListInterface) {
|
||||
$violations = $file;
|
||||
$message = "Unprocessable Entity: file validation failed.\n";
|
||||
$message .= implode("\n", array_map(function (ConstraintViolationInterface $violation) {
|
||||
return PlainTextOutput::renderFromHtml($violation->getMessage());
|
||||
}, (array) $violations->getIterator()));
|
||||
throw new UnprocessableEntityHttpException($message);
|
||||
}
|
||||
|
||||
if ($resource_type->getFieldByInternalName($file_field_name)->hasOne()) {
|
||||
$entity->{$file_field_name} = $file;
|
||||
}
|
||||
else {
|
||||
$entity->get($file_field_name)->appendItem($file);
|
||||
}
|
||||
static::validate($entity, [$file_field_name]);
|
||||
$entity->save();
|
||||
|
||||
$route_parameters = ['entity' => $entity->uuid()];
|
||||
$route_name = sprintf('jsonapi.%s.%s.related', $resource_type->getTypeName(), $file_field_name);
|
||||
$related_url = Url::fromRoute($route_name, $route_parameters)->toString(TRUE);
|
||||
$request = Request::create($related_url->getGeneratedUrl(), 'GET', [], $request->cookies->all(), [], $request->server->all());
|
||||
return $this->httpKernel->handle($request, HttpKernelInterface::SUB_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles JSON:API file upload requests.
|
||||
*
|
||||
* @param \Symfony\Component\HttpFoundation\Request $request
|
||||
* The HTTP request object.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type for the current request.
|
||||
* @param string $file_field_name
|
||||
* The file field for which the file is to be uploaded.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceResponse
|
||||
* The response object.
|
||||
*
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException
|
||||
* Thrown when there are validation errors.
|
||||
*/
|
||||
public function handleFileUploadForNewResource(Request $request, ResourceType $resource_type, $file_field_name) {
|
||||
$field_definition = $this->validateAndLoadFieldDefinition($resource_type->getEntityTypeId(), $resource_type->getBundle(), $file_field_name);
|
||||
|
||||
static::ensureFileUploadAccess($this->currentUser, $field_definition);
|
||||
|
||||
$filename = $this->fileUploader->validateAndParseContentDispositionHeader($request);
|
||||
$file = $this->fileUploader->handleFileUploadForField($field_definition, $filename, $this->currentUser);
|
||||
|
||||
if ($file instanceof EntityConstraintViolationListInterface) {
|
||||
$violations = $file;
|
||||
$message = "Unprocessable Entity: file validation failed.\n";
|
||||
$message .= implode("\n", array_map(function (ConstraintViolationInterface $violation) {
|
||||
return PlainTextOutput::renderFromHtml($violation->getMessage());
|
||||
}, iterator_to_array($violations)));
|
||||
throw new UnprocessableEntityHttpException($message);
|
||||
}
|
||||
|
||||
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
|
||||
$self_link = new Link(new CacheableMetadata(), Url::fromRoute('jsonapi.file--file.individual', ['entity' => $file->uuid()]), 'self');
|
||||
/* $self_link = new Link(new CacheableMetadata(), $this->entity->toUrl('jsonapi'), ['self']); */
|
||||
$links = new LinkCollection(['self' => $self_link]);
|
||||
|
||||
$relatable_resource_types = $resource_type->getRelatableResourceTypesByField($file_field_name);
|
||||
$file_resource_type = reset($relatable_resource_types);
|
||||
$resource_object = ResourceObject::createFromEntity($file_resource_type, $file);
|
||||
return new ResourceResponse(new JsonApiDocumentTopLevel(new ResourceObjectData([$resource_object], 1), new NullIncludedData(), $links), 201, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the given account is allowed to upload a file.
|
||||
*
|
||||
* @param \Drupal\Core\Session\AccountInterface $account
|
||||
* The account for which access should be checked.
|
||||
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
|
||||
* The field for which the file is to be uploaded.
|
||||
* @param \Drupal\Core\Entity\FieldableEntityInterface|null $entity
|
||||
* The entity, if one exists, for which the file is to be uploaded.
|
||||
*/
|
||||
protected static function ensureFileUploadAccess(AccountInterface $account, FieldDefinitionInterface $field_definition, FieldableEntityInterface $entity = NULL) {
|
||||
$access_result = $entity
|
||||
? TemporaryJsonapiFileFieldUploader::checkFileUploadAccess($account, $field_definition, $entity)
|
||||
: TemporaryJsonapiFileFieldUploader::checkFileUploadAccess($account, $field_definition);
|
||||
if (!$access_result->isAllowed()) {
|
||||
$reason = 'The current user is not permitted to upload a file for this field.';
|
||||
if ($access_result instanceof AccessResultReasonInterface) {
|
||||
$reason .= ' ' . $access_result->getReason();
|
||||
}
|
||||
throw new AccessDeniedHttpException($reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and loads a field definition instance.
|
||||
*
|
||||
* @param string $entity_type_id
|
||||
* The entity type ID the field is attached to.
|
||||
* @param string $bundle
|
||||
* The bundle the field is attached to.
|
||||
* @param string $field_name
|
||||
* The field name.
|
||||
*
|
||||
* @return \Drupal\Core\Field\FieldDefinitionInterface
|
||||
* The field definition.
|
||||
*
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
||||
* Thrown when the field does not exist.
|
||||
* @throws \Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException
|
||||
* Thrown when the target type of the field is not a file, or the current
|
||||
* user does not have 'edit' access for the field.
|
||||
*/
|
||||
protected function validateAndLoadFieldDefinition($entity_type_id, $bundle, $field_name) {
|
||||
$field_definitions = $this->fieldManager->getFieldDefinitions($entity_type_id, $bundle);
|
||||
if (!isset($field_definitions[$field_name])) {
|
||||
throw new NotFoundHttpException(sprintf('Field "%s" does not exist.', $field_name));
|
||||
}
|
||||
|
||||
/** @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition */
|
||||
$field_definition = $field_definitions[$field_name];
|
||||
if ($field_definition->getSetting('target_type') !== 'file') {
|
||||
throw new AccessDeniedException(sprintf('"%s" is not a file field', $field_name));
|
||||
}
|
||||
|
||||
return $field_definition;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Controller;
|
||||
|
||||
use Drupal\Component\Utility\Bytes;
|
||||
use Drupal\Component\Utility\Crypt;
|
||||
use Drupal\Component\Utility\Environment;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\Plugin\DataType\EntityAdapter;
|
||||
use Drupal\Core\Field\FieldDefinitionInterface;
|
||||
use Drupal\Core\File\Exception\FileException;
|
||||
use Drupal\Core\Validation\DrupalTranslator;
|
||||
use Drupal\file\FileInterface;
|
||||
use Drupal\Core\File\FileSystemInterface;
|
||||
use Drupal\Core\Config\ConfigFactoryInterface;
|
||||
use Drupal\Core\Lock\LockBackendInterface;
|
||||
use Drupal\Core\Render\BubbleableMetadata;
|
||||
use Drupal\Core\Session\AccountInterface;
|
||||
use Drupal\Core\Utility\Token;
|
||||
use Drupal\Component\Render\PlainTextOutput;
|
||||
use Drupal\file\Entity\File;
|
||||
use Drupal\file\Plugin\Field\FieldType\FileFieldItemList;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\Validator\ConstraintViolation;
|
||||
|
||||
/**
|
||||
* Reads data from an upload stream and creates a corresponding file entity.
|
||||
*
|
||||
* This is implemented at the field level for the following reasons:
|
||||
* - Validation for uploaded files is tied to fields (allowed extensions, max
|
||||
* size, etc..).
|
||||
* - The actual files do not need to be stored in another temporary location,
|
||||
* to be later moved when they are referenced from a file field.
|
||||
* - Permission to upload a file can be determined by a user's field- and
|
||||
* entity-level access.
|
||||
*
|
||||
* @internal This will be removed once https://www.drupal.org/project/drupal/issues/2940383 lands.
|
||||
*/
|
||||
class TemporaryJsonapiFileFieldUploader {
|
||||
|
||||
/**
|
||||
* The regex used to extract the filename from the content disposition header.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const REQUEST_HEADER_FILENAME_REGEX = '@\bfilename(?<star>\*?)=\"(?<filename>.+)\"@';
|
||||
|
||||
/**
|
||||
* The amount of bytes to read in each iteration when streaming file data.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const BYTES_TO_READ = 8192;
|
||||
|
||||
/**
|
||||
* A logger instance.
|
||||
*
|
||||
* @var \Psr\Log\LoggerInterface
|
||||
*/
|
||||
protected $logger;
|
||||
|
||||
/**
|
||||
* The file system service.
|
||||
*
|
||||
* @var \Drupal\Core\File\FileSystemInterface
|
||||
*/
|
||||
protected $fileSystem;
|
||||
|
||||
/**
|
||||
* The MIME type guesser.
|
||||
*
|
||||
* @var \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface
|
||||
*/
|
||||
protected $mimeTypeGuesser;
|
||||
|
||||
/**
|
||||
* The token replacement instance.
|
||||
*
|
||||
* @var \Drupal\Core\Utility\Token
|
||||
*/
|
||||
protected $token;
|
||||
|
||||
/**
|
||||
* The lock service.
|
||||
*
|
||||
* @var \Drupal\Core\Lock\LockBackendInterface
|
||||
*/
|
||||
protected $lock;
|
||||
|
||||
/**
|
||||
* System file configuration.
|
||||
*
|
||||
* @var \Drupal\Core\Config\ImmutableConfig
|
||||
*/
|
||||
protected $systemFileConfig;
|
||||
|
||||
/**
|
||||
* Constructs a FileUploadResource instance.
|
||||
*
|
||||
* @param \Psr\Log\LoggerInterface $logger
|
||||
* A logger instance.
|
||||
* @param \Drupal\Core\File\FileSystemInterface $file_system
|
||||
* The file system service.
|
||||
* @param \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface $mime_type_guesser
|
||||
* The MIME type guesser.
|
||||
* @param \Drupal\Core\Utility\Token $token
|
||||
* The token replacement instance.
|
||||
* @param \Drupal\Core\Lock\LockBackendInterface $lock
|
||||
* The lock service.
|
||||
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
|
||||
* The config factory.
|
||||
*/
|
||||
public function __construct(LoggerInterface $logger, FileSystemInterface $file_system, MimeTypeGuesserInterface $mime_type_guesser, Token $token, LockBackendInterface $lock, ConfigFactoryInterface $config_factory) {
|
||||
$this->logger = $logger;
|
||||
$this->fileSystem = $file_system;
|
||||
$this->mimeTypeGuesser = $mime_type_guesser;
|
||||
$this->token = $token;
|
||||
$this->lock = $lock;
|
||||
$this->systemFileConfig = $config_factory->get('system.file');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and validates a file entity for a file field from a file stream.
|
||||
*
|
||||
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
|
||||
* The field definition of the field for which the file is to be uploaded.
|
||||
* @param string $filename
|
||||
* The name of the file.
|
||||
* @param \Drupal\Core\Session\AccountInterface $owner
|
||||
* The owner of the file. Note, it is the responsibility of the caller to
|
||||
* enforce access.
|
||||
*
|
||||
* @return \Drupal\file\FileInterface|\Drupal\Core\Entity\EntityConstraintViolationListInterface
|
||||
* The newly uploaded file entity, or a list of validation constraint
|
||||
* violations
|
||||
*
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
|
||||
* Thrown when temporary files cannot be written, a lock cannot be acquired,
|
||||
* or when temporary files cannot be moved to their new location.
|
||||
*/
|
||||
public function handleFileUploadForField(FieldDefinitionInterface $field_definition, $filename, AccountInterface $owner) {
|
||||
assert(is_a($field_definition->getClass(), FileFieldItemList::class, TRUE));
|
||||
$destination = $this->getUploadLocation($field_definition->getSettings());
|
||||
|
||||
// Check the destination file path is writable.
|
||||
if (!$this->fileSystem->prepareDirectory($destination, FileSystemInterface::CREATE_DIRECTORY)) {
|
||||
throw new HttpException(500, 'Destination file path is not writable');
|
||||
}
|
||||
|
||||
$validators = $this->getUploadValidators($field_definition);
|
||||
|
||||
$prepared_filename = $this->prepareFilename($filename, $validators);
|
||||
|
||||
// Create the file.
|
||||
$file_uri = "{$destination}/{$prepared_filename}";
|
||||
|
||||
$temp_file_path = $this->streamUploadData();
|
||||
|
||||
$file_uri = $this->fileSystem->getDestinationFilename($file_uri, FileSystemInterface::EXISTS_RENAME);
|
||||
|
||||
// Lock based on the prepared file URI.
|
||||
$lock_id = $this->generateLockIdFromFileUri($file_uri);
|
||||
|
||||
if (!$this->lock->acquire($lock_id)) {
|
||||
throw new HttpException(503, sprintf('File "%s" is already locked for writing.'), NULL, ['Retry-After' => 1]);
|
||||
}
|
||||
|
||||
// Begin building file entity.
|
||||
$file = File::create([]);
|
||||
$file->setOwnerId($owner->id());
|
||||
$file->setFilename($prepared_filename);
|
||||
$file->setMimeType($this->mimeTypeGuesser->guess($prepared_filename));
|
||||
$file->setFileUri($file_uri);
|
||||
// Set the size. This is done in File::preSave() but we validate the file
|
||||
// before it is saved.
|
||||
$file->setSize(@filesize($temp_file_path));
|
||||
|
||||
// Validate the file entity against entity-level validation and field-level
|
||||
// validators.
|
||||
$violations = $this->validate($file, $validators);
|
||||
if ($violations->count() > 0) {
|
||||
return $violations;
|
||||
}
|
||||
|
||||
// Move the file to the correct location after validation. Use
|
||||
// FileSystemInterface::EXISTS_ERROR as the file location has already been
|
||||
// determined above in FileSystem::getDestinationFilename().
|
||||
try {
|
||||
$this->fileSystem->move($temp_file_path, $file_uri, FileSystemInterface::EXISTS_ERROR);
|
||||
}
|
||||
catch (FileException $e) {
|
||||
throw new HttpException(500, 'Temporary file could not be moved to file location');
|
||||
}
|
||||
|
||||
$file->save();
|
||||
|
||||
$this->lock->release($lock_id);
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and extracts the filename from the Content-Disposition header.
|
||||
*
|
||||
* @param \Symfony\Component\HttpFoundation\Request $request
|
||||
* The request object.
|
||||
*
|
||||
* @return string
|
||||
* The filename extracted from the header.
|
||||
*
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
|
||||
* Thrown when the 'Content-Disposition' request header is invalid.
|
||||
*/
|
||||
public function validateAndParseContentDispositionHeader(Request $request) {
|
||||
// First, check the header exists.
|
||||
if (!$request->headers->has('content-disposition')) {
|
||||
throw new BadRequestHttpException('"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided.');
|
||||
}
|
||||
|
||||
$content_disposition = $request->headers->get('content-disposition');
|
||||
|
||||
// Parse the header value. This regex does not allow an empty filename.
|
||||
// i.e. 'filename=""'. This also matches on a word boundary so other keys
|
||||
// like 'not_a_filename' don't work.
|
||||
if (!preg_match(static::REQUEST_HEADER_FILENAME_REGEX, $content_disposition, $matches)) {
|
||||
throw new BadRequestHttpException('No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided.');
|
||||
}
|
||||
|
||||
// Check for the "filename*" format. This is currently unsupported.
|
||||
if (!empty($matches['star'])) {
|
||||
throw new BadRequestHttpException('The extended "filename*" format is currently not supported in the "Content-Disposition" header.');
|
||||
}
|
||||
|
||||
// Don't validate the actual filename here, that will be done by the upload
|
||||
// validators in validate().
|
||||
// @see \Drupal\file\Plugin\rest\resource\FileUploadResource::validate()
|
||||
$filename = $matches['filename'];
|
||||
|
||||
// Make sure only the filename component is returned. Path information is
|
||||
// stripped as per https://tools.ietf.org/html/rfc6266#section-4.3.
|
||||
return $this->fileSystem->basename($filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current user has access to upload the file.
|
||||
*
|
||||
* @param \Drupal\Core\Session\AccountInterface $account
|
||||
* The account for which file upload access should be checked.
|
||||
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
|
||||
* The field definition for which to get validators.
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* (optional) The entity to which the file is to be uploaded, if it exists.
|
||||
* If the entity does not exist and it is not given, create access to the
|
||||
* file will be checked.
|
||||
*
|
||||
* @return \Drupal\Core\Access\AccessResultInterface
|
||||
* The file upload access result.
|
||||
*/
|
||||
public static function checkFileUploadAccess(AccountInterface $account, FieldDefinitionInterface $field_definition, EntityInterface $entity = NULL) {
|
||||
assert(is_null($entity) || $field_definition->getTargetEntityTypeId() === $entity->getEntityTypeId() && $field_definition->getTargetBundle() === $entity->bundle());
|
||||
$entity_type_manager = \Drupal::entityTypeManager();
|
||||
$entity_access_control_handler = $entity_type_manager->getAccessControlHandler($field_definition->getTargetEntityTypeId());
|
||||
$bundle = $entity_type_manager->getDefinition($field_definition->getTargetEntityTypeId())->hasKey('bundle') ? $field_definition->getTargetBundle() : NULL;
|
||||
$entity_access_result = $entity
|
||||
? $entity_access_control_handler->access($entity, 'update', $account, TRUE)
|
||||
: $entity_access_control_handler->createAccess($bundle, $account, [], TRUE);
|
||||
$field_access_result = $entity_access_control_handler->fieldAccess('edit', $field_definition, NULL, NULL, TRUE);
|
||||
return $entity_access_result->andIf($field_access_result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams file upload data to temporary file and moves to file destination.
|
||||
*
|
||||
* @return string
|
||||
* The temp file path.
|
||||
*
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
|
||||
* Thrown when input data cannot be read, the temporary file cannot be
|
||||
* opened, or the temporary file cannot be written.
|
||||
*/
|
||||
protected function streamUploadData() {
|
||||
// 'rb' is needed so reading works correctly on Windows environments too.
|
||||
$file_data = fopen('php://input', 'rb');
|
||||
|
||||
$temp_file_path = $this->fileSystem->tempnam('temporary://', 'file');
|
||||
if ($temp_file_path === FALSE) {
|
||||
$this->logger->error('Temporary file could not be created for file upload.');
|
||||
throw new HttpException(500, 'Temporary file could not be created');
|
||||
}
|
||||
$temp_file = fopen($temp_file_path, 'wb');
|
||||
|
||||
if ($temp_file) {
|
||||
while (!feof($file_data)) {
|
||||
$read = fread($file_data, static::BYTES_TO_READ);
|
||||
|
||||
if ($read === FALSE) {
|
||||
// Close the file streams.
|
||||
fclose($temp_file);
|
||||
fclose($file_data);
|
||||
$this->logger->error('Input data could not be read');
|
||||
throw new HttpException(500, 'Input file data could not be read.');
|
||||
}
|
||||
|
||||
if (fwrite($temp_file, $read) === FALSE) {
|
||||
// Close the file streams.
|
||||
fclose($temp_file);
|
||||
fclose($file_data);
|
||||
$this->logger->error('Temporary file data for "%path" could not be written', ['%path' => $temp_file_path]);
|
||||
throw new HttpException(500, 'Temporary file data could not be written.');
|
||||
}
|
||||
}
|
||||
|
||||
// Close the temp file stream.
|
||||
fclose($temp_file);
|
||||
}
|
||||
else {
|
||||
// Close the input file stream since we can't proceed with the upload.
|
||||
// Don't try to close $temp_file since it's FALSE at this point.
|
||||
fclose($file_data);
|
||||
$this->logger->error('Temporary file "%path" could not be opened for file upload.', ['%path' => $temp_file_path]);
|
||||
throw new HttpException(500, 'Temporary file could not be opened');
|
||||
}
|
||||
|
||||
// Close the input stream.
|
||||
fclose($file_data);
|
||||
|
||||
return $temp_file_path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the file.
|
||||
*
|
||||
* @param \Drupal\file\FileInterface $file
|
||||
* The file entity to validate.
|
||||
* @param array $validators
|
||||
* An array of upload validators to pass to file_validate().
|
||||
*
|
||||
* @return \Drupal\Core\Entity\EntityConstraintViolationListInterface
|
||||
* The list of constraint violations, if any.
|
||||
*/
|
||||
protected function validate(FileInterface $file, array $validators) {
|
||||
$violations = $file->validate();
|
||||
|
||||
// Remove violations of inaccessible fields as they cannot stem from our
|
||||
// changes.
|
||||
$violations->filterByFieldAccess();
|
||||
|
||||
// Validate the file based on the field definition configuration.
|
||||
$errors = file_validate($file, $validators);
|
||||
if (!empty($errors)) {
|
||||
$translator = new DrupalTranslator();
|
||||
foreach ($errors as $error) {
|
||||
$violation = new ConstraintViolation($translator->trans($error),
|
||||
$error,
|
||||
[],
|
||||
EntityAdapter::createFromEntity($file),
|
||||
'',
|
||||
NULL
|
||||
);
|
||||
$violations->add($violation);
|
||||
}
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the filename to strip out any malicious extensions.
|
||||
*
|
||||
* @param string $filename
|
||||
* The file name.
|
||||
* @param array $validators
|
||||
* The array of upload validators.
|
||||
*
|
||||
* @return string
|
||||
* The prepared/munged filename.
|
||||
*/
|
||||
protected function prepareFilename($filename, array &$validators) {
|
||||
if (!empty($validators['file_validate_extensions'][0])) {
|
||||
// If there is a file_validate_extensions validator and a list of
|
||||
// valid extensions, munge the filename to protect against possible
|
||||
// malicious extension hiding within an unknown file type. For example,
|
||||
// "filename.html.foo".
|
||||
$filename = file_munge_filename($filename, $validators['file_validate_extensions'][0]);
|
||||
}
|
||||
|
||||
// Rename potentially executable files, to help prevent exploits (i.e. will
|
||||
// rename filename.php.foo and filename.php to filename.php.foo.txt and
|
||||
// filename.php.txt, respectively). Don't rename if 'allow_insecure_uploads'
|
||||
// evaluates to TRUE.
|
||||
if (!$this->systemFileConfig->get('allow_insecure_uploads') && preg_match(FILE_INSECURE_EXTENSION_REGEX, $filename) && (substr($filename, -4) != '.txt')) {
|
||||
// The destination filename will also later be used to create the URI.
|
||||
$filename .= '.txt';
|
||||
|
||||
// The .txt extension may not be in the allowed list of extensions. We
|
||||
// have to add it here or else the file upload will fail.
|
||||
if (!empty($validators['file_validate_extensions'][0])) {
|
||||
$validators['file_validate_extensions'][0] .= ' txt';
|
||||
}
|
||||
}
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the URI for a file field.
|
||||
*
|
||||
* @param array $settings
|
||||
* The array of field settings.
|
||||
*
|
||||
* @return string
|
||||
* An un-sanitized file directory URI with tokens replaced. The result of
|
||||
* the token replacement is then converted to plain text and returned.
|
||||
*/
|
||||
protected function getUploadLocation(array $settings) {
|
||||
$destination = trim($settings['file_directory'], '/');
|
||||
|
||||
// Replace tokens. As the tokens might contain HTML we convert it to plain
|
||||
// text.
|
||||
$destination = PlainTextOutput::renderFromHtml($this->token->replace($destination, [], [], new BubbleableMetadata()));
|
||||
return $settings['uri_scheme'] . '://' . $destination;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the upload validators for a field definition.
|
||||
*
|
||||
* This is copied from \Drupal\file\Plugin\Field\FieldType\FileItem as there
|
||||
* is no entity instance available here that a FileItem would exist for.
|
||||
*
|
||||
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
|
||||
* The field definition for which to get validators.
|
||||
*
|
||||
* @return array
|
||||
* An array suitable for passing to file_save_upload() or the file field
|
||||
* element's '#upload_validators' property.
|
||||
*/
|
||||
protected function getUploadValidators(FieldDefinitionInterface $field_definition) {
|
||||
$validators = [
|
||||
// Add in our check of the file name length.
|
||||
'file_validate_name_length' => [],
|
||||
];
|
||||
$settings = $field_definition->getSettings();
|
||||
|
||||
// Cap the upload size according to the PHP limit.
|
||||
$max_filesize = Bytes::toInt(Environment::getUploadMaxSize());
|
||||
if (!empty($settings['max_filesize'])) {
|
||||
$max_filesize = min($max_filesize, Bytes::toInt($settings['max_filesize']));
|
||||
}
|
||||
|
||||
// There is always a file size limit due to the PHP server limit.
|
||||
$validators['file_validate_size'] = [$max_filesize];
|
||||
|
||||
// Add the extension check if necessary.
|
||||
if (!empty($settings['file_extensions'])) {
|
||||
$validators['file_validate_extensions'] = [$settings['file_extensions']];
|
||||
}
|
||||
|
||||
return $validators;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a lock ID based on the file URI.
|
||||
*
|
||||
* @param string $file_uri
|
||||
* The file URI.
|
||||
*
|
||||
* @return string
|
||||
* The generated lock ID.
|
||||
*/
|
||||
protected static function generateLockIdFromFileUri($file_uri) {
|
||||
return 'file:jsonapi:' . Crypt::hashBase64($file_uri);
|
||||
}
|
||||
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\DependencyInjection\Compiler;
|
||||
|
||||
use Drupal\serialization\RegisterSerializationClassesCompilerPass as DrupalRegisterSerializationClassesCompilerPass;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Reference;
|
||||
|
||||
/**
|
||||
* Adds services tagged JSON:API-only normalizers to the Serializer.
|
||||
*
|
||||
* Services tagged with 'jsonapi_normalizer' will be added to the JSON:API
|
||||
* serializer. No extensions can provide such services.
|
||||
*
|
||||
* JSON:API does respect generic (non-JSON:API) DataType-level normalizers.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class RegisterSerializationClassesCompilerPass extends DrupalRegisterSerializationClassesCompilerPass {
|
||||
|
||||
/**
|
||||
* The service ID.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const OVERRIDDEN_SERVICE_ID = 'jsonapi.serializer';
|
||||
|
||||
/**
|
||||
* The service tag that only JSON:API normalizers should use.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const OVERRIDDEN_SERVICE_NORMALIZER_TAG = 'jsonapi_normalizer';
|
||||
|
||||
/**
|
||||
* The service tag that only JSON:API encoders should use.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const OVERRIDDEN_SERVICE_ENCODER_TAG = 'jsonapi_encoder';
|
||||
|
||||
/**
|
||||
* The ID for the JSON:API format.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const FORMAT = 'api_json';
|
||||
|
||||
/**
|
||||
* Adds services to the JSON:API Serializer.
|
||||
*
|
||||
* This code is copied from the class parent with two modifications. The
|
||||
* service id has been changed and the service tag has been updated.
|
||||
*
|
||||
* ID: 'serializer' -> 'jsonapi.serializer'
|
||||
* Tag: 'normalizer' -> 'jsonapi_normalizer'
|
||||
*
|
||||
* @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
|
||||
* The container to process.
|
||||
*/
|
||||
public function process(ContainerBuilder $container) {
|
||||
$definition = $container->getDefinition(static::OVERRIDDEN_SERVICE_ID);
|
||||
|
||||
// Retrieve registered Normalizers and Encoders from the container.
|
||||
foreach ($container->findTaggedServiceIds(static::OVERRIDDEN_SERVICE_NORMALIZER_TAG) as $id => $attributes) {
|
||||
// Normalizers are not an API: mark private.
|
||||
$container->getDefinition($id)->setPublic(FALSE);
|
||||
|
||||
$priority = isset($attributes[0]['priority']) ? $attributes[0]['priority'] : 0;
|
||||
$normalizers[$priority][] = new Reference($id);
|
||||
}
|
||||
foreach ($container->findTaggedServiceIds(static::OVERRIDDEN_SERVICE_ENCODER_TAG) as $id => $attributes) {
|
||||
// Encoders are not an API: mark private.
|
||||
$container->getDefinition($id)->setPublic(FALSE);
|
||||
|
||||
$priority = isset($attributes[0]['priority']) ? $attributes[0]['priority'] : 0;
|
||||
$encoders[$priority][] = new Reference($id);
|
||||
}
|
||||
|
||||
// Add the registered Normalizers and Encoders to the Serializer.
|
||||
if (!empty($normalizers)) {
|
||||
$definition->replaceArgument(0, $this->sort($normalizers));
|
||||
}
|
||||
if (!empty($encoders)) {
|
||||
$definition->replaceArgument(1, $this->sort($encoders));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Encoder;
|
||||
|
||||
use Drupal\serialization\Encoder\JsonEncoder as SerializationJsonEncoder;
|
||||
|
||||
/**
|
||||
* Encodes JSON:API data.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class JsonEncoder extends SerializationJsonEncoder {
|
||||
|
||||
/**
|
||||
* The formats that this Encoder supports.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected static $format = ['api_json'];
|
||||
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Entity;
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\FieldableEntityInterface;
|
||||
use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
|
||||
|
||||
/**
|
||||
* Provides a method to validate an entity.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
trait EntityValidationTrait {
|
||||
|
||||
/**
|
||||
* Verifies that an entity does not violate any validation constraints.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity object.
|
||||
* @param string[] $field_names
|
||||
* (optional) An array of field names. If specified, filters the violations
|
||||
* list to include only this set of fields. Defaults to NULL,
|
||||
* which means that all violations will be reported.
|
||||
*
|
||||
* @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
|
||||
* Thrown when violations remain after filtering.
|
||||
*
|
||||
* @see \Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait::validate()
|
||||
*/
|
||||
protected static function validate(EntityInterface $entity, array $field_names = NULL) {
|
||||
if (!$entity instanceof FieldableEntityInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
$violations = $entity->validate();
|
||||
|
||||
// Remove violations of inaccessible fields as they cannot stem from our
|
||||
// changes.
|
||||
$violations->filterByFieldAccess();
|
||||
|
||||
// Filter violations based on the given fields.
|
||||
if ($field_names !== NULL) {
|
||||
$violations->filterByFields(
|
||||
array_diff(array_keys($entity->getFieldDefinitions()), $field_names)
|
||||
);
|
||||
}
|
||||
|
||||
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).
|
||||
// @see \Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer
|
||||
$exception = new UnprocessableHttpEntityException();
|
||||
$exception->setViolations($violations);
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\EventSubscriber;
|
||||
|
||||
use Drupal\jsonapi\JsonApiResource\ErrorCollection;
|
||||
use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
|
||||
use Drupal\jsonapi\JsonApiResource\LinkCollection;
|
||||
use Drupal\jsonapi\JsonApiResource\NullIncludedData;
|
||||
use Drupal\jsonapi\ResourceResponse;
|
||||
use Drupal\jsonapi\Routing\Routes;
|
||||
use Drupal\serialization\EventSubscriber\DefaultExceptionSubscriber as SerializationDefaultExceptionSubscriber;
|
||||
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
* Serializes exceptions in compliance with the JSON:API specification.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class DefaultExceptionSubscriber extends SerializationDefaultExceptionSubscriber {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static function getPriority() {
|
||||
return parent::getPriority() + 25;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getHandledFormats() {
|
||||
return ['api_json'];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function onException(GetResponseForExceptionEvent $event) {
|
||||
if (!$this->isJsonApiExceptionEvent($event)) {
|
||||
return;
|
||||
}
|
||||
if (($exception = $event->getException()) && !$exception instanceof HttpException) {
|
||||
$exception = new HttpException(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();
|
||||
$response = new ResourceResponse(new JsonApiDocumentTopLevel(new ErrorCollection([$exception]), new NullIncludedData(), new LinkCollection([])), $exception->getStatusCode(), $exception->getHeaders());
|
||||
$response->addCacheableDependency($exception);
|
||||
$event->setResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the error should be formatted using JSON:API.
|
||||
*
|
||||
* The JSON:API format is supported if the format is explicitly set or the
|
||||
* request is for a known JSON:API route.
|
||||
*
|
||||
* @param \Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent $exception_event
|
||||
* The exception event.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if it needs to be formatted using JSON:API. FALSE otherwise.
|
||||
*/
|
||||
protected function isJsonApiExceptionEvent(GetResponseForExceptionEvent $exception_event) {
|
||||
$request = $exception_event->getRequest();
|
||||
$parameters = $request->attributes->all();
|
||||
return $request->getRequestFormat() === 'api_json' || (bool) Routes::getResourceTypeNameFromParameters($parameters);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\EventSubscriber;
|
||||
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\jsonapi\JsonApiSpec;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
|
||||
use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Request subscriber that validates a JSON:API request.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class JsonApiRequestValidator implements EventSubscriberInterface {
|
||||
|
||||
/**
|
||||
* Validates JSON:API requests.
|
||||
*
|
||||
* @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
|
||||
* The event to process.
|
||||
*/
|
||||
public function onRequest(GetResponseEvent $event) {
|
||||
$request = $event->getRequest();
|
||||
if ($request->getRequestFormat() !== 'api_json') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validateQueryParams($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates custom (implementation-specific) query parameter names.
|
||||
*
|
||||
* @param \Symfony\Component\HttpFoundation\Request $request
|
||||
* The request for which to validate JSON:API query parameters.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceResponse|null
|
||||
* A JSON:API resource response.
|
||||
*
|
||||
* @see http://jsonapi.org/format/#query-parameters
|
||||
*/
|
||||
protected function validateQueryParams(Request $request) {
|
||||
$invalid_query_params = [];
|
||||
foreach (array_keys($request->query->all()) as $query_parameter_name) {
|
||||
// Ignore reserved (official) query parameters.
|
||||
if (in_array($query_parameter_name, JsonApiSpec::getReservedQueryParameters())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!JsonApiSpec::isValidCustomQueryParameter($query_parameter_name)) {
|
||||
$invalid_query_params[] = $query_parameter_name;
|
||||
}
|
||||
}
|
||||
|
||||
// Drupal uses the `_format` query parameter for Content-Type negotiation.
|
||||
// Using it violates the JSON:API spec. Nudge people nicely in the correct
|
||||
// direction. (This is special cased because using it is pretty common.)
|
||||
if (in_array('_format', $invalid_query_params, TRUE)) {
|
||||
$uri_without_query_string = $request->getSchemeAndHttpHost() . $request->getBaseUrl() . $request->getPathInfo();
|
||||
$exception = new CacheableBadRequestHttpException((new CacheableMetadata())->addCacheContexts(['url.query_args:_format']), 'JSON:API does not need that ugly \'_format\' query string! 🤘 Use the URL provided in \'links\' 🙏');
|
||||
$exception->setHeaders(['Link' => $uri_without_query_string]);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if (empty($invalid_query_params)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
$message = sprintf('The following query parameters violate the JSON:API spec: \'%s\'.', implode("', '", $invalid_query_params));
|
||||
$exception = new CacheableBadRequestHttpException((new CacheableMetadata())->addCacheContexts(['url.query_args']), $message);
|
||||
$exception->setHeaders(['Link' => 'http://jsonapi.org/format/#query-parameters']);
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function getSubscribedEvents() {
|
||||
$events[KernelEvents::REQUEST][] = ['onRequest'];
|
||||
return $events;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\EventSubscriber;
|
||||
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Render\RenderCacheInterface;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceObject;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\PostResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Caches entity normalizations after the response has been sent.
|
||||
*
|
||||
* @internal
|
||||
* @see \Drupal\jsonapi\Normalizer\ResourceObjectNormalizer::getNormalization()
|
||||
* @todo Refactor once https://www.drupal.org/node/2551419 lands.
|
||||
*/
|
||||
class ResourceObjectNormalizationCacher implements EventSubscriberInterface {
|
||||
|
||||
/**
|
||||
* Key for the base subset.
|
||||
*
|
||||
* The base subset contains the parts of the normalization that are always
|
||||
* present. The presence or absence of these are not affected by the requested
|
||||
* sparse field sets. This typically includes the resource type name, and the
|
||||
* resource ID.
|
||||
*/
|
||||
const RESOURCE_CACHE_SUBSET_BASE = 'base';
|
||||
|
||||
/**
|
||||
* Key for the fields subset.
|
||||
*
|
||||
* The fields subset contains the parts of the normalization that can appear
|
||||
* in a normalization based on the selected field set. This subset is
|
||||
* incrementally built across different requests for the same resource object.
|
||||
* A given field is normalized and put into the cache whenever there is a
|
||||
* cache miss for that field.
|
||||
*/
|
||||
const RESOURCE_CACHE_SUBSET_FIELDS = 'fields';
|
||||
|
||||
/**
|
||||
* The render cache.
|
||||
*
|
||||
* @var \Drupal\Core\Render\RenderCacheInterface
|
||||
*/
|
||||
protected $renderCache;
|
||||
|
||||
/**
|
||||
* The things to cache after the response has been sent.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $toCache = [];
|
||||
|
||||
/**
|
||||
* Sets the render cache service.
|
||||
*
|
||||
* @param \Drupal\Core\Render\RenderCacheInterface $render_cache
|
||||
* The render cache.
|
||||
*/
|
||||
public function setRenderCache(RenderCacheInterface $render_cache) {
|
||||
$this->renderCache = $render_cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an entity normalization from cache.
|
||||
*
|
||||
* The returned normalization may only be a partial normalization because it
|
||||
* was previously normalized with a sparse fieldset.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
|
||||
* The resource object for which to generate a cache item.
|
||||
*
|
||||
* @return array|false
|
||||
* The cached normalization parts, or FALSE if not yet cached.
|
||||
*
|
||||
* @see \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber::renderArrayToResponse()
|
||||
*/
|
||||
public function get(ResourceObject $object) {
|
||||
$cached = $this->renderCache->get(static::generateLookupRenderArray($object));
|
||||
return $cached ? $cached['#data'] : FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a normalization to be cached after the response has been sent.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
|
||||
* The resource object for which to generate a cache item.
|
||||
* @param array $normalization_parts
|
||||
* The normalization parts to cache.
|
||||
*/
|
||||
public function saveOnTerminate(ResourceObject $object, array $normalization_parts) {
|
||||
assert(
|
||||
array_keys($normalization_parts) === [
|
||||
static::RESOURCE_CACHE_SUBSET_BASE,
|
||||
static::RESOURCE_CACHE_SUBSET_FIELDS,
|
||||
]
|
||||
);
|
||||
$resource_type = $object->getResourceType();
|
||||
$key = $resource_type->getTypeName() . ':' . $object->getId();
|
||||
$this->toCache[$key] = [$object, $normalization_parts];
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes normalizations of entities to cache, if any were created.
|
||||
*
|
||||
* @param \Symfony\Component\HttpKernel\Event\PostResponseEvent $event
|
||||
* The Event to process.
|
||||
*/
|
||||
public function onTerminate(PostResponseEvent $event) {
|
||||
foreach ($this->toCache as $value) {
|
||||
list($object, $normalization_parts) = $value;
|
||||
$this->set($object, $normalization_parts);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a normalization to cache.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
|
||||
* The resource object for which to generate a cache item.
|
||||
* @param array $normalization_parts
|
||||
* The normalization parts to cache.
|
||||
*
|
||||
* @see \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber::responseToRenderArray()
|
||||
* @todo Refactor/remove once https://www.drupal.org/node/2551419 lands.
|
||||
*/
|
||||
protected function set(ResourceObject $object, array $normalization_parts) {
|
||||
$base = static::generateLookupRenderArray($object);
|
||||
$data_as_render_array = $base + [
|
||||
// The data we actually care about.
|
||||
'#data' => $normalization_parts,
|
||||
// Tell RenderCache to cache the #data property: the data we actually care
|
||||
// about.
|
||||
'#cache_properties' => ['#data'],
|
||||
// These exist only to fulfill the requirements of the RenderCache, which
|
||||
// is designed to work with render arrays only. We don't care about these.
|
||||
'#markup' => '',
|
||||
'#attached' => '',
|
||||
];
|
||||
|
||||
// Merge the entity's cacheability metadata with that of the normalization
|
||||
// parts, so that RenderCache can take care of cache redirects for us.
|
||||
CacheableMetadata::createFromObject($object)
|
||||
->merge(static::mergeCacheableDependencies($normalization_parts[static::RESOURCE_CACHE_SUBSET_BASE]))
|
||||
->merge(static::mergeCacheableDependencies($normalization_parts[static::RESOURCE_CACHE_SUBSET_FIELDS]))
|
||||
->applyTo($data_as_render_array);
|
||||
|
||||
$this->renderCache->set($data_as_render_array, $base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a lookup render array for a normalization.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
|
||||
* The resource object for which to generate a cache item.
|
||||
*
|
||||
* @return array
|
||||
* A render array for use with the RenderCache service.
|
||||
*
|
||||
* @see \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber::$dynamicPageCacheRedirectRenderArray
|
||||
*/
|
||||
protected static function generateLookupRenderArray(ResourceObject $object) {
|
||||
return [
|
||||
'#cache' => [
|
||||
'keys' => [$object->getResourceType()->getTypeName(), $object->getId()],
|
||||
'bin' => 'jsonapi_normalizations',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function getSubscribedEvents() {
|
||||
$events[KernelEvents::TERMINATE][] = ['onTerminate'];
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the joint cacheability of all provided dependencies.
|
||||
*
|
||||
* @param \Drupal\Core\Cache\CacheableDependencyInterface|object[] $dependencies
|
||||
* The dependencies.
|
||||
*
|
||||
* @return \Drupal\Core\Cache\CacheableMetadata
|
||||
* The cacheability of all dependencies.
|
||||
*
|
||||
* @see \Drupal\Core\Cache\RefinableCacheableDependencyInterface::addCacheableDependency()
|
||||
*/
|
||||
protected static function mergeCacheableDependencies(array $dependencies) {
|
||||
$merged_cacheability = new CacheableMetadata();
|
||||
array_walk($dependencies, function ($dependency) use ($merged_cacheability) {
|
||||
$merged_cacheability->addCacheableDependency($dependency);
|
||||
});
|
||||
return $merged_cacheability;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\EventSubscriber;
|
||||
|
||||
use Drupal\Core\Cache\CacheableResponse;
|
||||
use Drupal\Core\Cache\CacheableResponseInterface;
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
|
||||
use Drupal\jsonapi\ResourceResponse;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
/**
|
||||
* Response subscriber that serializes and removes ResourceResponses' data.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* This is 99% identical to:
|
||||
*
|
||||
* \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
|
||||
*
|
||||
* but with a few differences:
|
||||
* 1. It has the @jsonapi.serializer service injected instead of @serializer
|
||||
* 2. It has the @current_route_match service no longer injected
|
||||
* 3. It hardcodes the format to 'api_json'
|
||||
* 4. It adds the CacheableNormalization object returned by JSON:API
|
||||
* normalization to the response object.
|
||||
* 5. It flattens only to a cacheable response if the HTTP method is cacheable.
|
||||
*
|
||||
* @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
|
||||
*/
|
||||
class ResourceResponseSubscriber implements EventSubscriberInterface {
|
||||
|
||||
/**
|
||||
* The serializer.
|
||||
*
|
||||
* @var \Symfony\Component\Serializer\SerializerInterface
|
||||
*/
|
||||
protected $serializer;
|
||||
|
||||
/**
|
||||
* Constructs a ResourceResponseSubscriber object.
|
||||
*
|
||||
* @param \Symfony\Component\Serializer\SerializerInterface $serializer
|
||||
* The serializer.
|
||||
*/
|
||||
public function __construct(SerializerInterface $serializer) {
|
||||
$this->serializer = $serializer;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber::getSubscribedEvents()
|
||||
* @see \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber
|
||||
*/
|
||||
public static function getSubscribedEvents() {
|
||||
// Run before the dynamic page cache subscriber (priority 100), so that
|
||||
// Dynamic Page Cache can cache flattened responses.
|
||||
$events[KernelEvents::RESPONSE][] = ['onResponse', 128];
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes ResourceResponse responses' data, and removes that data.
|
||||
*
|
||||
* @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
|
||||
* The event to process.
|
||||
*/
|
||||
public function onResponse(FilterResponseEvent $event) {
|
||||
$response = $event->getResponse();
|
||||
if (!$response instanceof ResourceResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $event->getRequest();
|
||||
$format = 'api_json';
|
||||
$this->renderResponseBody($request, $response, $this->serializer, $format);
|
||||
$event->setResponse($this->flattenResponse($response, $request));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a resource response body.
|
||||
*
|
||||
* 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\jsonapi\ResourceResponse $response
|
||||
* The response from the JSON:API resource.
|
||||
* @param \Symfony\Component\Serializer\SerializerInterface $serializer
|
||||
* The serializer to use.
|
||||
* @param string|null $format
|
||||
* The response format, or NULL in case the response does not need a format,
|
||||
* for example for the response to a DELETE request.
|
||||
*
|
||||
* @todo Add test coverage for language negotiation contexts in
|
||||
* https://www.drupal.org/node/2135829.
|
||||
*/
|
||||
protected function renderResponseBody(Request $request, ResourceResponse $response, SerializerInterface $serializer, $format) {
|
||||
$data = $response->getResponseData();
|
||||
|
||||
// If there is data to send, serialize and set it as the response body.
|
||||
if ($data !== NULL) {
|
||||
// First normalize the data. Note that error responses do not need a
|
||||
// normalization context, since there are no entities to normalize.
|
||||
// @see \Drupal\jsonapi\EventSubscriber\DefaultExceptionSubscriber::isJsonApiExceptionEvent()
|
||||
$context = !$response->isSuccessful() ? [] : static::generateContext($request);
|
||||
$jsonapi_doc_object = $serializer->normalize($data, $format, $context);
|
||||
// Having just normalized the data, we can associate its cacheability with
|
||||
// the response object.
|
||||
assert($jsonapi_doc_object instanceof CacheableNormalization);
|
||||
$response->addCacheableDependency($jsonapi_doc_object);
|
||||
// Finally, encode the normalized data (JSON:API's encoder rasterizes it
|
||||
// automatically).
|
||||
$response->setContent($serializer->encode($jsonapi_doc_object->getNormalization(), $format));
|
||||
$response->headers->set('Content-Type', $request->getMimeType($format));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a top-level JSON:API normalization context.
|
||||
*
|
||||
* @param \Symfony\Component\HttpFoundation\Request $request
|
||||
* The request from which the context can be derived.
|
||||
*
|
||||
* @return array
|
||||
* The generated context.
|
||||
*/
|
||||
protected static function generateContext(Request $request) {
|
||||
// Build the expanded context.
|
||||
$context = [
|
||||
'account' => NULL,
|
||||
'sparse_fieldset' => NULL,
|
||||
];
|
||||
if ($request->query->get('fields')) {
|
||||
$context['sparse_fieldset'] = array_map(function ($item) {
|
||||
return explode(',', $item);
|
||||
}, $request->query->get('fields'));
|
||||
}
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a fully rendered resource response.
|
||||
*
|
||||
* Ensures that complex data structures in ResourceResponse::getResponseData()
|
||||
* are not serialized. Not doing this means that caching this response object
|
||||
* requires deserializing the PHP data when reading this response object from
|
||||
* cache, which can be very costly, and is unnecessary.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceResponse $response
|
||||
* A fully rendered resource response.
|
||||
* @param \Symfony\Component\HttpFoundation\Request $request
|
||||
* The request for which this response is generated.
|
||||
*
|
||||
* @return \Drupal\Core\Cache\CacheableResponse|\Symfony\Component\HttpFoundation\Response
|
||||
* The flattened response.
|
||||
*/
|
||||
protected static function flattenResponse(ResourceResponse $response, Request $request) {
|
||||
$final_response = ($response instanceof CacheableResponseInterface && $request->isMethodCacheable()) ? new CacheableResponse() : new Response();
|
||||
$final_response->setContent($response->getContent());
|
||||
$final_response->setStatusCode($response->getStatusCode());
|
||||
$final_response->setProtocolVersion($response->getProtocolVersion());
|
||||
if ($charset = $response->getCharset()) {
|
||||
$final_response->setCharset($charset);
|
||||
}
|
||||
$final_response->headers = clone $response->headers;
|
||||
if ($final_response instanceof CacheableResponseInterface) {
|
||||
$final_response->addCacheableDependency($response->getCacheableMetadata());
|
||||
}
|
||||
return $final_response;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\EventSubscriber;
|
||||
|
||||
use Drupal\Component\Serialization\Json;
|
||||
use Drupal\Core\Extension\ModuleHandlerInterface;
|
||||
use JsonSchema\Validator;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
|
||||
/**
|
||||
* Response subscriber that validates a JSON:API response.
|
||||
*
|
||||
* This must run after ResourceResponseSubscriber.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see \Drupal\rest\EventSubscriber\ResourceResponseSubscriber
|
||||
*/
|
||||
class ResourceResponseValidator implements EventSubscriberInterface {
|
||||
|
||||
/**
|
||||
* The JSON:API logger channel.
|
||||
*
|
||||
* @var \Psr\Log\LoggerInterface
|
||||
*/
|
||||
protected $logger;
|
||||
|
||||
/**
|
||||
* The schema validator.
|
||||
*
|
||||
* This property will only be set if the validator library is available.
|
||||
*
|
||||
* @var \JsonSchema\Validator|null
|
||||
*/
|
||||
protected $validator;
|
||||
|
||||
/**
|
||||
* The module handler.
|
||||
*
|
||||
* @var \Drupal\Core\Extension\ModuleHandlerInterface
|
||||
*/
|
||||
protected $moduleHandler;
|
||||
|
||||
/**
|
||||
* The application's root file path.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $appRoot;
|
||||
|
||||
/**
|
||||
* Constructs a ResourceResponseValidator object.
|
||||
*
|
||||
* @param \Psr\Log\LoggerInterface $logger
|
||||
* The JSON:API logger channel.
|
||||
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
|
||||
* The module handler.
|
||||
* @param string $app_root
|
||||
* The application's root file path.
|
||||
*/
|
||||
public function __construct(LoggerInterface $logger, ModuleHandlerInterface $module_handler, $app_root) {
|
||||
$this->logger = $logger;
|
||||
$this->moduleHandler = $module_handler;
|
||||
$this->appRoot = $app_root;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function getSubscribedEvents() {
|
||||
$events[KernelEvents::RESPONSE][] = ['onResponse'];
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the validator service if available.
|
||||
*/
|
||||
public function setValidator(Validator $validator = NULL) {
|
||||
if ($validator) {
|
||||
$this->validator = $validator;
|
||||
}
|
||||
elseif (class_exists(Validator::class)) {
|
||||
$this->validator = new Validator();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates JSON:API responses.
|
||||
*
|
||||
* @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
|
||||
* The event to process.
|
||||
*/
|
||||
public function onResponse(FilterResponseEvent $event) {
|
||||
$response = $event->getResponse();
|
||||
if (strpos($response->headers->get('Content-Type'), 'application/vnd.api+json') === FALSE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->doValidateResponse($response, $event->getRequest());
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps validation in an assert to prevent execution in production.
|
||||
*
|
||||
* @see self::validateResponse
|
||||
*/
|
||||
public function doValidateResponse(Response $response, Request $request) {
|
||||
assert($this->validateResponse($response, $request), 'A JSON:API response failed validation (see the logs for details). Please report this in the issue queue on drupal.org');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a response against the JSON:API specification.
|
||||
*
|
||||
* @param \Symfony\Component\HttpFoundation\Response $response
|
||||
* The response to validate.
|
||||
* @param \Symfony\Component\HttpFoundation\Request $request
|
||||
* The request containing info about what to validate.
|
||||
*
|
||||
* @return bool
|
||||
* FALSE if the response failed validation, otherwise TRUE.
|
||||
*/
|
||||
protected function validateResponse(Response $response, Request $request) {
|
||||
// If the validator isn't set, then the validation library is not installed.
|
||||
if (!$this->validator) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
// Do not use Json::decode here since it coerces the response into an
|
||||
// associative array, which creates validation errors.
|
||||
$response_data = json_decode($response->getContent());
|
||||
if (empty($response_data)) {
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
$schema_ref = sprintf(
|
||||
'file://%s/schema.json',
|
||||
implode('/', [
|
||||
$this->appRoot,
|
||||
$this->moduleHandler->getModule('jsonapi')->getPath(),
|
||||
])
|
||||
);
|
||||
$generic_jsonapi_schema = (object) ['$ref' => $schema_ref];
|
||||
|
||||
return $this->validateSchema($generic_jsonapi_schema, $response_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a string against a JSON Schema. It logs any possible errors.
|
||||
*
|
||||
* @param object $schema
|
||||
* The JSON Schema object.
|
||||
* @param string $response_data
|
||||
* The JSON string to validate.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the string is a valid instance of the schema. FALSE otherwise.
|
||||
*/
|
||||
protected function validateSchema($schema, $response_data) {
|
||||
$this->validator->check($response_data, $schema);
|
||||
$is_valid = $this->validator->isValid();
|
||||
if (!$is_valid) {
|
||||
$this->logger->debug("Response failed validation.\nResponse:\n@data\n\nErrors:\n@errors", [
|
||||
'@data' => Json::encode($response_data),
|
||||
'@errors' => Json::encode($this->validator->getErrors()),
|
||||
]);
|
||||
}
|
||||
return $is_valid;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Exception;
|
||||
|
||||
use Drupal\Core\Access\AccessResultInterface;
|
||||
use Drupal\Core\Access\AccessResultReasonInterface;
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceIdentifierTrait;
|
||||
|
||||
/**
|
||||
* Enhances the access denied exception with information about the entity.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class EntityAccessDeniedHttpException extends CacheableAccessDeniedHttpException implements ResourceIdentifierInterface {
|
||||
|
||||
use DependencySerializationTrait;
|
||||
use ResourceIdentifierTrait;
|
||||
|
||||
/**
|
||||
* The error which caused the 403.
|
||||
*
|
||||
* The error contains:
|
||||
* - entity: The entity which the current user does not have access to.
|
||||
* - pointer: A path in the JSON:API response structure pointing to the
|
||||
* entity.
|
||||
* - reason: (Optional) An optional reason for this failure.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $error = [];
|
||||
|
||||
/**
|
||||
* EntityAccessDeniedHttpException constructor.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface|null $entity
|
||||
* The entity, or NULL when an entity is being created.
|
||||
* @param \Drupal\Core\Access\AccessResultInterface $entity_access
|
||||
* The access result.
|
||||
* @param string $pointer
|
||||
* (optional) The pointer.
|
||||
* @param string $message
|
||||
* (Optional) The display to display.
|
||||
* @param string $relationship_field
|
||||
* (Optional) A relationship field name if access was denied because the
|
||||
* user does not have permission to view an entity's relationship field.
|
||||
* @param \Exception|null $previous
|
||||
* The previous exception.
|
||||
* @param int $code
|
||||
* The code.
|
||||
*/
|
||||
public function __construct($entity, AccessResultInterface $entity_access, $pointer, $message = 'The current user is not allowed to GET the selected resource.', $relationship_field = NULL, \Exception $previous = NULL, $code = 0) {
|
||||
assert(is_null($entity) || $entity instanceof EntityInterface);
|
||||
parent::__construct(CacheableMetadata::createFromObject($entity_access), $message, $previous, $code);
|
||||
$error = [
|
||||
'entity' => $entity,
|
||||
'pointer' => $pointer,
|
||||
'reason' => NULL,
|
||||
'relationship_field' => $relationship_field,
|
||||
];
|
||||
if ($entity_access instanceof AccessResultReasonInterface) {
|
||||
$error['reason'] = $entity_access->getReason();
|
||||
}
|
||||
$this->error = $error;
|
||||
// @todo: remove this ternary operation in https://www.drupal.org/project/jsonapi/issues/2997594.
|
||||
$this->resourceIdentifier = $entity ? ResourceIdentifier::fromEntity($entity) : NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the error.
|
||||
*
|
||||
* @return array
|
||||
* The error.
|
||||
*/
|
||||
public function getError() {
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Exception;
|
||||
|
||||
use Drupal\Core\Entity\EntityConstraintViolationListInterface;
|
||||
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
* A class to represent a 422 - Unprocessable Entity Exception.
|
||||
*
|
||||
* The HTTP 422 status code is used when the server sees:-
|
||||
*
|
||||
* The content type of the request is correct.
|
||||
* The syntax of the request is correct.
|
||||
* BUT was unable to process the contained instruction.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class UnprocessableHttpEntityException extends HttpException {
|
||||
|
||||
use DependencySerializationTrait;
|
||||
|
||||
/**
|
||||
* The constraint violations associated with this exception.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityConstraintViolationListInterface
|
||||
*/
|
||||
protected $violations;
|
||||
|
||||
/**
|
||||
* UnprocessableHttpEntityException constructor.
|
||||
*
|
||||
* @param \Exception|null $previous
|
||||
* The pervious error, if any, associated with the request.
|
||||
* @param array $headers
|
||||
* The headers associated with the request.
|
||||
* @param int $code
|
||||
* The HTTP status code associated with the request. Defaults to zero.
|
||||
*/
|
||||
public function __construct(\Exception $previous = NULL, array $headers = [], $code = 0) {
|
||||
parent::__construct(422, "Unprocessable Entity: validation failed.", $previous, $headers, $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the constraint violations associated with this exception.
|
||||
*
|
||||
* @return \Drupal\Core\Entity\EntityConstraintViolationListInterface
|
||||
* The constraint violations.
|
||||
*/
|
||||
public function getViolations() {
|
||||
return $this->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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Form;
|
||||
|
||||
use Drupal\Core\Form\ConfigFormBase;
|
||||
use Drupal\Core\Form\FormStateInterface;
|
||||
|
||||
/**
|
||||
* Configure JSON:API settings for this site.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class JsonApiSettingsForm extends ConfigFormBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFormId() {
|
||||
return 'jsonapi_settings';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getEditableConfigNames() {
|
||||
return ['jsonapi.settings'];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(array $form, FormStateInterface $form_state) {
|
||||
$jsonapi_config = $this->config('jsonapi.settings');
|
||||
|
||||
$form['read_only'] = [
|
||||
'#type' => 'radios',
|
||||
'#title' => $this->t('Allowed operations'),
|
||||
'#options' => [
|
||||
'r' => $this->t('Accept only JSON:API read operations.'),
|
||||
'rw' => $this->t('Accept all JSON:API create, read, update, and delete operations.'),
|
||||
],
|
||||
'#default_value' => $jsonapi_config->get('read_only') === TRUE ? 'r' : 'rw',
|
||||
'#description' => $this->t('Warning: Only enable all operations if the site requires it. <a href=":docs">Learn more about securing your site with JSON:API.</a>', [':docs' => 'https://www.drupal.org/docs/8/modules/jsonapi/security-considerations']),
|
||||
];
|
||||
|
||||
return parent::buildForm($form, $form_state);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function submitForm(array &$form, FormStateInterface $form_state) {
|
||||
$this->config('jsonapi.settings')
|
||||
->set('read_only', $form_state->getValue('read_only') === 'r')
|
||||
->save();
|
||||
|
||||
parent::submitForm($form, $form_state);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi;
|
||||
|
||||
use Drupal\Core\Access\AccessResult;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\EntityTypeManagerInterface;
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
|
||||
use Drupal\jsonapi\Access\EntityAccessChecker;
|
||||
use Drupal\jsonapi\Context\FieldResolver;
|
||||
use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
|
||||
use Drupal\jsonapi\JsonApiResource\Data;
|
||||
use Drupal\jsonapi\JsonApiResource\IncludedData;
|
||||
use Drupal\jsonapi\JsonApiResource\LabelOnlyResourceObject;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceObject;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceObjectData;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
|
||||
/**
|
||||
* Resolves included resources for an entity or collection of entities.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class IncludeResolver {
|
||||
|
||||
/**
|
||||
* The entity type manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
|
||||
*/
|
||||
protected $entityTypeManager;
|
||||
|
||||
/**
|
||||
* The JSON:API entity access checker.
|
||||
*
|
||||
* @var \Drupal\jsonapi\Access\EntityAccessChecker
|
||||
*/
|
||||
protected $entityAccessChecker;
|
||||
|
||||
/**
|
||||
* IncludeResolver constructor.
|
||||
*/
|
||||
public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityAccessChecker $entity_access_checker) {
|
||||
$this->entityTypeManager = $entity_type_manager;
|
||||
$this->entityAccessChecker = $entity_access_checker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves included resources.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface|\Drupal\jsonapi\JsonApiResource\ResourceObjectData $data
|
||||
* The resource(s) for which to resolve includes.
|
||||
* @param string $include_parameter
|
||||
* The include query parameter to resolve.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\IncludedData
|
||||
* An IncludedData object of resolved resources to be included.
|
||||
*
|
||||
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
|
||||
* Thrown if an included entity type doesn't exist.
|
||||
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
|
||||
* Thrown if a storage handler couldn't be loaded.
|
||||
*/
|
||||
public function resolve($data, $include_parameter) {
|
||||
assert($data instanceof ResourceObject || $data instanceof ResourceObjectData);
|
||||
$data = $data instanceof ResourceObjectData ? $data : new ResourceObjectData([$data], 1);
|
||||
$include_tree = static::toIncludeTree($data, $include_parameter);
|
||||
return IncludedData::deduplicate($this->resolveIncludeTree($include_tree, $data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives a tree of include field names and resolves resources for it.
|
||||
*
|
||||
* This method takes a tree of relationship field names and JSON:API Data
|
||||
* object. For the top-level of the tree and for each entity in the
|
||||
* collection, it gets the target entity type and IDs for each relationship
|
||||
* field. The method then loads all of those targets and calls itself
|
||||
* recursively with the next level of the tree and those loaded resources.
|
||||
*
|
||||
* @param array $include_tree
|
||||
* The include paths, represented as a tree.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\Data $data
|
||||
* The entity collection from which includes should be resolved.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\Data|null $includes
|
||||
* (Internal use only) Any prior resolved includes.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\Data
|
||||
* A JSON:API Data of included items.
|
||||
*
|
||||
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
|
||||
* Thrown if an included entity type doesn't exist.
|
||||
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
|
||||
* Thrown if a storage handler couldn't be loaded.
|
||||
*/
|
||||
protected function resolveIncludeTree(array $include_tree, Data $data, Data $includes = NULL) {
|
||||
$includes = is_null($includes) ? new IncludedData([]) : $includes;
|
||||
foreach ($include_tree as $field_name => $children) {
|
||||
$references = [];
|
||||
foreach ($data as $resource_object) {
|
||||
// Some objects in the collection may be LabelOnlyResourceObjects or
|
||||
// EntityAccessDeniedHttpException objects.
|
||||
assert($resource_object instanceof ResourceIdentifierInterface);
|
||||
if ($resource_object instanceof LabelOnlyResourceObject) {
|
||||
$message = "The current user is not allowed to view this relationship.";
|
||||
$exception = new EntityAccessDeniedHttpException($resource_object->getEntity(), AccessResult::forbidden("The user only has authorization for the 'view label' operation."), '', $message, $field_name);
|
||||
$includes = IncludedData::merge($includes, new IncludedData([$exception]));
|
||||
continue;
|
||||
}
|
||||
elseif (!$resource_object instanceof ResourceObject) {
|
||||
continue;
|
||||
}
|
||||
$public_field_name = $resource_object->getResourceType()->getPublicName($field_name);
|
||||
// Not all entities in $entity_collection will be of the same bundle and
|
||||
// may not have all of the same fields. Therefore, calling
|
||||
// $resource_object->get($a_missing_field_name) will result in an
|
||||
// exception.
|
||||
if (!$resource_object->hasField($public_field_name)) {
|
||||
continue;
|
||||
}
|
||||
$field_list = $resource_object->getField($public_field_name);
|
||||
// Config entities don't have real fields and can't have relationships.
|
||||
if (!$field_list instanceof FieldItemListInterface) {
|
||||
continue;
|
||||
}
|
||||
$field_access = $field_list->access('view', NULL, TRUE);
|
||||
if (!$field_access->isAllowed()) {
|
||||
$message = 'The current user is not allowed to view this relationship.';
|
||||
$exception = new EntityAccessDeniedHttpException($field_list->getEntity(), $field_access, '', $message, $public_field_name);
|
||||
$includes = IncludedData::merge($includes, new IncludedData([$exception]));
|
||||
continue;
|
||||
}
|
||||
$target_type = $field_list->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type');
|
||||
assert(!empty($target_type));
|
||||
foreach ($field_list as $field_item) {
|
||||
assert($field_item instanceof EntityReferenceItem);
|
||||
$references[$target_type][] = $field_item->get($field_item::mainPropertyName())->getValue();
|
||||
}
|
||||
}
|
||||
foreach ($references as $target_type => $ids) {
|
||||
$entity_storage = $this->entityTypeManager->getStorage($target_type);
|
||||
$targeted_entities = $entity_storage->loadMultiple(array_unique($ids));
|
||||
$access_checked_entities = array_map(function (EntityInterface $entity) {
|
||||
return $this->entityAccessChecker->getAccessCheckedResourceObject($entity);
|
||||
}, $targeted_entities);
|
||||
$targeted_collection = new IncludedData(array_filter($access_checked_entities, function (ResourceIdentifierInterface $resource_object) {
|
||||
return !$resource_object->getResourceType()->isInternal();
|
||||
}));
|
||||
$includes = static::resolveIncludeTree($children, $targeted_collection, IncludedData::merge($includes, $targeted_collection));
|
||||
}
|
||||
}
|
||||
return $includes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a tree of field names to include from an include parameter.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObjectData $data
|
||||
* The base resources for which includes should be resolved.
|
||||
* @param string $include_parameter
|
||||
* The raw include parameter value.
|
||||
*
|
||||
* @return array
|
||||
* An multi-dimensional array representing a tree of field names to be
|
||||
* included. Array keys are the field names. Leaves are empty arrays.
|
||||
*/
|
||||
protected static function toIncludeTree(ResourceObjectData $data, $include_parameter) {
|
||||
// $include_parameter: 'one.two.three, one.two.four'.
|
||||
$include_paths = array_map('trim', explode(',', $include_parameter));
|
||||
// $exploded_paths: [['one', 'two', 'three'], ['one', 'two', 'four']].
|
||||
$exploded_paths = array_map(function ($include_path) {
|
||||
return array_map('trim', explode('.', $include_path));
|
||||
}, $include_paths);
|
||||
$resolved_paths_per_resource_type = [];
|
||||
/* @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface $resource_object */
|
||||
foreach ($data as $resource_object) {
|
||||
$resource_type = $resource_object->getResourceType();
|
||||
$resource_type_name = $resource_type->getTypeName();
|
||||
if (isset($resolved_paths_per_resource_type[$resource_type_name])) {
|
||||
continue;
|
||||
}
|
||||
$resolved_paths_per_resource_type[$resource_type_name] = static::resolveInternalIncludePaths($resource_type, $exploded_paths);
|
||||
}
|
||||
$resolved_paths = array_reduce($resolved_paths_per_resource_type, 'array_merge', []);
|
||||
return static::buildTree($resolved_paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an array of public field paths.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $base_resource_type
|
||||
* The base resource type from which to resolve an internal include path.
|
||||
* @param array $paths
|
||||
* An array of exploded include paths.
|
||||
*
|
||||
* @return array
|
||||
* An array of all possible internal include paths derived from the given
|
||||
* public include paths.
|
||||
*
|
||||
* @see self::buildTree
|
||||
*/
|
||||
protected static function resolveInternalIncludePaths(ResourceType $base_resource_type, array $paths) {
|
||||
$internal_paths = array_map(function ($exploded_path) use ($base_resource_type) {
|
||||
if (empty($exploded_path)) {
|
||||
return [];
|
||||
}
|
||||
return FieldResolver::resolveInternalIncludePath($base_resource_type, $exploded_path);
|
||||
}, $paths);
|
||||
$flattened_paths = array_reduce($internal_paths, 'array_merge', []);
|
||||
return $flattened_paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an array of exploded paths and builds a tree of field names.
|
||||
*
|
||||
* Input example: [
|
||||
* ['one', 'two', 'three'],
|
||||
* ['one', 'two', 'four'],
|
||||
* ['one', 'two', 'internal'],
|
||||
* ]
|
||||
*
|
||||
* Output example: [
|
||||
* 'one' => [
|
||||
* 'two' [
|
||||
* 'three' => [],
|
||||
* 'four' => [],
|
||||
* 'internal' => [],
|
||||
* ],
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* @param array $paths
|
||||
* An array of exploded include paths.
|
||||
*
|
||||
* @return array
|
||||
* An multi-dimensional array representing a tree of field names to be
|
||||
* included. Array keys are the field names. Leaves are empty arrays.
|
||||
*/
|
||||
protected static function buildTree(array $paths) {
|
||||
$merged = [];
|
||||
foreach ($paths as $parts) {
|
||||
if (!$field_name = array_shift($parts)) {
|
||||
continue;
|
||||
}
|
||||
$previous = isset($merged[$field_name]) ? $merged[$field_name] : [];
|
||||
$merged[$field_name] = array_merge($previous, [$parts]);
|
||||
}
|
||||
return !empty($merged) ? array_map([static::class, __FUNCTION__], $merged) : $merged;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
use Drupal\Component\Assertion\Inspector;
|
||||
use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* Represents the `data` and `included` objects of a top-level object.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
abstract class Data implements \IteratorAggregate, \Countable {
|
||||
|
||||
/**
|
||||
* Various representations of JSON:API objects.
|
||||
*
|
||||
* @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface[]
|
||||
*/
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* The number of resources permitted in this collection.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $cardinality;
|
||||
|
||||
/**
|
||||
* Holds a boolean indicating if there is a next page.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $hasNextPage;
|
||||
|
||||
/**
|
||||
* Holds the total count of entities.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $count;
|
||||
|
||||
/**
|
||||
* Instantiates a Data object.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface[] $data
|
||||
* The resources or resource identifiers for the collection.
|
||||
* @param int $cardinality
|
||||
* The number of resources that this collection may contain. Related
|
||||
* resource collections may handle both to-one or to-many relationships. A
|
||||
* to-one relationship should have a cardinality of 1. Use -1 for unlimited
|
||||
* cardinality.
|
||||
*/
|
||||
public function __construct(array $data, $cardinality = -1) {
|
||||
assert(Inspector::assertAllObjects($data, ResourceIdentifierInterface::class));
|
||||
assert($cardinality >= -1 && $cardinality !== 0, 'Cardinality must be -1 for unlimited cardinality or a positive integer.');
|
||||
assert($cardinality === -1 || count($data) <= $cardinality, 'If cardinality is not unlimited, the number of given resources must not exceed the cardinality of the collection.');
|
||||
$this->data = array_values($data);
|
||||
$this->cardinality = $cardinality;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an iterator for entities.
|
||||
*
|
||||
* @return \ArrayIterator
|
||||
* An \ArrayIterator instance
|
||||
*/
|
||||
public function getIterator() {
|
||||
return new \ArrayIterator($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of entities.
|
||||
*
|
||||
* @return int
|
||||
* The number of parameters
|
||||
*/
|
||||
public function count() {
|
||||
return count($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getTotalCount() {
|
||||
return $this->count;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function setTotalCount($count) {
|
||||
$this->count = $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the collection as an array.
|
||||
*
|
||||
* @return \Drupal\Core\Entity\EntityInterface[]
|
||||
* The array of entities.
|
||||
*/
|
||||
public function toArray() {
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cardinality of this collection.
|
||||
*
|
||||
* @return int
|
||||
* The cardinality of the resource collection. -1 for unlimited cardinality.
|
||||
*/
|
||||
public function getCardinality() {
|
||||
return $this->cardinality;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new Data object containing the entities of $this and $other.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\Data $a
|
||||
* A Data object object to be merged.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\Data $b
|
||||
* A Data object to be merged.
|
||||
*
|
||||
* @return static
|
||||
* A new merged Data object.
|
||||
*/
|
||||
public static function merge(Data $a, Data $b) {
|
||||
return new static(array_merge($a->toArray(), $b->toArray()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new, deduplicated Data object.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\Data $collection
|
||||
* The Data object to deduplicate.
|
||||
*
|
||||
* @return static
|
||||
* A new merged Data object.
|
||||
*/
|
||||
public static function deduplicate(Data $collection) {
|
||||
$deduplicated = [];
|
||||
foreach ($collection as $resource) {
|
||||
$dedupe_key = $resource->getTypeName() . ':' . $resource->getId();
|
||||
if ($resource instanceof EntityAccessDeniedHttpException && ($error = $resource->getError()) && !is_null($error['relationship_field'])) {
|
||||
$dedupe_key .= ':' . $error['relationship_field'];
|
||||
}
|
||||
$deduplicated[$dedupe_key] = $resource;
|
||||
}
|
||||
return new static(array_values($deduplicated));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
use Drupal\Component\Assertion\Inspector;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
|
||||
/**
|
||||
* To be used when the primary data is `errors`.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* (The spec says the top-level `data` and `errors` members MUST NOT coexist.)
|
||||
* @see http://jsonapi.org/format/#document-top-level
|
||||
*
|
||||
* @see http://jsonapi.org/format/#error-objects
|
||||
*/
|
||||
class ErrorCollection implements \IteratorAggregate {
|
||||
|
||||
/**
|
||||
* The HTTP exceptions.
|
||||
*
|
||||
* @var \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface[]
|
||||
*/
|
||||
protected $errors;
|
||||
|
||||
/**
|
||||
* Instantiates an ErrorCollection object.
|
||||
*
|
||||
* @param \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface[] $errors
|
||||
* The errors.
|
||||
*/
|
||||
public function __construct(array $errors) {
|
||||
assert(Inspector::assertAll(function ($error) {
|
||||
return $error instanceof HttpExceptionInterface;
|
||||
}, $errors));
|
||||
$this->errors = $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an iterator for errors.
|
||||
*
|
||||
* @return \ArrayIterator
|
||||
* An \ArrayIterator instance
|
||||
*/
|
||||
public function getIterator() {
|
||||
return new \ArrayIterator($this->errors);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
use Drupal\Component\Assertion\Inspector;
|
||||
use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* Represents the included member of a JSON:API document.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class IncludedData extends ResourceObjectData {
|
||||
|
||||
/**
|
||||
* IncludedData constructor.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject[]|\Drupal\jsonapi\Exception\EntityAccessDeniedHttpException[] $data
|
||||
* Resource objects that are the primary data for the response.
|
||||
*
|
||||
* @see \Drupal\jsonapi\JsonApiResource\Data::__construct
|
||||
*/
|
||||
public function __construct($data) {
|
||||
assert(Inspector::assertAllObjects($data, ResourceObject::class, EntityAccessDeniedHttpException::class));
|
||||
parent::__construct($data, -1);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
/**
|
||||
* Represents a JSON:API document's "top level".
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see http://jsonapi.org/format/#document-top-level
|
||||
*
|
||||
* @todo Add support for the missing optional 'jsonapi' member or document why not.
|
||||
*/
|
||||
class JsonApiDocumentTopLevel {
|
||||
|
||||
/**
|
||||
* The data to normalize.
|
||||
*
|
||||
* @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface|\Drupal\jsonapi\JsonApiResource\Data|\Drupal\jsonapi\JsonApiResource\ErrorCollection|\Drupal\Core\Field\EntityReferenceFieldItemListInterface
|
||||
*/
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* The metadata to normalize.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $meta;
|
||||
|
||||
/**
|
||||
* The links.
|
||||
*
|
||||
* @var \Drupal\jsonapi\JsonApiResource\LinkCollection
|
||||
*/
|
||||
protected $links;
|
||||
|
||||
/**
|
||||
* The includes to normalize.
|
||||
*
|
||||
* @var \Drupal\jsonapi\JsonApiResource\IncludedData
|
||||
*/
|
||||
protected $includes;
|
||||
|
||||
/**
|
||||
* Resource objects that will be omitted from the response for access reasons.
|
||||
*
|
||||
* @var \Drupal\jsonapi\JsonApiResource\OmittedData
|
||||
*/
|
||||
protected $omissions;
|
||||
|
||||
/**
|
||||
* Instantiates a JsonApiDocumentTopLevel object.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\TopLevelDataInterface|\Drupal\jsonapi\JsonApiResource\ErrorCollection $data
|
||||
* The data to normalize. It can be either a ResourceObject, or a stand-in
|
||||
* for one, or a collection of the same.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\IncludedData $includes
|
||||
* A JSON:API Data object containing resources to be included in the
|
||||
* response document or NULL if there should not be includes.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
|
||||
* A collection of links to resources related to the top-level document.
|
||||
* @param array $meta
|
||||
* (optional) The metadata to normalize.
|
||||
*/
|
||||
public function __construct($data, IncludedData $includes, LinkCollection $links, array $meta = []) {
|
||||
assert($data instanceof TopLevelDataInterface || $data instanceof ErrorCollection);
|
||||
assert(!$data instanceof ErrorCollection || $includes instanceof NullIncludedData);
|
||||
$this->data = $data instanceof TopLevelDataInterface ? $data->getData() : $data;
|
||||
$this->includes = $includes->getData();
|
||||
$this->links = $data instanceof TopLevelDataInterface ? $data->getMergedLinks($links->withContext($this)) : $links->withContext($this);
|
||||
$this->meta = $data instanceof TopLevelDataInterface ? $data->getMergedMeta($meta) : $meta;
|
||||
$this->omissions = $data instanceof TopLevelDataInterface
|
||||
? OmittedData::merge($data->getOmissions(), $includes->getOmissions())
|
||||
: $includes->getOmissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the data.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\Data|\Drupal\jsonapi\JsonApiResource\ErrorCollection
|
||||
* The data.
|
||||
*/
|
||||
public function getData() {
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the links.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\LinkCollection
|
||||
* The top-level links.
|
||||
*/
|
||||
public function getLinks() {
|
||||
return $this->links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the metadata.
|
||||
*
|
||||
* @return array
|
||||
* The metadata.
|
||||
*/
|
||||
public function getMeta() {
|
||||
return $this->meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a JSON:API Data object of resources to be included in the response.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\IncludedData
|
||||
* The includes.
|
||||
*/
|
||||
public function getIncludes() {
|
||||
return $this->includes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an OmittedData instance containing resources to be omitted.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\OmittedData
|
||||
* The omissions.
|
||||
*/
|
||||
public function getOmissions() {
|
||||
return $this->omissions;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\RevisionableInterface;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
|
||||
/**
|
||||
* Value object decorating a ResourceObject; only its label is available.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
final class LabelOnlyResourceObject extends ResourceObject {
|
||||
|
||||
/**
|
||||
* The entity represented by this resource object.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityInterface
|
||||
*/
|
||||
protected $entity;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function createFromEntity(ResourceType $resource_type, EntityInterface $entity, LinkCollection $links = NULL) {
|
||||
$resource_object = new static(
|
||||
$entity,
|
||||
$resource_type,
|
||||
$entity->uuid(),
|
||||
$resource_type->isVersionable() && $entity instanceof RevisionableInterface ? $entity->getRevisionId() : NULL,
|
||||
static::extractFieldsFromEntity($resource_type, $entity),
|
||||
static::buildLinksFromEntity($resource_type, $entity, $links ?: new LinkCollection([]))
|
||||
);
|
||||
$resource_object->setEntity($entity);
|
||||
return $resource_object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the decorated entity.
|
||||
*
|
||||
* @return \Drupal\Core\Entity\EntityInterface
|
||||
* The label for which to only normalize its label.
|
||||
*/
|
||||
public function getEntity() {
|
||||
return $this->entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the underlying entity.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* An entity.
|
||||
*/
|
||||
protected function setEntity(EntityInterface $entity) {
|
||||
$this->entity = $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected static function extractFieldsFromEntity(ResourceType $resource_type, EntityInterface $entity) {
|
||||
$fields = parent::extractFieldsFromEntity($resource_type, $entity);
|
||||
$public_label_field_name = $resource_type->getPublicName(static::getLabelFieldName($entity));
|
||||
return array_intersect_key($fields, [$public_label_field_name => TRUE]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
use Drupal\Component\Assertion\Inspector;
|
||||
use Drupal\Component\Utility\DiffArray;
|
||||
use Drupal\Core\Cache\CacheableDependencyInterface;
|
||||
use Drupal\Core\Cache\CacheableDependencyTrait;
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Url;
|
||||
|
||||
/**
|
||||
* Represents an RFC8288 based link.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see https://tools.ietf.org/html/rfc8288
|
||||
*/
|
||||
final class Link implements CacheableDependencyInterface {
|
||||
|
||||
use CacheableDependencyTrait;
|
||||
|
||||
/**
|
||||
* The link URI.
|
||||
*
|
||||
* @var \Drupal\Core\Url
|
||||
*/
|
||||
protected $uri;
|
||||
|
||||
/**
|
||||
* The URI, as a string.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $href;
|
||||
|
||||
/**
|
||||
* The link relation types.
|
||||
*
|
||||
* @var string[]
|
||||
*
|
||||
* @todo: change this type documentation to be a single string in
|
||||
* https://www.drupal.org/project/drupal/issues/3080467.
|
||||
*/
|
||||
protected $rel;
|
||||
|
||||
/**
|
||||
* The link target attributes.
|
||||
*
|
||||
* @var string[]
|
||||
* An associative array where the keys are the attribute keys and values are
|
||||
* either string or an array of strings.
|
||||
*/
|
||||
protected $attributes;
|
||||
|
||||
/**
|
||||
* JSON:API Link constructor.
|
||||
*
|
||||
* @param \Drupal\Core\Cache\CacheableMetadata $cacheability
|
||||
* Any cacheability metadata associated with the link. For example, a
|
||||
* 'call-to-action' link might reference a registration resource if an event
|
||||
* has vacancies or a wait-list resource otherwise. Therefore, the link's
|
||||
* cacheability might be depend on a certain entity's values other than the
|
||||
* entity on which the link will appear.
|
||||
* @param \Drupal\Core\Url $url
|
||||
* The Url object for the link.
|
||||
* @param string $link_relation_type
|
||||
* An array of registered or extension RFC8288 link relation types.
|
||||
* @param array $target_attributes
|
||||
* An associative array of target attributes for the link.
|
||||
*
|
||||
* @see https://tools.ietf.org/html/rfc8288#section-2.1
|
||||
*/
|
||||
public function __construct(CacheableMetadata $cacheability, Url $url, $link_relation_type, array $target_attributes = []) {
|
||||
// @todo Remove this conditional block in drupal:9.0.0 and add a type hint to the $link_relation_type argument of this method in https://www.drupal.org/project/drupal/issues/3080467.
|
||||
if (is_array($link_relation_type)) {
|
||||
@trigger_error('Constructing a ' . self::class . ' with an array of link relation types is deprecated in drupal:8.8.0 and will throw a fatal error in drupal:9.0.0. Pass a single string instead. See https://www.drupal.org/node/3087821.', E_USER_DEPRECATED);
|
||||
assert(Inspector::assertAllStrings($link_relation_type));
|
||||
}
|
||||
else {
|
||||
assert(is_string($link_relation_type));
|
||||
$link_relation_type = [$link_relation_type];
|
||||
}
|
||||
assert(Inspector::assertAllStrings(array_keys($target_attributes)));
|
||||
assert(Inspector::assertAll(function ($target_attribute_value) {
|
||||
return is_string($target_attribute_value) || is_array($target_attribute_value);
|
||||
}, array_values($target_attributes)));
|
||||
$generated_url = $url->setAbsolute()->toString(TRUE);
|
||||
$this->href = $generated_url->getGeneratedUrl();
|
||||
$this->uri = $url;
|
||||
$this->rel = $link_relation_type;
|
||||
$this->attributes = $target_attributes;
|
||||
$this->setCacheability($cacheability->addCacheableDependency($generated_url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the link's URI.
|
||||
*
|
||||
* @return \Drupal\Core\Url
|
||||
* The link's URI as a Url object.
|
||||
*/
|
||||
public function getUri() {
|
||||
return $this->uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the link's URI as a string.
|
||||
*
|
||||
* @return string
|
||||
* The link's URI as a string.
|
||||
*/
|
||||
public function getHref() {
|
||||
return $this->href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the link's relation type.
|
||||
*
|
||||
* @return string
|
||||
* The link's relation type.
|
||||
*/
|
||||
public function getLinkRelationType() {
|
||||
return reset($this->rel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the link's relation types.
|
||||
*
|
||||
* @return string[]
|
||||
* The link's relation types.
|
||||
*
|
||||
* @todo: remove this method in https://www.drupal.org/project/drupal/issues/3080467.
|
||||
*/
|
||||
public function getLinkRelationTypes() {
|
||||
@trigger_error(__METHOD__ . '() is deprecated in drupal:8.8.0 and will be removed in drupal:9.0.0. Use getLinkRelationType() instead. See https://www.drupal.org/node/3087821.', E_USER_DEPRECATED);
|
||||
return $this->rel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the link's target attributes.
|
||||
*
|
||||
* @return string[]
|
||||
* The link's target attributes.
|
||||
*/
|
||||
public function getTargetAttributes() {
|
||||
return $this->attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two links.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\Link $a
|
||||
* The first link.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\Link $b
|
||||
* The second link.
|
||||
*
|
||||
* @return int
|
||||
* 0 if the links can be considered identical, an integer greater than or
|
||||
* less than 0 otherwise.
|
||||
*/
|
||||
public static function compare(Link $a, Link $b) {
|
||||
// @todo: Remove $rel_to_string function once rel property is a single
|
||||
// string in https://www.drupal.org/project/drupal/issues/3080467.
|
||||
$rel_to_string = function (array $rel) {
|
||||
// Sort the link relation type array so that the order of link relation
|
||||
// types does not matter during link comparison.
|
||||
sort($rel);
|
||||
return implode(' ', $rel);
|
||||
};
|
||||
// Any string concatenation would work, but a Link header-like format makes
|
||||
// it clear what is being compared.
|
||||
$a_string = sprintf('<%s>;rel="%s"', $a->getHref(), $rel_to_string($a->rel));
|
||||
$b_string = sprintf('<%s>;rel="%s"', $b->getHref(), $rel_to_string($b->rel));
|
||||
$cmp = strcmp($a_string, $b_string);
|
||||
// If the `href` or `rel` of the links are not equivalent, it's not
|
||||
// necessary to compare target attributes.
|
||||
if ($cmp === 0) {
|
||||
return (int) !empty(DiffArray::diffAssocRecursive($a->getTargetAttributes(), $b->getTargetAttributes()));
|
||||
}
|
||||
return $cmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two equivalent links into one link with the merged cacheability.
|
||||
*
|
||||
* The links must share the same URI, link relation type and attributes.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\Link $a
|
||||
* The first link.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\Link $b
|
||||
* The second link.
|
||||
*
|
||||
* @return static
|
||||
* A new JSON:API Link object with the cacheability of both links merged.
|
||||
*/
|
||||
public static function merge(Link $a, Link $b) {
|
||||
assert(static::compare($a, $b) === 0, 'Only equivalent links can be merged.');
|
||||
$merged_cacheability = (new CacheableMetadata())->addCacheableDependency($a)->addCacheableDependency($b);
|
||||
return new static($merged_cacheability, $a->getUri(), $a->getLinkRelationType(), $a->getTargetAttributes());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
use Drupal\Component\Assertion\Inspector;
|
||||
|
||||
/**
|
||||
* Contains a set of JSON:API Link objects.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
final class LinkCollection implements \IteratorAggregate {
|
||||
|
||||
/**
|
||||
* The links in the collection, keyed by unique strings.
|
||||
*
|
||||
* @var \Drupal\jsonapi\JsonApiResource\Link[]
|
||||
*/
|
||||
protected $links;
|
||||
|
||||
/**
|
||||
* The link context.
|
||||
*
|
||||
* All links objects exist within a context object. Links form a relationship
|
||||
* between a source IRI and target IRI. A context is the link's source.
|
||||
*
|
||||
* @var \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\Relationship
|
||||
*
|
||||
* @see https://tools.ietf.org/html/rfc8288#section-3.2
|
||||
*/
|
||||
protected $context;
|
||||
|
||||
/**
|
||||
* LinkCollection constructor.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\Link[] $links
|
||||
* An associated array of key names and JSON:API Link objects.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\Relationship $context
|
||||
* (internal use only) The context object. Use the self::withContext()
|
||||
* method to establish a context. This should be done automatically when
|
||||
* a LinkCollection is passed into a context object.
|
||||
*/
|
||||
public function __construct(array $links, $context = NULL) {
|
||||
assert(Inspector::assertAll(function ($key) {
|
||||
return static::validKey($key);
|
||||
}, array_keys($links)));
|
||||
assert(Inspector::assertAll(function ($link) {
|
||||
return $link instanceof Link || is_array($link) && Inspector::assertAllObjects($link, Link::class);
|
||||
}, $links));
|
||||
assert(is_null($context) || Inspector::assertAllObjects([$context], JsonApiDocumentTopLevel::class, ResourceObject::class, Relationship::class));
|
||||
ksort($links);
|
||||
$this->links = array_map(function ($link) {
|
||||
return is_array($link) ? $link : [$link];
|
||||
}, $links);
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getIterator() {
|
||||
assert(!is_null($this->context), 'A LinkCollection is invalid unless a context has been established.');
|
||||
return new \ArrayIterator($this->links);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new LinkCollection with the given link inserted.
|
||||
*
|
||||
* @param string $key
|
||||
* A key for the link. If the key already exists and the link shares an
|
||||
* href, link relation type and attributes with an existing link with that
|
||||
* key, those links will be merged together.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\Link $new_link
|
||||
* The link to insert.
|
||||
*
|
||||
* @return static
|
||||
* A new LinkCollection with the given link inserted or merged with the
|
||||
* current set of links.
|
||||
*/
|
||||
public function withLink($key, Link $new_link) {
|
||||
assert(static::validKey($key));
|
||||
$merged = $this->links;
|
||||
if (isset($merged[$key])) {
|
||||
foreach ($merged[$key] as $index => $existing_link) {
|
||||
if (Link::compare($existing_link, $new_link) === 0) {
|
||||
$merged[$key][$index] = Link::merge($existing_link, $new_link);
|
||||
return new static($merged, $this->context);
|
||||
}
|
||||
}
|
||||
}
|
||||
$merged[$key][] = $new_link;
|
||||
return new static($merged, $this->context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a link with the given key exists.
|
||||
*
|
||||
* @param string $key
|
||||
* The key.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if a link with the given key exist, FALSE otherwise.
|
||||
*/
|
||||
public function hasLinkWithKey($key) {
|
||||
return array_key_exists($key, $this->links);
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes a new context for a LinkCollection.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\Relationship $context
|
||||
* The new context object.
|
||||
*
|
||||
* @return static
|
||||
* A new LinkCollection with the given context.
|
||||
*/
|
||||
public function withContext($context) {
|
||||
return new static($this->links, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the LinkCollection's context object.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel|\Drupal\jsonapi\JsonApiResource\ResourceObject|\Drupal\jsonapi\JsonApiResource\Relationship
|
||||
* The LinkCollection's context.
|
||||
*/
|
||||
public function getContext() {
|
||||
assert(!is_null($this->context), 'A LinkCollection is invalid unless a context has been established.');
|
||||
return $this->context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters a LinkCollection using the provided callback.
|
||||
*
|
||||
* @param callable $f
|
||||
* The filter callback. The callback has the signature below.
|
||||
*
|
||||
* @code
|
||||
* boolean callback(string $key, \Drupal\jsonapi\JsonApiResource\Link $link, mixed $context))
|
||||
* @endcode
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\LinkCollection
|
||||
* A new, filtered LinkCollection.
|
||||
*/
|
||||
public function filter(callable $f) {
|
||||
$links = iterator_to_array($this);
|
||||
$filtered = array_reduce(array_keys($links), function ($filtered, $key) use ($links, $f) {
|
||||
if ($f($key, $links[$key], $this->context)) {
|
||||
$filtered[$key] = $links[$key];
|
||||
}
|
||||
return $filtered;
|
||||
}, []);
|
||||
return new LinkCollection($filtered, $this->context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges two LinkCollections.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\LinkCollection $a
|
||||
* The first link collection.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\LinkCollection $b
|
||||
* The second link collection.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\LinkCollection
|
||||
* A new LinkCollection with the links of both inputs.
|
||||
*/
|
||||
public static function merge(LinkCollection $a, LinkCollection $b) {
|
||||
assert($a->getContext() === $b->getContext());
|
||||
$merged = new LinkCollection([], $a->getContext());
|
||||
foreach ($a as $key => $links) {
|
||||
$merged = array_reduce($links, function (self $merged, Link $link) use ($key) {
|
||||
return $merged->withLink($key, $link);
|
||||
}, $merged);
|
||||
}
|
||||
foreach ($b as $key => $links) {
|
||||
$merged = array_reduce($links, function (self $merged, Link $link) use ($key) {
|
||||
return $merged->withLink($key, $link);
|
||||
}, $merged);
|
||||
}
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that a link key is valid.
|
||||
*
|
||||
* @param string $key
|
||||
* A key name.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the key is valid, FALSE otherwise.
|
||||
*/
|
||||
protected static function validKey($key) {
|
||||
return is_string($key) && !is_numeric($key) && strpos($key, ':') === FALSE;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
/**
|
||||
* Use when there are no included resources but a Data object is required.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class NullIncludedData extends IncludedData {
|
||||
|
||||
/**
|
||||
* NullData constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
parent::__construct([]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
use Drupal\Component\Assertion\Inspector;
|
||||
use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* Represents resource data that should be omitted from the JSON:API document.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class OmittedData extends ResourceObjectData {
|
||||
|
||||
/**
|
||||
* OmittedData constructor.
|
||||
*
|
||||
* @param \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException[] $data
|
||||
* Resource objects that are the primary data for the response.
|
||||
*
|
||||
* @see \Drupal\jsonapi\JsonApiResource\Data::__construct
|
||||
*/
|
||||
public function __construct(array $data) {
|
||||
assert(Inspector::assertAllObjects($data, EntityAccessDeniedHttpException::class));
|
||||
parent::__construct($data, -1);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
use Drupal\Component\Utility\NestedArray;
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\jsonapi\JsonApiSpec;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Drupal\jsonapi\Routing\Routes;
|
||||
|
||||
/**
|
||||
* Represents references from one resource object to other resource object(s).
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class Relationship implements TopLevelDataInterface {
|
||||
|
||||
/**
|
||||
* The context resource object of the relationship.
|
||||
*
|
||||
* A relationship object represents references from a resource object in
|
||||
* which it’s defined to other resource objects. Respectively, the "context"
|
||||
* of the relationship and the "target(s)" of the relationship.
|
||||
*
|
||||
* A relationship object's context either comes from the resource object that
|
||||
* contains it or, in the case that the relationship object is accessed
|
||||
* directly via a relationship URL, from its `self` URL, which should identify
|
||||
* the resource to which it belongs.
|
||||
*
|
||||
* @var \Drupal\jsonapi\JsonApiResource\ResourceObject
|
||||
*
|
||||
* @see https://jsonapi.org/format/#document-resource-object-relationships
|
||||
* @see https://jsonapi.org/recommendations/#urls-relationships
|
||||
*/
|
||||
protected $context;
|
||||
|
||||
/**
|
||||
* The data of the relationship object.
|
||||
*
|
||||
* @var \Drupal\jsonapi\JsonApiResource\RelationshipData
|
||||
*/
|
||||
protected $data;
|
||||
|
||||
/**
|
||||
* The relationship's public field name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $fieldName;
|
||||
|
||||
/**
|
||||
* The relationship object's links.
|
||||
*
|
||||
* @var \Drupal\jsonapi\JsonApiResource\LinkCollection
|
||||
*/
|
||||
protected $links;
|
||||
|
||||
/**
|
||||
* The relationship object's meta member.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $meta;
|
||||
|
||||
/**
|
||||
* Relationship constructor.
|
||||
*
|
||||
* This constructor is protected by design. To create a new relationship, use
|
||||
* static::createFromEntityReferenceField().
|
||||
*
|
||||
* @param string $public_field_name
|
||||
* The public field name of the relationship field.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\RelationshipData $data
|
||||
* The relationship data.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
|
||||
* Any links for the resource object, if a `self` link is not
|
||||
* provided, one will be automatically added if the resource is locatable
|
||||
* and is not internal.
|
||||
* @param array $meta
|
||||
* Any relationship metadata.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $context
|
||||
* The relationship's context resource object. Use the
|
||||
* self::withContext() method to establish a context.
|
||||
*
|
||||
* @see \Drupal\jsonapi\JsonApiResource\Relationship::createFromEntityReferenceField()
|
||||
*/
|
||||
protected function __construct($public_field_name, RelationshipData $data, LinkCollection $links, array $meta, ResourceObject $context) {
|
||||
$this->fieldName = $public_field_name;
|
||||
$this->data = $data;
|
||||
$this->links = $links->withContext($this);
|
||||
$this->meta = $meta;
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Relationship from an entity reference field.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $context
|
||||
* The context resource object of the relationship to be created.
|
||||
* @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field
|
||||
* The entity reference field from which to create the relationship.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
|
||||
* (optional) Any extra links for the Relationship, if a `self` link is not
|
||||
* provided, one will be automatically added if the context resource is
|
||||
* locatable and is not internal.
|
||||
* @param array $meta
|
||||
* (optional) Any relationship metadata.
|
||||
*
|
||||
* @return static
|
||||
* An instantiated relationship object.
|
||||
*/
|
||||
public static function createFromEntityReferenceField(ResourceObject $context, EntityReferenceFieldItemListInterface $field, LinkCollection $links = NULL, array $meta = []) {
|
||||
$context_resource_type = $context->getResourceType();
|
||||
$resource_field = $context_resource_type->getFieldByInternalName($field->getName());
|
||||
return new static(
|
||||
$resource_field->getPublicName(),
|
||||
new RelationshipData(ResourceIdentifier::toResourceIdentifiers($field), $resource_field->hasOne() ? 1 : -1),
|
||||
static::buildLinkCollectionFromEntityReferenceField($context, $field, $links ?: new LinkCollection([])),
|
||||
$meta,
|
||||
$context
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets context resource object of the relationship.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\ResourceObject
|
||||
* The context ResourceObject.
|
||||
*
|
||||
* @see \Drupal\jsonapi\JsonApiResource\Relationship::$context
|
||||
*/
|
||||
public function getContext() {
|
||||
return $this->context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the relationship object's public field name.
|
||||
*
|
||||
* @return string
|
||||
* The relationship's field name.
|
||||
*/
|
||||
public function getFieldName() {
|
||||
return $this->fieldName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the relationship object's data.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\RelationshipData
|
||||
* The relationship's data.
|
||||
*/
|
||||
public function getData() {
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the relationship object's links.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\LinkCollection
|
||||
* The relationship object's links.
|
||||
*/
|
||||
public function getLinks() {
|
||||
return $this->links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the relationship object's metadata.
|
||||
*
|
||||
* @return array
|
||||
* The relationship object's metadata.
|
||||
*/
|
||||
public function getMeta() {
|
||||
return $this->meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getOmissions() {
|
||||
return new OmittedData([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getMergedLinks(LinkCollection $top_level_links) {
|
||||
// When directly fetching a relationship object, the relationship object's
|
||||
// links become the top-level object's links unless they've been
|
||||
// overridden. Overrides are especially important for the `self` link, which
|
||||
// must match the link that generated the response. For example, the
|
||||
// top-level `self` link might have an `include` query parameter that would
|
||||
// be lost otherwise.
|
||||
// See https://jsonapi.org/format/#fetching-relationships-responses-200 and
|
||||
// https://jsonapi.org/format/#document-top-level.
|
||||
return LinkCollection::merge($top_level_links, $this->getLinks()->filter(function ($key) use ($top_level_links) {
|
||||
return !$top_level_links->hasLinkWithKey($key);
|
||||
})->withContext($top_level_links->getContext()));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getMergedMeta(array $top_level_meta) {
|
||||
return NestedArray::mergeDeep($top_level_meta, $this->getMeta());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a LinkCollection for the given entity reference field.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $context
|
||||
* The context resource object of the relationship object.
|
||||
* @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $field
|
||||
* The entity reference field from which to create the links.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
|
||||
* Any extra links for the Relationship, if a `self` link is not provided,
|
||||
* one will be automatically added if the context resource is locatable and
|
||||
* is not internal.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\LinkCollection
|
||||
* The built links.
|
||||
*/
|
||||
protected static function buildLinkCollectionFromEntityReferenceField(ResourceObject $context, EntityReferenceFieldItemListInterface $field, LinkCollection $links) {
|
||||
$context_resource_type = $context->getResourceType();
|
||||
$public_field_name = $context_resource_type->getPublicName($field->getName());
|
||||
if ($context_resource_type->isLocatable() && !$context_resource_type->isInternal()) {
|
||||
$context_is_versionable = $context_resource_type->isVersionable();
|
||||
if (!$links->hasLinkWithKey('self')) {
|
||||
$route_name = Routes::getRouteName($context_resource_type, "$public_field_name.relationship.get");
|
||||
$self_link = Url::fromRoute($route_name, ['entity' => $context->getId()]);
|
||||
if ($context_is_versionable) {
|
||||
$self_link->setOption('query', [JsonApiSpec::VERSION_QUERY_PARAMETER => $context->getVersionIdentifier()]);
|
||||
}
|
||||
$links = $links->withLink('self', new Link(new CacheableMetadata(), $self_link, 'self'));
|
||||
}
|
||||
$has_non_internal_resource_type = array_reduce($context_resource_type->getRelatableResourceTypesByField($public_field_name), function ($carry, ResourceType $target) {
|
||||
return $carry ?: !$target->isInternal();
|
||||
}, FALSE);
|
||||
// If a `related` link was not provided, automatically generate one from
|
||||
// the relationship object to the collection resource with all of the
|
||||
// resources targeted by this relationship. However, that link should
|
||||
// *not* be generated if all of the relatable resources are internal.
|
||||
// That's because, in that case, a route will not exist for it.
|
||||
if (!$links->hasLinkWithKey('related') && $has_non_internal_resource_type) {
|
||||
$route_name = Routes::getRouteName($context_resource_type, "$public_field_name.related");
|
||||
$related_link = Url::fromRoute($route_name, ['entity' => $context->getId()]);
|
||||
if ($context_is_versionable) {
|
||||
$related_link->setOption('query', [JsonApiSpec::VERSION_QUERY_PARAMETER => $context->getVersionIdentifier()]);
|
||||
}
|
||||
$links = $links->withLink('related', new Link(new CacheableMetadata(), $related_link, 'related'));
|
||||
}
|
||||
}
|
||||
return $links;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
use Drupal\Component\Assertion\Inspector;
|
||||
|
||||
/**
|
||||
* Represents the data of a relationship object or relationship document.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class RelationshipData extends Data {
|
||||
|
||||
/**
|
||||
* RelationshipData constructor.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $data
|
||||
* Resource objects that are the primary data for the response.
|
||||
* @param int $cardinality
|
||||
* The number of ResourceIdentifiers that this collection may contain.
|
||||
*
|
||||
* @see \Drupal\jsonapi\JsonApiResource\Data::__construct
|
||||
*/
|
||||
public function __construct(array $data, $cardinality = -1) {
|
||||
assert(Inspector::assertAllObjects($data, ResourceIdentifier::class));
|
||||
parent::__construct($data, $cardinality);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
|
||||
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem;
|
||||
use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
|
||||
use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
|
||||
/**
|
||||
* Represents a JSON:API resource identifier object.
|
||||
*
|
||||
* The official JSON:API JSON-Schema document requires that no two resource
|
||||
* identifier objects are duplicates, however Drupal allows multiple entity
|
||||
* reference items to the same entity. Here, these are termed "parallel"
|
||||
* relationships (as in "parallel edges" of a graph).
|
||||
*
|
||||
* This class adds a concept of an @code arity @endcode member under each its
|
||||
* @code meta @endcode object. The value of this member is an integer that is
|
||||
* incremented by 1 (starting from 0) for each repeated resource identifier
|
||||
* sharing a common @code type @endcode and @code id @endcode.
|
||||
*
|
||||
* There are a number of helper methods to process the logic of dealing with
|
||||
* resource identifies with and without arity.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see http://jsonapi.org/format/#document-resource-object-relationships
|
||||
* @see https://github.com/json-api/json-api/pull/1156#issuecomment-325377995
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/2864680
|
||||
*/
|
||||
class ResourceIdentifier implements ResourceIdentifierInterface {
|
||||
|
||||
const ARITY_KEY = 'arity';
|
||||
|
||||
/**
|
||||
* The JSON:API resource type name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $resourceTypeName;
|
||||
|
||||
/**
|
||||
* The JSON:API resource type.
|
||||
*
|
||||
* @var \Drupal\jsonapi\ResourceType\ResourceType
|
||||
*/
|
||||
protected $resourceType;
|
||||
|
||||
/**
|
||||
* The resource ID.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $id;
|
||||
|
||||
/**
|
||||
* The relationship's metadata.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $meta;
|
||||
|
||||
/**
|
||||
* ResourceIdentifier constructor.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType|string $resource_type
|
||||
* The JSON:API resource type or a JSON:API resource type name.
|
||||
* @param string $id
|
||||
* The resource ID.
|
||||
* @param array $meta
|
||||
* Any metadata for the ResourceIdentifier.
|
||||
*/
|
||||
public function __construct($resource_type, $id, array $meta = []) {
|
||||
assert(is_string($resource_type) || $resource_type instanceof ResourceType);
|
||||
assert(!isset($meta[static::ARITY_KEY]) || is_int($meta[static::ARITY_KEY]) && $meta[static::ARITY_KEY] >= 0);
|
||||
$this->resourceTypeName = is_string($resource_type) ? $resource_type : $resource_type->getTypeName();
|
||||
$this->id = $id;
|
||||
$this->meta = $meta;
|
||||
if (!is_string($resource_type)) {
|
||||
$this->resourceType = $resource_type;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ResourceIdentifier's JSON:API resource type name.
|
||||
*
|
||||
* @return string
|
||||
* The JSON:API resource type name.
|
||||
*/
|
||||
public function getTypeName() {
|
||||
return $this->resourceTypeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getResourceType() {
|
||||
if (!isset($this->resourceType)) {
|
||||
/* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
|
||||
$resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
|
||||
$this->resourceType = $resource_type_repository->getByTypeName($this->getTypeName());
|
||||
}
|
||||
return $this->resourceType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ResourceIdentifier's ID.
|
||||
*
|
||||
* @return string
|
||||
* The ID.
|
||||
*/
|
||||
public function getId() {
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this ResourceIdentifier has an arity.
|
||||
*
|
||||
* @return int
|
||||
* TRUE if the ResourceIdentifier has an arity, FALSE otherwise.
|
||||
*/
|
||||
public function hasArity() {
|
||||
return isset($this->meta[static::ARITY_KEY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ResourceIdentifier's arity.
|
||||
*
|
||||
* One must check self::hasArity() before calling this method.
|
||||
*
|
||||
* @return int
|
||||
* The arity.
|
||||
*/
|
||||
public function getArity() {
|
||||
assert($this->hasArity());
|
||||
return $this->meta[static::ARITY_KEY];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy of the given ResourceIdentifier with the given arity.
|
||||
*
|
||||
* @param int $arity
|
||||
* The new arity; must be a non-negative integer.
|
||||
*
|
||||
* @return static
|
||||
* A newly created ResourceIdentifier with the given arity, otherwise
|
||||
* the same.
|
||||
*/
|
||||
public function withArity($arity) {
|
||||
return new static($this->getResourceType(), $this->getId(), [static::ARITY_KEY => $arity] + $this->getMeta());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the resource identifier objects metadata.
|
||||
*
|
||||
* @return array
|
||||
* The metadata.
|
||||
*/
|
||||
public function getMeta() {
|
||||
return $this->meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if two ResourceIdentifiers are the same.
|
||||
*
|
||||
* This method does not consider parallel relationships with different arity
|
||||
* values to be duplicates. For that, use the isParallel() method.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $a
|
||||
* The first ResourceIdentifier object.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $b
|
||||
* The second ResourceIdentifier object.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if both relationships reference the same resource and do not have
|
||||
* two distinct arity's, FALSE otherwise.
|
||||
*
|
||||
* For example, if $a and $b both reference the same resource identifier,
|
||||
* they can only be distinct if they *both* have an arity and those values
|
||||
* are not the same. If $a or $b does not have an arity, they will be
|
||||
* considered duplicates.
|
||||
*/
|
||||
public static function isDuplicate(ResourceIdentifier $a, ResourceIdentifier $b) {
|
||||
return static::compare($a, $b) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if two ResourceIdentifiers identify the same resource object.
|
||||
*
|
||||
* This method does not consider arity.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $a
|
||||
* The first ResourceIdentifier object.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $b
|
||||
* The second ResourceIdentifier object.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if both relationships reference the same resource, even when they
|
||||
* have differing arity values, FALSE otherwise.
|
||||
*/
|
||||
public static function isParallel(ResourceIdentifier $a, ResourceIdentifier $b) {
|
||||
return static::compare($a->withArity(0), $b->withArity(0)) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares ResourceIdentifier objects.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $a
|
||||
* The first ResourceIdentifier object.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $b
|
||||
* The second ResourceIdentifier object.
|
||||
*
|
||||
* @return int
|
||||
* Returns 0 if $a and $b are duplicate ResourceIdentifiers. If $a and $b
|
||||
* identify the same resource but have distinct arity values, then the
|
||||
* return value will be arity $a minus arity $b. -1 otherwise.
|
||||
*/
|
||||
public static function compare(ResourceIdentifier $a, ResourceIdentifier $b) {
|
||||
$result = strcmp(sprintf('%s:%s', $a->getTypeName(), $a->getId()), sprintf('%s:%s', $b->getTypeName(), $b->getId()));
|
||||
// If type and ID do not match, return their ordering.
|
||||
if ($result !== 0) {
|
||||
return $result;
|
||||
}
|
||||
// If both $a and $b have an arity, then return the order by arity.
|
||||
// Otherwise, they are considered equal.
|
||||
return $a->hasArity() && $b->hasArity()
|
||||
? $a->getArity() - $b->getArity()
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates an array of ResourceIdentifier objects.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $resource_identifiers
|
||||
* The list of ResourceIdentifiers to deduplicate.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[]
|
||||
* A deduplicated array of ResourceIdentifier objects.
|
||||
*
|
||||
* @see self::isDuplicate()
|
||||
*/
|
||||
public static function deduplicate(array $resource_identifiers) {
|
||||
return array_reduce(array_slice($resource_identifiers, 1), function ($deduplicated, $current) {
|
||||
assert($current instanceof static);
|
||||
return array_merge($deduplicated, array_reduce($deduplicated, function ($duplicate, $previous) use ($current) {
|
||||
return $duplicate ?: static::isDuplicate($previous, $current);
|
||||
}, FALSE) ? [] : [$current]);
|
||||
}, array_slice($resource_identifiers, 0, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an array of ResourceIdentifier objects is duplicate free.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceIdentifier[] $resource_identifiers
|
||||
* The list of ResourceIdentifiers to assess.
|
||||
*
|
||||
* @return bool
|
||||
* Whether all the given resource identifiers are unique.
|
||||
*/
|
||||
public static function areResourceIdentifiersUnique(array $resource_identifiers) {
|
||||
return count($resource_identifiers) === count(static::deduplicate($resource_identifiers));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ResourceIdentifier object.
|
||||
*
|
||||
* @param \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $item
|
||||
* The entity reference field item from which to create the relationship.
|
||||
* @param int $arity
|
||||
* (optional) The arity of the relationship.
|
||||
*
|
||||
* @return self
|
||||
* A new ResourceIdentifier object.
|
||||
*/
|
||||
public static function toResourceIdentifier(EntityReferenceItem $item, $arity = NULL) {
|
||||
$property_name = static::getDataReferencePropertyName($item);
|
||||
$target = $item->get($property_name)->getValue();
|
||||
if ($target === NULL) {
|
||||
return static::getVirtualOrMissingResourceIdentifier($item);
|
||||
}
|
||||
assert($target instanceof EntityInterface);
|
||||
/* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
|
||||
$resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
|
||||
$resource_type = $resource_type_repository->get($target->getEntityTypeId(), $target->bundle());
|
||||
// Remove unwanted properties from the meta value, usually 'entity'
|
||||
// and 'target_id'.
|
||||
$properties = TypedDataInternalPropertiesHelper::getNonInternalProperties($item);
|
||||
$meta = array_diff_key($properties, array_flip([$property_name, $item->getDataDefinition()->getMainPropertyName()]));
|
||||
if (!is_null($arity)) {
|
||||
$meta[static::ARITY_KEY] = $arity;
|
||||
}
|
||||
return new static($resource_type, $target->uuid(), $meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an array of ResourceIdentifier objects.
|
||||
*
|
||||
* @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items
|
||||
* The entity reference field items from which to create the relationship
|
||||
* array.
|
||||
*
|
||||
* @return self[]
|
||||
* An array of new ResourceIdentifier objects with appropriate arity values.
|
||||
*/
|
||||
public static function toResourceIdentifiers(EntityReferenceFieldItemListInterface $items) {
|
||||
$relationships = [];
|
||||
foreach ($items->filterEmptyItems() as $item) {
|
||||
// Create a ResourceIdentifier from the field item. This will make it
|
||||
// comparable with all previous field items. Here, it is assumed that the
|
||||
// resource identifier is unique so it has no arity. If a parallel
|
||||
// relationship is encountered, it will be assigned later.
|
||||
$relationship = static::toResourceIdentifier($item);
|
||||
if ($relationship->getResourceType()->isInternal()) {
|
||||
continue;
|
||||
}
|
||||
// Now, iterate over the previously seen resource identifiers in reverse
|
||||
// order. Reverse order is important so that when a parallel relationship
|
||||
// is encountered, it will have the highest arity value so the current
|
||||
// relationship's arity value can simply be incremented by one.
|
||||
/* @var self $existing */
|
||||
foreach (array_reverse($relationships, TRUE) as $index => $existing) {
|
||||
$is_parallel = static::isParallel($existing, $relationship);
|
||||
if ($is_parallel) {
|
||||
// A parallel relationship has been found. If the previous
|
||||
// relationship does not have an arity, it must now be assigned an
|
||||
// arity of 0.
|
||||
if (!$existing->hasArity()) {
|
||||
$relationships[$index] = $existing->withArity(0);
|
||||
}
|
||||
// Since the new ResourceIdentifier is parallel, it must have an arity
|
||||
// assigned to it that is the arity of the last parallel
|
||||
// relationship's arity + 1.
|
||||
$relationship = $relationship->withArity($relationships[$index]->getArity() + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Finally, append the relationship to the list of ResourceIdentifiers.
|
||||
$relationships[] = $relationship;
|
||||
}
|
||||
return $relationships;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an array of ResourceIdentifier objects with arity on every value.
|
||||
*
|
||||
* @param \Drupal\Core\Field\EntityReferenceFieldItemListInterface $items
|
||||
* The entity reference field items from which to create the relationship
|
||||
* array.
|
||||
*
|
||||
* @return self[]
|
||||
* An array of new ResourceIdentifier objects with appropriate arity values.
|
||||
* Unlike self::toResourceIdentifiers(), this method does not omit arity
|
||||
* when an identifier is not parallel to any other identifier.
|
||||
*/
|
||||
public static function toResourceIdentifiersWithArityRequired(EntityReferenceFieldItemListInterface $items) {
|
||||
return array_map(function (ResourceIdentifier $identifier) {
|
||||
return $identifier->hasArity() ? $identifier : $identifier->withArity(0);
|
||||
}, static::toResourceIdentifiers($items));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ResourceIdentifier object.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity from which to create the resource identifier.
|
||||
*
|
||||
* @return self
|
||||
* A new ResourceIdentifier object.
|
||||
*/
|
||||
public static function fromEntity(EntityInterface $entity) {
|
||||
/* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository */
|
||||
$resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
|
||||
$resource_type = $resource_type_repository->get($entity->getEntityTypeId(), $entity->bundle());
|
||||
return new static($resource_type, $entity->uuid());
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to determine which field item property contains an entity.
|
||||
*
|
||||
* @param \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $item
|
||||
* The entity reference item for which to determine the entity property
|
||||
* name.
|
||||
*
|
||||
* @return string
|
||||
* The property name which has an entity as its value.
|
||||
*/
|
||||
protected static function getDataReferencePropertyName(EntityReferenceItem $item) {
|
||||
foreach ($item->getDataDefinition()->getPropertyDefinitions() as $property_name => $property_definition) {
|
||||
if ($property_definition instanceof DataReferenceDefinitionInterface) {
|
||||
return $property_name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ResourceIdentifier for a NULL or FALSE entity reference item.
|
||||
*
|
||||
* @param \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem $item
|
||||
* The entity reference field item.
|
||||
*
|
||||
* @return self
|
||||
* A new ResourceIdentifier object.
|
||||
*/
|
||||
protected static function getVirtualOrMissingResourceIdentifier(EntityReferenceItem $item) {
|
||||
$resource_type_repository = \Drupal::service('jsonapi.resource_type.repository');
|
||||
$property_name = static::getDataReferencePropertyName($item);
|
||||
$value = $item->get($property_name)->getValue();
|
||||
assert($value === NULL);
|
||||
$field = $item->getParent();
|
||||
assert($field instanceof EntityReferenceFieldItemListInterface);
|
||||
$host_entity = $field->getEntity();
|
||||
assert($host_entity instanceof EntityInterface);
|
||||
$resource_type = $resource_type_repository->get($host_entity->getEntityTypeId(), $host_entity->bundle());
|
||||
assert($resource_type instanceof ResourceType);
|
||||
$relatable_resource_types = $resource_type->getRelatableResourceTypesByField($resource_type->getPublicName($field->getName()));
|
||||
assert(!empty($relatable_resource_types));
|
||||
$get_metadata = function ($type) {
|
||||
return [
|
||||
'links' => [
|
||||
'help' => [
|
||||
'href' => "https://www.drupal.org/docs/8/modules/json-api/core-concepts#$type",
|
||||
'meta' => [
|
||||
'about' => "Usage and meaning of the '$type' resource identifier.",
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
};
|
||||
$resource_type = reset($relatable_resource_types);
|
||||
// A non-empty entity reference field that refers to a non-existent entity
|
||||
// is not a data integrity problem. For example, Term entities' "parent"
|
||||
// entity reference field uses target_id zero to refer to the non-existent
|
||||
// "<root>" term. And references to entities that no longer exist are not
|
||||
// cleaned up by Drupal; hence we map it to a "missing" resource.
|
||||
if ($field->getFieldDefinition()->getSetting('target_type') === 'taxonomy_term' && $item->get('target_id')->getCastedValue() === 0) {
|
||||
if (count($relatable_resource_types) !== 1) {
|
||||
throw new \RuntimeException('Relationships to virtual resources are possible only if a single resource type is relatable.');
|
||||
}
|
||||
return new static($resource_type, 'virtual', $get_metadata('virtual'));
|
||||
}
|
||||
else {
|
||||
// In case of a dangling reference, it is impossible to determine which
|
||||
// resource type it used to reference, because that requires knowing the
|
||||
// referenced bundle, which Drupal does not store.
|
||||
// If we can reliably determine the resource type of the dangling
|
||||
// reference, use it; otherwise conjure a fake resource type out of thin
|
||||
// air, one that indicates we don't know the bundle.
|
||||
$resource_type = count($relatable_resource_types) > 1
|
||||
? new ResourceType('?', '?', '')
|
||||
: reset($relatable_resource_types);
|
||||
return new static($resource_type, 'missing', $get_metadata('missing'));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
/**
|
||||
* An interface for identifying a related resource.
|
||||
*
|
||||
* Implement this interface when an object is a stand-in for an Entity object.
|
||||
* For example, \Drupal\jsonapi\Exception\EntityAccessDeniedHttpException
|
||||
* implements this interface because it often replaces an entity in a JSON:API
|
||||
* Data object.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
interface ResourceIdentifierInterface {
|
||||
|
||||
/**
|
||||
* Gets the resource identifier's ID.
|
||||
*
|
||||
* @return string
|
||||
* A resource ID.
|
||||
*/
|
||||
public function getId();
|
||||
|
||||
/**
|
||||
* Gets the resource identifier's JSON:API resource type name.
|
||||
*
|
||||
* @return string
|
||||
* The JSON:API resource type name.
|
||||
*/
|
||||
public function getTypeName();
|
||||
|
||||
/**
|
||||
* Gets the resource identifier's JSON:API resource type.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceType
|
||||
* The JSON:API resource type.
|
||||
*/
|
||||
public function getResourceType();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
/**
|
||||
* Used to associate an object like an exception to a particular resource.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see \Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface
|
||||
*/
|
||||
trait ResourceIdentifierTrait {
|
||||
|
||||
/**
|
||||
* A ResourceIdentifier object.
|
||||
*
|
||||
* @var \Drupal\jsonapi\JsonApiResource\ResourceIdentifier
|
||||
*/
|
||||
protected $resourceIdentifier;
|
||||
|
||||
/**
|
||||
* The JSON:API resource type of the identified resource object.
|
||||
*
|
||||
* @var \Drupal\jsonapi\ResourceType\ResourceType
|
||||
*/
|
||||
protected $resourceType;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getId() {
|
||||
return $this->resourceIdentifier->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getTypeName() {
|
||||
return $this->resourceIdentifier->getTypeName();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getResourceType() {
|
||||
if (!isset($this->resourceType)) {
|
||||
$this->resourceType = $this->resourceIdentifier->getResourceType();
|
||||
}
|
||||
return $this->resourceType;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
use Drupal\Core\Cache\CacheableDependencyInterface;
|
||||
use Drupal\Core\Cache\CacheableDependencyTrait;
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Config\Entity\ConfigEntityInterface;
|
||||
use Drupal\Core\Entity\ContentEntityInterface;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\RevisionableInterface;
|
||||
use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\jsonapi\JsonApiSpec;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Drupal\jsonapi\Revisions\VersionByRel;
|
||||
use Drupal\jsonapi\Routing\Routes;
|
||||
use Drupal\user\UserInterface;
|
||||
|
||||
/**
|
||||
* Represents a JSON:API resource object.
|
||||
*
|
||||
* This value object wraps a Drupal entity so that it can carry a JSON:API
|
||||
* resource type object alongside it. It also helps abstract away differences
|
||||
* between config and content entities within the JSON:API codebase.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class ResourceObject implements CacheableDependencyInterface, ResourceIdentifierInterface {
|
||||
|
||||
use CacheableDependencyTrait;
|
||||
use ResourceIdentifierTrait;
|
||||
|
||||
/**
|
||||
* The resource object's version identifier.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $versionIdentifier;
|
||||
|
||||
/**
|
||||
* The object's fields.
|
||||
*
|
||||
* This refers to "fields" in the JSON:API sense of the word. Config entities
|
||||
* do not have real fields, so in that case, this will be an array of values
|
||||
* for config entity attributes.
|
||||
*
|
||||
* @var \Drupal\Core\Field\FieldItemListInterface[]|mixed[]
|
||||
*/
|
||||
protected $fields;
|
||||
|
||||
/**
|
||||
* The resource object's links.
|
||||
*
|
||||
* @var \Drupal\jsonapi\JsonApiResource\LinkCollection
|
||||
*/
|
||||
protected $links;
|
||||
|
||||
/**
|
||||
* ResourceObject constructor.
|
||||
*
|
||||
* @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
|
||||
* The cacheability for the resource object.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type of the resource object.
|
||||
* @param string $id
|
||||
* The resource object's ID.
|
||||
* @param mixed|null $revision_id
|
||||
* The resource object's version identifier. NULL, if the resource object is
|
||||
* not versionable.
|
||||
* @param array $fields
|
||||
* An array of the resource object's fields, keyed by public field name.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
|
||||
* The links for the resource object.
|
||||
*/
|
||||
public function __construct(CacheableDependencyInterface $cacheability, ResourceType $resource_type, $id, $revision_id, array $fields, LinkCollection $links) {
|
||||
assert(is_null($revision_id) || $resource_type->isVersionable());
|
||||
$this->setCacheability($cacheability);
|
||||
$this->resourceType = $resource_type;
|
||||
$this->resourceIdentifier = new ResourceIdentifier($resource_type, $id);
|
||||
$this->versionIdentifier = $revision_id ? 'id:' . $revision_id : NULL;
|
||||
$this->fields = $fields;
|
||||
$this->links = $links->withContext($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ResourceObject from an entity.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type of the resource object.
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity to be represented by this resource object.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
|
||||
* (optional) Any links for the resource object, if a `self` link is not
|
||||
* provided, one will be automatically added if the resource is locatable
|
||||
* and is not an internal entity.
|
||||
*
|
||||
* @return static
|
||||
* An instantiated resource object.
|
||||
*/
|
||||
public static function createFromEntity(ResourceType $resource_type, EntityInterface $entity, LinkCollection $links = NULL) {
|
||||
return new static(
|
||||
$entity,
|
||||
$resource_type,
|
||||
$entity->uuid(),
|
||||
$resource_type->isVersionable() && $entity instanceof RevisionableInterface ? $entity->getRevisionId() : NULL,
|
||||
static::extractFieldsFromEntity($resource_type, $entity),
|
||||
static::buildLinksFromEntity($resource_type, $entity, $links ?: new LinkCollection([]))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the resource object has the given field.
|
||||
*
|
||||
* @param string $public_field_name
|
||||
* A public field name.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the resource object has the given field, FALSE otherwise.
|
||||
*/
|
||||
public function hasField($public_field_name) {
|
||||
return isset($this->fields[$public_field_name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the given field.
|
||||
*
|
||||
* @param string $public_field_name
|
||||
* A public field name.
|
||||
*
|
||||
* @return mixed|\Drupal\Core\Field\FieldItemListInterface|null
|
||||
* The field or NULL if the resource object does not have the given field.
|
||||
*
|
||||
* @see ::extractFields()
|
||||
*/
|
||||
public function getField($public_field_name) {
|
||||
return $this->hasField($public_field_name) ? $this->fields[$public_field_name] : NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ResourceObject's fields.
|
||||
*
|
||||
* @return array
|
||||
* The resource object's fields, keyed by public field name.
|
||||
*
|
||||
* @see ::extractFields()
|
||||
*/
|
||||
public function getFields() {
|
||||
return $this->fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ResourceObject's links.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\LinkCollection
|
||||
* The resource object's links.
|
||||
*/
|
||||
public function getLinks() {
|
||||
return $this->links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a version identifier for the ResourceObject.
|
||||
*
|
||||
* @return string
|
||||
* The version identifier of the resource object, if the resource type is
|
||||
* versionable.
|
||||
*/
|
||||
public function getVersionIdentifier() {
|
||||
if (!$this->resourceType->isVersionable()) {
|
||||
throw new \LogicException('Cannot get a version identifier for a non-versionable resource.');
|
||||
}
|
||||
return $this->versionIdentifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a Url for the ResourceObject.
|
||||
*
|
||||
* @return \Drupal\Core\Url
|
||||
* The URL for the identified resource object.
|
||||
*
|
||||
* @throws \LogicException
|
||||
* Thrown if the resource object is not locatable.
|
||||
*
|
||||
* @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository::isLocatableResourceType()
|
||||
*/
|
||||
public function toUrl() {
|
||||
foreach ($this->links as $key => $link) {
|
||||
if ($key === 'self') {
|
||||
$first = reset($link);
|
||||
return $first->getUri();
|
||||
}
|
||||
}
|
||||
throw new \LogicException('A Url does not exist for this resource object because its resource type is not locatable.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the entity's fields.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type of the given entity.
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity from which fields should be extracted.
|
||||
*
|
||||
* @return mixed|\Drupal\Core\Field\FieldItemListInterface[]
|
||||
* If the resource object represents a content entity, the fields will be
|
||||
* objects satisfying FieldItemListInterface. If it represents a config
|
||||
* entity, the fields will be scalar values or arrays.
|
||||
*/
|
||||
protected static function extractFieldsFromEntity(ResourceType $resource_type, EntityInterface $entity) {
|
||||
assert($entity instanceof ContentEntityInterface || $entity instanceof ConfigEntityInterface);
|
||||
return $entity instanceof ContentEntityInterface
|
||||
? static::extractContentEntityFields($resource_type, $entity)
|
||||
: static::extractConfigEntityFields($resource_type, $entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a LinkCollection for the given entity.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type of the given entity.
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity for which to build links.
|
||||
* @param \Drupal\jsonapi\JsonApiResource\LinkCollection $links
|
||||
* (optional) Any extra links for the resource object, if a `self` link is
|
||||
* not provided, one will be automatically added if the resource is
|
||||
* locatable and is not an internal entity.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\LinkCollection
|
||||
* The built links.
|
||||
*/
|
||||
protected static function buildLinksFromEntity(ResourceType $resource_type, EntityInterface $entity, LinkCollection $links) {
|
||||
if ($resource_type->isLocatable() && !$resource_type->isInternal()) {
|
||||
$self_url = Url::fromRoute(Routes::getRouteName($resource_type, 'individual'), ['entity' => $entity->uuid()]);
|
||||
if ($resource_type->isVersionable()) {
|
||||
assert($entity instanceof RevisionableInterface);
|
||||
if (!$links->hasLinkWithKey('self')) {
|
||||
// If the resource is versionable, the `self` link should be the exact
|
||||
// link for the represented version. This helps a client track
|
||||
// revision changes and to disambiguate resource objects with the same
|
||||
// `type` and `id` in a `version-history` collection.
|
||||
$self_with_version_url = $self_url->setOption('query', [JsonApiSpec::VERSION_QUERY_PARAMETER => 'id:' . $entity->getRevisionId()]);
|
||||
$links = $links->withLink('self', new Link(new CacheableMetadata(), $self_with_version_url, 'self'));
|
||||
}
|
||||
if (!$entity->isDefaultRevision()) {
|
||||
$latest_version_url = $self_url->setOption('query', [JsonApiSpec::VERSION_QUERY_PARAMETER => 'rel:' . VersionByRel::LATEST_VERSION]);
|
||||
$links = $links->withLink(VersionByRel::LATEST_VERSION, new Link(new CacheableMetadata(), $latest_version_url, VersionByRel::LATEST_VERSION));
|
||||
}
|
||||
if (!$entity->isLatestRevision()) {
|
||||
$working_copy_url = $self_url->setOption('query', [JsonApiSpec::VERSION_QUERY_PARAMETER => 'rel:' . VersionByRel::WORKING_COPY]);
|
||||
$links = $links->withLink(VersionByRel::WORKING_COPY, new Link(new CacheableMetadata(), $working_copy_url, VersionByRel::WORKING_COPY));
|
||||
}
|
||||
}
|
||||
if (!$links->hasLinkWithKey('self')) {
|
||||
$links = $links->withLink('self', new Link(new CacheableMetadata(), $self_url, 'self'));
|
||||
}
|
||||
}
|
||||
return $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a content entity's fields.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type of the given entity.
|
||||
* @param \Drupal\Core\Entity\ContentEntityInterface $entity
|
||||
* The config entity from which fields should be extracted.
|
||||
*
|
||||
* @return \Drupal\Core\Field\FieldItemListInterface[]
|
||||
* The fields extracted from a content entity.
|
||||
*/
|
||||
protected static function extractContentEntityFields(ResourceType $resource_type, ContentEntityInterface $entity) {
|
||||
$output = [];
|
||||
$fields = TypedDataInternalPropertiesHelper::getNonInternalProperties($entity->getTypedData());
|
||||
// Filter the array based on the field names.
|
||||
$enabled_field_names = array_filter(
|
||||
array_keys($fields),
|
||||
[$resource_type, 'isFieldEnabled']
|
||||
);
|
||||
|
||||
// Special handling for user entities that allows a JSON:API user agent to
|
||||
// access the display name of a user. For example, this is useful when
|
||||
// displaying the name of a node's author.
|
||||
// @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254.
|
||||
$entity_type = $entity->getEntityType();
|
||||
if ($entity_type->id() == 'user' && $resource_type->isFieldEnabled('display_name')) {
|
||||
assert($entity instanceof UserInterface);
|
||||
$display_name = $resource_type->getPublicName('display_name');
|
||||
$output[$display_name] = $entity->getDisplayName();
|
||||
}
|
||||
|
||||
// Return a sub-array of $output containing the keys in $enabled_fields.
|
||||
$input = array_intersect_key($fields, array_flip($enabled_field_names));
|
||||
foreach ($input as $field_name => $field_value) {
|
||||
$public_field_name = $resource_type->getPublicName($field_name);
|
||||
$output[$public_field_name] = $field_value;
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the entity type's (internal) label field name.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity from which fields should be extracted.
|
||||
*
|
||||
* @return string
|
||||
* The label field name.
|
||||
*/
|
||||
protected static function getLabelFieldName(EntityInterface $entity) {
|
||||
$label_field_name = $entity->getEntityType()->getKey('label');
|
||||
// Special handling for user entities that allows a JSON:API user agent to
|
||||
// access the display name of a user. This is useful when displaying the
|
||||
// name of a node's author.
|
||||
// @see \Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields()
|
||||
// @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254.
|
||||
if ($entity->getEntityTypeId() === 'user') {
|
||||
$label_field_name = 'display_name';
|
||||
}
|
||||
return $label_field_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a config entity's fields.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type of the given entity.
|
||||
* @param \Drupal\Core\Config\Entity\ConfigEntityInterface $entity
|
||||
* The config entity from which fields should be extracted.
|
||||
*
|
||||
* @return array
|
||||
* The fields extracted from a config entity.
|
||||
*/
|
||||
protected static function extractConfigEntityFields(ResourceType $resource_type, ConfigEntityInterface $entity) {
|
||||
$enabled_public_fields = [];
|
||||
$fields = $entity->toArray();
|
||||
// Filter the array based on the field names.
|
||||
$enabled_field_names = array_filter(array_keys($fields), function ($internal_field_name) use ($resource_type) {
|
||||
// Config entities have "fields" which aren't known to the resource type,
|
||||
// these fields should not be excluded because they cannot be enabled or
|
||||
// disabled.
|
||||
return !$resource_type->hasField($internal_field_name) || $resource_type->isFieldEnabled($internal_field_name);
|
||||
});
|
||||
// Return a sub-array of $output containing the keys in $enabled_fields.
|
||||
$input = array_intersect_key($fields, array_flip($enabled_field_names));
|
||||
/* @var \Drupal\Core\Config\Entity\ConfigEntityInterface $entity */
|
||||
foreach ($input as $field_name => $field_value) {
|
||||
$public_field_name = $resource_type->getPublicName($field_name);
|
||||
$enabled_public_fields[$public_field_name] = $field_value;
|
||||
}
|
||||
return $enabled_public_fields;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
use Drupal\Component\Assertion\Inspector;
|
||||
use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
|
||||
|
||||
/**
|
||||
* Represents the primary data for individual and collection documents.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
|
||||
* may change at any time and could break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class ResourceObjectData extends Data implements TopLevelDataInterface {
|
||||
|
||||
/**
|
||||
* ResourceObjectData constructor.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject[]|\Drupal\jsonapi\Exception\EntityAccessDeniedHttpException[] $data
|
||||
* Resource objects that are the primary data for the response.
|
||||
* @param int $cardinality
|
||||
* The number of resources that this collection may contain.
|
||||
*
|
||||
* @see \Drupal\jsonapi\JsonApiResource\Data::__construct
|
||||
*/
|
||||
public function __construct($data, $cardinality = -1) {
|
||||
assert(Inspector::assertAllObjects($data, ResourceObject::class, EntityAccessDeniedHttpException::class));
|
||||
parent::__construct($data, $cardinality);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getData() {
|
||||
return $this->getAccessible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets only data to be exposed.
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function getAccessible() {
|
||||
$accessible_data = [];
|
||||
foreach ($this->data as $resource_object) {
|
||||
if (!$resource_object instanceof EntityAccessDeniedHttpException) {
|
||||
$accessible_data[] = $resource_object;
|
||||
}
|
||||
}
|
||||
return new static($accessible_data, $this->cardinality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets only data to be omitted.
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function getOmissions() {
|
||||
$omitted_data = [];
|
||||
foreach ($this->data as $resource_object) {
|
||||
if ($resource_object instanceof EntityAccessDeniedHttpException) {
|
||||
$omitted_data[] = $resource_object;
|
||||
}
|
||||
}
|
||||
return new OmittedData($omitted_data);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getMergedLinks(LinkCollection $top_level_links) {
|
||||
return $top_level_links;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getMergedMeta(array $top_level_meta) {
|
||||
return $top_level_meta;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\JsonApiResource;
|
||||
|
||||
/**
|
||||
* Interface for objects that can appear as top-level object data.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
interface TopLevelDataInterface {
|
||||
|
||||
/**
|
||||
* Returns the data for the top-level data member of a JSON:API document.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\Data
|
||||
* The top-level data.
|
||||
*/
|
||||
public function getData();
|
||||
|
||||
/**
|
||||
* Returns the data that was omitted from the JSON:API document.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\OmittedData
|
||||
* The omitted data.
|
||||
*/
|
||||
public function getOmissions();
|
||||
|
||||
/**
|
||||
* Merges the object's links with the top-level links.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\LinkCollection $top_level_links
|
||||
* The top-level links to merge.
|
||||
*
|
||||
* @return \Drupal\jsonapi\JsonApiResource\LinkCollection
|
||||
* The merged links.
|
||||
*/
|
||||
public function getMergedLinks(LinkCollection $top_level_links);
|
||||
|
||||
/**
|
||||
* Merges the object's meta member with the top-level meta member.
|
||||
*
|
||||
* @param array $top_level_meta
|
||||
* The top-level links to merge.
|
||||
*
|
||||
* @return array
|
||||
* The merged meta member.
|
||||
*/
|
||||
public function getMergedMeta(array $top_level_meta);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi;
|
||||
|
||||
/**
|
||||
* Defines constants used for compliance with the JSON:API specification.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see http://jsonapi.org/format
|
||||
*/
|
||||
class JsonApiSpec {
|
||||
|
||||
/**
|
||||
* The minimum supported specification version.
|
||||
*
|
||||
* @see http://jsonapi.org/format/#document-jsonapi-object
|
||||
*/
|
||||
const SUPPORTED_SPECIFICATION_VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* The URI of the supported specification document.
|
||||
*/
|
||||
const SUPPORTED_SPECIFICATION_PERMALINK = 'http://jsonapi.org/format/1.0/';
|
||||
|
||||
/**
|
||||
* Member name: globally allowed characters.
|
||||
*
|
||||
* U+0080 and above (non-ASCII Unicode characters) are allowed, but are not
|
||||
* URL-safe. It is RECOMMENDED to not use them.
|
||||
*
|
||||
* A character class, for use in regular expressions.
|
||||
*
|
||||
* @see http://jsonapi.org/format/#document-member-names-allowed-characters
|
||||
* @see http://php.net/manual/en/regexp.reference.character-classes.php
|
||||
*/
|
||||
const MEMBER_NAME_GLOBALLY_ALLOWED_CHARACTER_CLASS = '[a-zA-Z0-9\x{80}-\x{10FFFF}]';
|
||||
|
||||
/**
|
||||
* Member name: allowed characters except as the first or last character.
|
||||
*
|
||||
* Space (U+0020) is allowed, but is not URL-safe. It is RECOMMENDED to not
|
||||
* use it.
|
||||
*
|
||||
* A character class, for use in regular expressions.
|
||||
*
|
||||
* @see http://jsonapi.org/format/#document-member-names-allowed-characters
|
||||
* @see http://php.net/manual/en/regexp.reference.character-classes.php
|
||||
*/
|
||||
const MEMBER_NAME_INNER_ALLOWED_CHARACTERS = "[a-zA-Z0-9\x{80}-\x{10FFFF}\-_ ]";
|
||||
|
||||
/**
|
||||
* Checks whether the given member name is valid.
|
||||
*
|
||||
* Requirements:
|
||||
* - it MUST contain at least one character.
|
||||
* - it MUST contain only the allowed characters
|
||||
* - it MUST start and end with a "globally allowed character"
|
||||
*
|
||||
* @param string $member_name
|
||||
* A member name to validate.
|
||||
*
|
||||
* @return bool
|
||||
* Whether the given member name is in compliance with the JSON:API
|
||||
* specification.
|
||||
*
|
||||
* @see http://jsonapi.org/format/#document-member-names
|
||||
*/
|
||||
public static function isValidMemberName($member_name) {
|
||||
// @todo When D8 requires PHP >=5.6, move to a MEMBER_NAME_REGEXP constant.
|
||||
static $regexp;
|
||||
// @codingStandardsIgnoreStart
|
||||
if (!isset($regexp)) {
|
||||
$regexp = '/^' .
|
||||
// First character must be "globally allowed". Length must be >=1.
|
||||
self::MEMBER_NAME_GLOBALLY_ALLOWED_CHARACTER_CLASS . '{1}' .
|
||||
'(' .
|
||||
// As many non-globally allowed characters as desired.
|
||||
self::MEMBER_NAME_INNER_ALLOWED_CHARACTERS . '*' .
|
||||
// If length > 1, then it must end in a "globally allowed" character.
|
||||
self::MEMBER_NAME_GLOBALLY_ALLOWED_CHARACTER_CLASS . '{1}' .
|
||||
// >1 characters is optional.
|
||||
')?' .
|
||||
'$/u';
|
||||
}
|
||||
// @codingStandardsIgnoreEnd
|
||||
|
||||
return preg_match($regexp, $member_name) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reserved (official) query parameters.
|
||||
*
|
||||
* @todo When D8 requires PHP >= 5.6, convert to an array.
|
||||
*/
|
||||
const RESERVED_QUERY_PARAMETERS = 'filter|sort|page|fields|include';
|
||||
|
||||
/**
|
||||
* The query parameter for providing a version (revision) value.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const VERSION_QUERY_PARAMETER = 'resourceVersion';
|
||||
|
||||
/**
|
||||
* Gets the reserved (official) JSON:API query parameters.
|
||||
*
|
||||
* @return string[]
|
||||
* Gets the query parameters reserved by the specification.
|
||||
*/
|
||||
public static function getReservedQueryParameters() {
|
||||
return explode('|', static::RESERVED_QUERY_PARAMETERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given custom query parameter name is valid.
|
||||
*
|
||||
* A custom query parameter name must be a valid member name, with one
|
||||
* additional requirement: it MUST contain at least one non a-z character.
|
||||
*
|
||||
* Requirements:
|
||||
* - it MUST contain at least one character.
|
||||
* - it MUST contain only the allowed characters
|
||||
* - it MUST start and end with a "globally allowed character"
|
||||
* - it MUST contain at least none a-z (U+0061 to U+007A) character
|
||||
*
|
||||
* It is RECOMMENDED that a hyphen (U+002D), underscore (U+005F) or capital
|
||||
* letter is used (i.e. camelCasing).
|
||||
*
|
||||
* @param string $custom_query_parameter_name
|
||||
* A custom query parameter name to validate.
|
||||
*
|
||||
* @return bool
|
||||
* Whether the given query parameter is in compliane with the JSON:API
|
||||
* specification.
|
||||
*
|
||||
* @see http://jsonapi.org/format/#query-parameters
|
||||
*/
|
||||
public static function isValidCustomQueryParameter($custom_query_parameter_name) {
|
||||
return static::isValidMemberName($custom_query_parameter_name) && preg_match('/[^a-z]/u', $custom_query_parameter_name) === 1;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi;
|
||||
|
||||
use Drupal\Core\DependencyInjection\ContainerBuilder;
|
||||
use Drupal\Core\DependencyInjection\ServiceModifierInterface;
|
||||
use Drupal\Core\DependencyInjection\ServiceProviderInterface;
|
||||
use Drupal\Core\StackMiddleware\NegotiationMiddleware;
|
||||
use Drupal\jsonapi\DependencyInjection\Compiler\RegisterSerializationClassesCompilerPass;
|
||||
|
||||
/**
|
||||
* Adds 'api_json' as known format and prevents its use in the REST module.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class JsonapiServiceProvider implements ServiceModifierInterface, ServiceProviderInterface {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function alter(ContainerBuilder $container) {
|
||||
if ($container->has('http_middleware.negotiation') && is_a($container->getDefinition('http_middleware.negotiation')->getClass(), NegotiationMiddleware::class, 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function register(ContainerBuilder $container) {
|
||||
$container->addCompilerPass(new RegisterSerializationClassesCompilerPass());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Core\Config\Entity\ConfigEntityInterface;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
|
||||
/**
|
||||
* Converts the Drupal config entity object to a JSON:API array structure.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
final class ConfigEntityDenormalizer extends EntityDenormalizerBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = ConfigEntityInterface::class;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function prepareInput(array $data, ResourceType $resource_type, $format, array $context) {
|
||||
$prepared = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$prepared[$resource_type->getInternalName($key)] = $value;
|
||||
}
|
||||
return $prepared;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Core\Entity\ContentEntityInterface;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* Converts a JSON:API array structure into a Drupal entity object.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
final class ContentEntityDenormalizer extends EntityDenormalizerBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = ContentEntityInterface::class;
|
||||
|
||||
/**
|
||||
* Prepares the input data to create the entity.
|
||||
*
|
||||
* @param array $data
|
||||
* The input data to modify.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* Contains the info about the resource type.
|
||||
* @param string $format
|
||||
* Format the given data was extracted from.
|
||||
* @param array $context
|
||||
* Options available to the denormalizer.
|
||||
*
|
||||
* @return array
|
||||
* The modified input data.
|
||||
*/
|
||||
protected function prepareInput(array $data, ResourceType $resource_type, $format, array $context) {
|
||||
$data_internal = [];
|
||||
|
||||
$field_map = $this->fieldManager->getFieldMap()[$resource_type->getEntityTypeId()];
|
||||
|
||||
$entity_type_id = $resource_type->getEntityTypeId();
|
||||
$entity_type_definition = $this->entityTypeManager->getDefinition($entity_type_id);
|
||||
$bundle_key = $entity_type_definition->getKey('bundle');
|
||||
$uuid_key = $entity_type_definition->getKey('uuid');
|
||||
|
||||
// User resource objects contain a read-only attribute that is not a real
|
||||
// field on the user entity type.
|
||||
// @see \Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields()
|
||||
// @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254.
|
||||
if ($entity_type_id === 'user') {
|
||||
$data = array_diff_key($data, array_flip([$resource_type->getPublicName('display_name')]));
|
||||
}
|
||||
|
||||
// Translate the public fields into the entity fields.
|
||||
foreach ($data as $public_field_name => $field_value) {
|
||||
$internal_name = $resource_type->getInternalName($public_field_name);
|
||||
|
||||
// Skip any disabled field, except the always required bundle key and
|
||||
// required-in-case-of-PATCHing uuid key.
|
||||
// @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository::getFieldMapping()
|
||||
if ($resource_type->hasField($internal_name) && !$resource_type->isFieldEnabled($internal_name) && $bundle_key !== $internal_name && $uuid_key !== $internal_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($field_map[$internal_name]) || !in_array($resource_type->getBundle(), $field_map[$internal_name]['bundles'], TRUE)) {
|
||||
throw new UnprocessableEntityHttpException(sprintf(
|
||||
'The attribute %s does not exist on the %s resource type.',
|
||||
$internal_name,
|
||||
$resource_type->getTypeName()
|
||||
));
|
||||
}
|
||||
|
||||
$field_type = $field_map[$internal_name]['type'];
|
||||
$field_class = $this->pluginManager->getDefinition($field_type)['list_class'];
|
||||
|
||||
$field_denormalization_context = array_merge($context, [
|
||||
'field_type' => $field_type,
|
||||
'field_name' => $internal_name,
|
||||
'field_definition' => $this->fieldManager->getFieldDefinitions($resource_type->getEntityTypeId(), $resource_type->getBundle())[$internal_name],
|
||||
]);
|
||||
$data_internal[$internal_name] = $this->serializer->denormalize($field_value, $field_class, $format, $field_denormalization_context);
|
||||
}
|
||||
|
||||
return $data_internal;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\jsonapi\JsonApiResource\Data;
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
|
||||
|
||||
/**
|
||||
* Normalizes JSON:API Data objects.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class DataNormalizer extends NormalizerBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = Data::class;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function normalize($object, $format = NULL, array $context = []) {
|
||||
assert($object instanceof Data);
|
||||
$cacheable_normalizations = array_map(function ($resource) use ($format, $context) {
|
||||
return $this->serializer->normalize($resource, $format, $context);
|
||||
}, $object->toArray());
|
||||
return $object->getCardinality() === 1
|
||||
? array_shift($cacheable_normalizations) ?: CacheableNormalization::permanent(NULL)
|
||||
: CacheableNormalization::aggregate($cacheable_normalizations);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\jsonapi\Exception\EntityAccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
* Normalizes an EntityAccessDeniedException.
|
||||
*
|
||||
* Normalizes an EntityAccessDeniedException in compliance with the JSON:API
|
||||
* specification. A source pointer is added to help client applications report
|
||||
* which entity was access denied.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see http://jsonapi.org/format/#error-objects
|
||||
*/
|
||||
class EntityAccessDeniedHttpExceptionNormalizer extends HttpExceptionNormalizer {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = EntityAccessDeniedHttpException::class;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function buildErrorObjects(HttpException $exception) {
|
||||
$errors = parent::buildErrorObjects($exception);
|
||||
|
||||
if ($exception instanceof EntityAccessDeniedHttpException) {
|
||||
$error = $exception->getError();
|
||||
/** @var \Drupal\Core\Entity\EntityInterface $entity */
|
||||
$entity = $error['entity'];
|
||||
$pointer = $error['pointer'];
|
||||
$reason = $error['reason'];
|
||||
$relationship_field = isset($error['relationship_field'])
|
||||
? $error['relationship_field']
|
||||
: NULL;
|
||||
|
||||
if (isset($entity)) {
|
||||
$entity_type_id = $entity->getEntityTypeId();
|
||||
$bundle = $entity->bundle();
|
||||
/* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
|
||||
$resource_type = \Drupal::service('jsonapi.resource_type.repository')->get($entity_type_id, $bundle);
|
||||
$resource_type_name = $resource_type->getTypeName();
|
||||
$route_name = !is_null($relationship_field)
|
||||
? "jsonapi.$resource_type_name.$relationship_field.related"
|
||||
: "jsonapi.$resource_type_name.individual";
|
||||
$url = Url::fromRoute($route_name, ['entity' => $entity->uuid()]);
|
||||
$errors[0]['links']['via']['href'] = $url->setAbsolute()->toString(TRUE)->getGeneratedUrl();
|
||||
}
|
||||
$errors[0]['source']['pointer'] = $pointer;
|
||||
|
||||
if ($reason) {
|
||||
$errors[0]['detail'] = isset($errors[0]['detail']) ? $errors[0]['detail'] . ' ' . $reason : $reason;
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Core\Entity\EntityFieldManagerInterface;
|
||||
use Drupal\Core\Entity\EntityTypeManagerInterface;
|
||||
use Drupal\Core\Field\FieldTypePluginManagerInterface;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
|
||||
/**
|
||||
* Converts the Drupal entity object to a JSON:API array structure.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
abstract class EntityDenormalizerBase extends NormalizerBase implements DenormalizerInterface {
|
||||
|
||||
/**
|
||||
* The JSON:API resource type repository.
|
||||
*
|
||||
* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
|
||||
*/
|
||||
protected $resourceTypeRepository;
|
||||
|
||||
/**
|
||||
* The entity type manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
|
||||
*/
|
||||
protected $entityTypeManager;
|
||||
|
||||
/**
|
||||
* The entity field manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
|
||||
*/
|
||||
protected $fieldManager;
|
||||
|
||||
/**
|
||||
* The field plugin manager.
|
||||
*
|
||||
* @var \Drupal\Core\Field\FieldTypePluginManagerInterface
|
||||
*/
|
||||
protected $pluginManager;
|
||||
|
||||
/**
|
||||
* Constructs an EntityDenormalizerBase object.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
|
||||
* The entity type manager.
|
||||
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
|
||||
* The entity field manager.
|
||||
* @param \Drupal\Core\Field\FieldTypePluginManagerInterface $plugin_manager
|
||||
* The plugin manager for fields.
|
||||
*/
|
||||
public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, FieldTypePluginManagerInterface $plugin_manager) {
|
||||
$this->entityTypeManager = $entity_type_manager;
|
||||
$this->fieldManager = $field_manager;
|
||||
$this->pluginManager = $plugin_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function supportsNormalization($data, $format = NULL) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function normalize($object, $format = NULL, array $context = []) {
|
||||
throw new \LogicException('This method should never be called.');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function denormalize($data, $class, $format = NULL, array $context = []) {
|
||||
if (empty($context['resource_type']) || !$context['resource_type'] instanceof ResourceType) {
|
||||
throw new PreconditionFailedHttpException('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($this->prepareInput($data, $resource_type, $format, $context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the input data to create the entity.
|
||||
*
|
||||
* @param array $data
|
||||
* The input data to modify.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* Contains the info about the resource type.
|
||||
* @param string $format
|
||||
* Format the given data was extracted from.
|
||||
* @param array $context
|
||||
* Options available to the denormalizer.
|
||||
*
|
||||
* @return array
|
||||
* The modified input data.
|
||||
*/
|
||||
abstract protected function prepareInput(array $data, ResourceType $resource_type, $format, array $context);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
|
||||
use Drupal\Core\Url;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceIdentifierInterface;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceObject;
|
||||
use Drupal\jsonapi\JsonApiSpec;
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
|
||||
use Drupal\jsonapi\ResourceType\ResourceTypeRelationship;
|
||||
use Drupal\jsonapi\Routing\Routes;
|
||||
|
||||
/**
|
||||
* Normalizer class specific for entity reference field objects.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class EntityReferenceFieldNormalizer extends FieldNormalizer {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = EntityReferenceFieldItemListInterface::class;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function normalize($field, $format = NULL, array $context = []) {
|
||||
assert($field instanceof EntityReferenceFieldItemListInterface);
|
||||
// Build the relationship object based on the Entity Reference and normalize
|
||||
// that object instead.
|
||||
$resource_identifiers = array_filter(ResourceIdentifier::toResourceIdentifiers($field->filterEmptyItems()), function (ResourceIdentifierInterface $resource_identifier) {
|
||||
return !$resource_identifier->getResourceType()->isInternal();
|
||||
});
|
||||
$normalized_items = CacheableNormalization::aggregate($this->serializer->normalize($resource_identifiers, $format, $context));
|
||||
assert($context['resource_object'] instanceof ResourceObject);
|
||||
$resource_relationship = $context['resource_object']->getResourceType()->getFieldByInternalName($field->getName());
|
||||
assert($resource_relationship instanceof ResourceTypeRelationship);
|
||||
$link_cacheability = new CacheableMetadata();
|
||||
$links = array_map(function (Url $link) use ($link_cacheability) {
|
||||
$href = $link->setAbsolute()->toString(TRUE);
|
||||
$link_cacheability->addCacheableDependency($href);
|
||||
return ['href' => $href->getGeneratedUrl()];
|
||||
}, static::getRelationshipLinks($context['resource_object'], $resource_relationship));
|
||||
$data_normalization = $normalized_items->getNormalization();
|
||||
$normalization = [
|
||||
// Empty 'to-one' relationships must be NULL.
|
||||
// Empty 'to-many' relationships must be an empty array.
|
||||
// @link http://jsonapi.org/format/#document-resource-object-linkage
|
||||
'data' => $resource_relationship->hasOne() ? array_shift($data_normalization) : $data_normalization,
|
||||
];
|
||||
if (!empty($links)) {
|
||||
$normalization['links'] = $links;
|
||||
}
|
||||
return (new CacheableNormalization($normalized_items, $normalization))->withCacheableDependency($link_cacheability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the links for the relationship.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $relationship_context
|
||||
* The JSON:API resource object context of the relationship.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceTypeRelationship $resource_relationship
|
||||
* The resource type relationship field.
|
||||
*
|
||||
* @return array
|
||||
* The relationship's links.
|
||||
*/
|
||||
public static function getRelationshipLinks(ResourceObject $relationship_context, ResourceTypeRelationship $resource_relationship) {
|
||||
$resource_type = $relationship_context->getResourceType();
|
||||
if ($resource_type->isInternal() || !$resource_type->isLocatable()) {
|
||||
return [];
|
||||
}
|
||||
$public_field_name = $resource_relationship->getPublicName();
|
||||
$relationship_route_name = Routes::getRouteName($resource_type, "$public_field_name.relationship.get");
|
||||
$links = [
|
||||
'self' => Url::fromRoute($relationship_route_name, ['entity' => $relationship_context->getId()]),
|
||||
];
|
||||
if (static::hasNonInternalResourceType($resource_type->getRelatableResourceTypesByField($public_field_name))) {
|
||||
$related_route_name = Routes::getRouteName($resource_type, "$public_field_name.related");
|
||||
$links['related'] = Url::fromRoute($related_route_name, ['entity' => $relationship_context->getId()]);
|
||||
}
|
||||
if ($resource_type->isVersionable()) {
|
||||
$version_query_parameter = [JsonApiSpec::VERSION_QUERY_PARAMETER => $relationship_context->getVersionIdentifier()];
|
||||
$links['self']->setOption('query', $version_query_parameter);
|
||||
if (isset($links['related'])) {
|
||||
$links['related']->setOption('query', $version_query_parameter);
|
||||
}
|
||||
}
|
||||
return $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a given list of resource types contains a non-internal type.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
|
||||
* The JSON:API resource types to evaluate.
|
||||
*
|
||||
* @return bool
|
||||
* FALSE if every resource type is internal, TRUE otherwise.
|
||||
*/
|
||||
protected static function hasNonInternalResourceType(array $resource_types) {
|
||||
foreach ($resource_types as $resource_type) {
|
||||
if (!$resource_type->isInternal()) {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Entity\EntityTypeManagerInterface;
|
||||
use Drupal\Core\Field\FieldItemInterface;
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
use Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface;
|
||||
use Drupal\Core\TypedData\TypedDataInternalPropertiesHelper;
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Drupal\serialization\Normalizer\CacheableNormalizerInterface;
|
||||
use Drupal\serialization\Normalizer\SerializedColumnNormalizerTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
|
||||
/**
|
||||
* Converts the Drupal field item object to a JSON:API array structure.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class FieldItemNormalizer extends NormalizerBase implements DenormalizerInterface {
|
||||
|
||||
use SerializedColumnNormalizerTrait;
|
||||
|
||||
/**
|
||||
* The interface or class that this Normalizer supports.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = FieldItemInterface::class;
|
||||
|
||||
/**
|
||||
* The entity type manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
|
||||
*/
|
||||
protected $entityTypeManager;
|
||||
|
||||
/**
|
||||
* FieldItemNormalizer constructor.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
|
||||
* The entity type manager.
|
||||
*/
|
||||
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
|
||||
$this->entityTypeManager = $entity_type_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* This normalizer leaves JSON:API normalizer land and enters the land of
|
||||
* Drupal core's serialization system. That system was never designed with
|
||||
* cacheability in mind, and hence bubbles cacheability out of band. This must
|
||||
* catch it, and pass it to the value object that JSON:API uses.
|
||||
*/
|
||||
public function normalize($field_item, $format = NULL, array $context = []) {
|
||||
/** @var \Drupal\Core\TypedData\TypedDataInterface $property */
|
||||
$values = [];
|
||||
$context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY] = new CacheableMetadata();
|
||||
if (!empty($field_item->getProperties(TRUE))) {
|
||||
// We normalize each individual value, so each can do their own casting,
|
||||
// if needed.
|
||||
$field_properties = TypedDataInternalPropertiesHelper::getNonInternalProperties($field_item);
|
||||
foreach ($field_properties as $property_name => $property) {
|
||||
$values[$property_name] = $this->serializer->normalize($property, $format, $context);
|
||||
}
|
||||
// Flatten if there is only a single property to normalize.
|
||||
$values = static::rasterizeValueRecursive(count($field_properties) == 1 ? reset($values) : $values);
|
||||
}
|
||||
else {
|
||||
$values = $field_item->getValue();
|
||||
}
|
||||
$normalization = new CacheableNormalization(
|
||||
$context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY],
|
||||
$values
|
||||
);
|
||||
unset($context[CacheableNormalizerInterface::SERIALIZATION_CONTEXT_CACHEABILITY]);
|
||||
return $normalization;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function denormalize($data, $class, $format = NULL, array $context = []) {
|
||||
$item_definition = $context['field_definition']->getItemDefinition();
|
||||
assert($item_definition instanceof FieldItemDataDefinitionInterface);
|
||||
|
||||
$field_item = $this->getFieldItemInstance($context['resource_type'], $item_definition);
|
||||
$this->checkForSerializedStrings($data, $class, $field_item);
|
||||
|
||||
$property_definitions = $item_definition->getPropertyDefinitions();
|
||||
|
||||
$serialized_property_names = $this->getCustomSerializedPropertyNames($field_item);
|
||||
$denormalize_property = function ($property_name, $property_value, $property_value_class, $format, $context) use ($serialized_property_names) {
|
||||
if ($this->serializer->supportsDenormalization($property_value, $property_value_class, $format, $context)) {
|
||||
return $this->serializer->denormalize($property_value, $property_value_class, $format, $context);
|
||||
}
|
||||
else {
|
||||
if (in_array($property_name, $serialized_property_names, TRUE)) {
|
||||
$property_value = serialize($property_value);
|
||||
}
|
||||
return $property_value;
|
||||
}
|
||||
};
|
||||
// Because e.g. the 'bundle' entity key field requires field values to not
|
||||
// be expanded to an array of all properties, we special-case single-value
|
||||
// properties.
|
||||
if (!is_array($data)) {
|
||||
// The NULL normalization means there is no value, hence we can return
|
||||
// early. Note that this is not just an optimization but a necessity for
|
||||
// field types without main properties (such as the "map" field type).
|
||||
if ($data === NULL) {
|
||||
return $data;
|
||||
}
|
||||
$property_value = $data;
|
||||
$property_name = $item_definition->getMainPropertyName();
|
||||
$property_value_class = $property_definitions[$property_name]->getClass();
|
||||
return $denormalize_property($property_name, $property_value, $property_value_class, $format, $context);
|
||||
}
|
||||
|
||||
$data_internal = [];
|
||||
if (!empty($property_definitions)) {
|
||||
foreach ($data as $property_name => $property_value) {
|
||||
$property_value_class = $property_definitions[$property_name]->getClass();
|
||||
$data_internal[$property_name] = $denormalize_property($property_name, $property_value, $property_value_class, $format, $context);
|
||||
}
|
||||
}
|
||||
else {
|
||||
$data_internal = $data;
|
||||
}
|
||||
|
||||
return $data_internal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a field item instance for use with SerializedColumnNormalizerTrait.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type of the entity being denormalized.
|
||||
* @param \Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface $item_definition
|
||||
* The field item definition of the instance to get.
|
||||
*
|
||||
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
|
||||
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
|
||||
*/
|
||||
protected function getFieldItemInstance(ResourceType $resource_type, FieldItemDataDefinitionInterface $item_definition) {
|
||||
if ($bundle_key = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId())
|
||||
->getKey('bundle')) {
|
||||
$create_values = [$bundle_key => $resource_type->getBundle()];
|
||||
}
|
||||
else {
|
||||
$create_values = [];
|
||||
}
|
||||
$entity = $this->entityTypeManager->getStorage($resource_type->getEntityTypeId())->create($create_values);
|
||||
$field = $entity->get($item_definition->getFieldDefinition()->getName());
|
||||
assert($field instanceof FieldItemListInterface);
|
||||
$field_item = $field->appendItem();
|
||||
assert($field_item instanceof FieldItemInterface);
|
||||
return $field_item;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Core\Field\FieldDefinitionInterface;
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceObject;
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
|
||||
/**
|
||||
* Converts the Drupal field structure to a JSON:API array structure.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class FieldNormalizer extends NormalizerBase implements DenormalizerInterface {
|
||||
|
||||
/**
|
||||
* The interface or class that this Normalizer supports.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = FieldItemListInterface::class;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function normalize($field, $format = NULL, array $context = []) {
|
||||
/* @var \Drupal\Core\Field\FieldItemListInterface $field */
|
||||
$normalized_items = $this->normalizeFieldItems($field, $format, $context);
|
||||
assert($context['resource_object'] instanceof ResourceObject);
|
||||
return $context['resource_object']->getResourceType()->getFieldByInternalName($field->getName())->hasOne()
|
||||
? array_shift($normalized_items) ?: CacheableNormalization::permanent(NULL)
|
||||
: CacheableNormalization::aggregate($normalized_items);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function denormalize($data, $class, $format = NULL, array $context = []) {
|
||||
$field_definition = $context['field_definition'];
|
||||
assert($field_definition instanceof FieldDefinitionInterface);
|
||||
$resource_type = $context['resource_type'];
|
||||
assert($resource_type instanceof ResourceType);
|
||||
|
||||
// If $data contains items (recognizable by numerical array keys, which
|
||||
// Drupal's Field API calls "deltas"), then it already is itemized; it's not
|
||||
// using the simplified JSON structure that JSON:API generates.
|
||||
$is_already_itemized = is_array($data) && array_reduce(array_keys($data), function ($carry, $index) {
|
||||
return $carry && is_numeric($index);
|
||||
}, TRUE);
|
||||
|
||||
$itemized_data = $is_already_itemized
|
||||
? $data
|
||||
: [0 => $data];
|
||||
|
||||
// Single-cardinality fields don't need itemization.
|
||||
$field_item_class = $field_definition->getItemDefinition()->getClass();
|
||||
if (count($itemized_data) === 1 && $resource_type->getFieldByInternalName($field_definition->getName())->hasOne()) {
|
||||
return $this->serializer->denormalize($itemized_data[0], $field_item_class, $format, $context);
|
||||
}
|
||||
|
||||
$data_internal = [];
|
||||
foreach ($itemized_data as $delta => $field_item_value) {
|
||||
$data_internal[$delta] = $this->serializer->denormalize($field_item_value, $field_item_class, $format, $context);
|
||||
}
|
||||
|
||||
return $data_internal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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\FieldItemNormalizerValue[]
|
||||
* The array of normalized field items.
|
||||
*/
|
||||
protected function normalizeFieldItems(FieldItemListInterface $field, $format, array $context) {
|
||||
$normalizer_items = [];
|
||||
if (!$field->isEmpty()) {
|
||||
foreach ($field as $field_item) {
|
||||
$normalizer_items[] = $this->serializer->normalize($field_item, $format, $context);
|
||||
}
|
||||
}
|
||||
return $normalizer_items;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Session\AccountInterface;
|
||||
use Drupal\jsonapi\Normalizer\Value\HttpExceptionNormalizerValue;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
* Normalizes an HttpException in compliance with the JSON:API specification.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see http://jsonapi.org/format/#error-objects
|
||||
*/
|
||||
class HttpExceptionNormalizer extends NormalizerBase {
|
||||
|
||||
/**
|
||||
* The interface or class that this Normalizer supports.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = HttpException::class;
|
||||
|
||||
/**
|
||||
* The current user making the request.
|
||||
*
|
||||
* @var \Drupal\Core\Session\AccountInterface
|
||||
*/
|
||||
protected $currentUser;
|
||||
|
||||
/**
|
||||
* HttpExceptionNormalizer constructor.
|
||||
*
|
||||
* @param \Drupal\Core\Session\AccountInterface $current_user
|
||||
* The current user.
|
||||
*/
|
||||
public function __construct(AccountInterface $current_user) {
|
||||
$this->currentUser = $current_user;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function normalize($object, $format = NULL, array $context = []) {
|
||||
$cacheability = new CacheableMetadata();
|
||||
$cacheability->addCacheableDependency($object);
|
||||
return new HttpExceptionNormalizerValue($cacheability, static::rasterizeValueRecursive($this->buildErrorObjects($object)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' => (string) $status_code,
|
||||
'detail' => $exception->getMessage(),
|
||||
];
|
||||
$error['links']['via']['href'] = \Drupal::request()->getUri();
|
||||
// Provide an "info" link by default: if the exception carries a single
|
||||
// "Link" header, use that, otherwise fall back to the HTTP spec section
|
||||
// covering the exception's status code.
|
||||
$headers = $exception->getHeaders();
|
||||
if (isset($headers['Link']) && !is_array($headers['Link'])) {
|
||||
$error['links']['info']['href'] = $headers['Link'];
|
||||
}
|
||||
elseif ($info_url = $this->getInfoUrl($status_code)) {
|
||||
$error['links']['info']['href'] = $info_url;
|
||||
}
|
||||
// Exceptions thrown without an explicitly defined code get assigned zero by
|
||||
// default. Since this is no helpful information, omit it.
|
||||
if ($exception->getCode() !== 0) {
|
||||
$error['code'] = (string) $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|null
|
||||
* URL pointing to the specific RFC-2616 section. Or NULL if it is an HTTP
|
||||
* status code that is defined in another RFC.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/2832211#comment-11826234
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
public static 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 = [
|
||||
'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]) ? NULL : $url . $sections[$status_code];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
|
||||
use Drupal\Component\Utility\Crypt;
|
||||
use Drupal\Component\Uuid\Uuid;
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Entity\EntityTypeManagerInterface;
|
||||
use Drupal\jsonapi\JsonApiResource\ErrorCollection;
|
||||
use Drupal\jsonapi\JsonApiResource\OmittedData;
|
||||
use Drupal\jsonapi\JsonApiSpec;
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableOmission;
|
||||
use Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel;
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Normalizes the top-level document according to the JSON:API specification.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel
|
||||
*/
|
||||
class JsonApiDocumentTopLevelNormalizer extends NormalizerBase implements DenormalizerInterface, NormalizerInterface {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = JsonApiDocumentTopLevel::class;
|
||||
|
||||
/**
|
||||
* The entity type manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
|
||||
*/
|
||||
protected $entityTypeManager;
|
||||
|
||||
/**
|
||||
* The JSON:API resource type repository.
|
||||
*
|
||||
* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
|
||||
*/
|
||||
protected $resourceTypeRepository;
|
||||
|
||||
/**
|
||||
* Constructs a JsonApiDocumentTopLevelNormalizer object.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
|
||||
* The entity type manager.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
|
||||
* The JSON:API resource type repository.
|
||||
*/
|
||||
public function __construct(EntityTypeManagerInterface $entity_type_manager, ResourceTypeRepositoryInterface $resource_type_repository) {
|
||||
$this->entityTypeManager = $entity_type_manager;
|
||||
$this->resourceTypeRepository = $resource_type_repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function denormalize($data, $class, $format = NULL, array $context = []) {
|
||||
$resource_type = $context['resource_type'];
|
||||
|
||||
// Validate a few common errors in document formatting.
|
||||
static::validateRequestBody($data, $resource_type);
|
||||
|
||||
$normalized = [];
|
||||
|
||||
if (!empty($data['data']['attributes'])) {
|
||||
$normalized = $data['data']['attributes'];
|
||||
}
|
||||
|
||||
if (!empty($data['data']['id'])) {
|
||||
$uuid_key = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId())->getKey('uuid');
|
||||
$normalized[$uuid_key] = $data['data']['id'];
|
||||
}
|
||||
|
||||
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) {
|
||||
if (empty($relationship['data'])) {
|
||||
return [];
|
||||
}
|
||||
if (empty($relationship['data'][0]['id'])) {
|
||||
throw new BadRequestHttpException("No ID specified for related resource");
|
||||
}
|
||||
$id_list = array_column($relationship['data'], 'id');
|
||||
if (empty($relationship['data'][0]['type'])) {
|
||||
throw new BadRequestHttpException("No type specified for related resource");
|
||||
}
|
||||
if (!$resource_type = $this->resourceTypeRepository->getByTypeName($relationship['data'][0]['type'])) {
|
||||
throw new BadRequestHttpException("Invalid type specified for related resource: '" . $relationship['data'][0]['type'] . "'");
|
||||
}
|
||||
|
||||
$entity_type_id = $resource_type->getEntityTypeId();
|
||||
try {
|
||||
$entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
|
||||
}
|
||||
catch (PluginNotFoundException $e) {
|
||||
throw new BadRequestHttpException("Invalid type specified for related resource: '" . $relationship['data'][0]['type'] . "'");
|
||||
}
|
||||
// In order to maintain the order ($delta) of the relationships, we need
|
||||
// to load the entities and create a mapping between id and uuid.
|
||||
$uuid_key = $this->entityTypeManager
|
||||
->getDefinition($entity_type_id)->getKey('uuid');
|
||||
$related_entities = array_values($entity_storage->loadByProperties([$uuid_key => $id_list]));
|
||||
$map = [];
|
||||
foreach ($related_entities as $related_entity) {
|
||||
$map[$related_entity->uuid()] = $related_entity->id();
|
||||
}
|
||||
|
||||
// $id_list has the correct order of uuids. We stitch this together with
|
||||
// $map which contains loaded entities, and then bring in the correct
|
||||
// meta values from the relationship, whose deltas match with $id_list.
|
||||
$canonical_ids = [];
|
||||
foreach ($id_list as $delta => $uuid) {
|
||||
if (!isset($map[$uuid])) {
|
||||
// @see \Drupal\jsonapi\Normalizer\EntityReferenceFieldNormalizer::normalize()
|
||||
if ($uuid === 'virtual') {
|
||||
continue;
|
||||
}
|
||||
throw new NotFoundHttpException(sprintf('The resource identified by `%s:%s` (given as a relationship item) could not be found.', $relationship['data'][$delta]['type'], $uuid));
|
||||
}
|
||||
$reference_item = [
|
||||
'target_id' => $map[$uuid],
|
||||
];
|
||||
if (isset($relationship['data'][$delta]['meta'])) {
|
||||
$reference_item += $relationship['data'][$delta]['meta'];
|
||||
}
|
||||
$canonical_ids[] = $reference_item;
|
||||
}
|
||||
|
||||
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 = []) {
|
||||
assert($object instanceof JsonApiDocumentTopLevel);
|
||||
$data = $object->getData();
|
||||
$document['jsonapi'] = CacheableNormalization::permanent([
|
||||
'version' => JsonApiSpec::SUPPORTED_SPECIFICATION_VERSION,
|
||||
'meta' => [
|
||||
'links' => [
|
||||
'self' => [
|
||||
'href' => JsonApiSpec::SUPPORTED_SPECIFICATION_PERMALINK,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
if ($data instanceof ErrorCollection) {
|
||||
$document['errors'] = $this->normalizeErrorDocument($object, $format, $context);
|
||||
}
|
||||
else {
|
||||
// Add data.
|
||||
$document['data'] = $this->serializer->normalize($data, $format, $context);
|
||||
// Add includes.
|
||||
$document['included'] = $this->serializer->normalize($object->getIncludes(), $format, $context)->omitIfEmpty();
|
||||
// Add omissions and metadata.
|
||||
$normalized_omissions = $this->normalizeOmissionsLinks($object->getOmissions(), $format, $context);
|
||||
$meta = !$normalized_omissions instanceof CacheableOmission
|
||||
? array_merge($object->getMeta(), ['omitted' => $normalized_omissions->getNormalization()])
|
||||
: $object->getMeta();
|
||||
$document['meta'] = (new CacheableNormalization($normalized_omissions, $meta))->omitIfEmpty();
|
||||
}
|
||||
// Add document links.
|
||||
$document['links'] = $this->serializer->normalize($object->getLinks(), $format, $context)->omitIfEmpty();
|
||||
// Every JSON:API document contains absolute URLs.
|
||||
return CacheableNormalization::aggregate($document)->withCacheableDependency((new CacheableMetadata())->addCacheContexts(['url.site']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an error collection.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel $document
|
||||
* The document to normalize.
|
||||
* @param string $format
|
||||
* The normalization format.
|
||||
* @param array $context
|
||||
* The normalization context.
|
||||
*
|
||||
* @return \Drupal\jsonapi\Normalizer\Value\CacheableNormalization
|
||||
* The normalized document.
|
||||
*
|
||||
* @todo: refactor this to use CacheableNormalization::aggregate in https://www.drupal.org/project/jsonapi/issues/3036284.
|
||||
*/
|
||||
protected function normalizeErrorDocument(JsonApiDocumentTopLevel $document, $format, array $context = []) {
|
||||
$normalized_values = array_map(function (HttpExceptionInterface $exception) use ($format, $context) {
|
||||
return $this->serializer->normalize($exception, $format, $context);
|
||||
}, (array) $document->getData()->getIterator());
|
||||
$cacheability = new CacheableMetadata();
|
||||
$errors = [];
|
||||
foreach ($normalized_values as $normalized_error) {
|
||||
$cacheability->addCacheableDependency($normalized_error);
|
||||
$errors = array_merge($errors, $normalized_error->getNormalization());
|
||||
}
|
||||
return new CacheableNormalization($cacheability, $errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes omitted data into a set of omission links.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\OmittedData $omissions
|
||||
* The omitted response data.
|
||||
* @param string $format
|
||||
* The normalization format.
|
||||
* @param array $context
|
||||
* The normalization context.
|
||||
*
|
||||
* @return \Drupal\jsonapi\Normalizer\Value\CacheableNormalization|\Drupal\jsonapi\Normalizer\Value\CacheableOmission
|
||||
* The normalized omissions.
|
||||
*
|
||||
* @todo: refactor this to use link collections in https://www.drupal.org/project/jsonapi/issues/3036279.
|
||||
*/
|
||||
protected function normalizeOmissionsLinks(OmittedData $omissions, $format, array $context = []) {
|
||||
$normalized_omissions = array_map(function (HttpExceptionInterface $exception) use ($format, $context) {
|
||||
return $this->serializer->normalize($exception, $format, $context);
|
||||
}, $omissions->toArray());
|
||||
$cacheability = CacheableMetadata::createFromObject(CacheableNormalization::aggregate($normalized_omissions));
|
||||
if (empty($normalized_omissions)) {
|
||||
return new CacheableOmission($cacheability);
|
||||
}
|
||||
$omission_links = [
|
||||
'detail' => 'Some resources have been omitted because of insufficient authorization.',
|
||||
'links' => [
|
||||
'help' => [
|
||||
'href' => 'https://www.drupal.org/docs/8/modules/json-api/filtering#filters-access-control',
|
||||
],
|
||||
],
|
||||
];
|
||||
$link_hash_salt = Crypt::randomBytesBase64();
|
||||
foreach ($normalized_omissions as $omission) {
|
||||
$cacheability->addCacheableDependency($omission);
|
||||
// Add the errors to the pre-existing errors.
|
||||
foreach ($omission->getNormalization() as $error) {
|
||||
// JSON:API links cannot be arrays and the spec generally favors link
|
||||
// relation types as keys. 'item' is the right link relation type, but
|
||||
// we need multiple values. To do that, we generate a meaningless,
|
||||
// random value to use as a unique key. That value is a hash of a
|
||||
// random salt and the link href. This ensures that the key is non-
|
||||
// deterministic while letting use deduplicate the links by their
|
||||
// href. The salt is *not* used for any cryptographic reason.
|
||||
$link_key = 'item--' . static::getLinkHash($link_hash_salt, $error['links']['via']['href']);
|
||||
$omission_links['links'][$link_key] = [
|
||||
'href' => $error['links']['via']['href'],
|
||||
'meta' => [
|
||||
'rel' => 'item',
|
||||
'detail' => $error['detail'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
return new CacheableNormalization($cacheability, $omission_links);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs minimal validation of the document.
|
||||
*/
|
||||
protected static function validateRequestBody(array $document, ResourceType $resource_type) {
|
||||
// Ensure that the relationships key was not placed in the top level.
|
||||
if (isset($document['relationships']) && !empty($document['relationships'])) {
|
||||
throw new BadRequestHttpException("Found \"relationships\" within the document's top level. The \"relationships\" key must be within resource object.");
|
||||
}
|
||||
// Ensure that the resource object contains the "type" key.
|
||||
if (!isset($document['data']['type'])) {
|
||||
throw new BadRequestHttpException("Resource object must include a \"type\".");
|
||||
}
|
||||
// Ensure that the client provided ID is a valid UUID.
|
||||
if (isset($document['data']['id']) && !Uuid::isValid($document['data']['id'])) {
|
||||
throw new UnprocessableEntityHttpException('IDs should be properly generated and formatted UUIDs as described in RFC 4122.');
|
||||
}
|
||||
// Ensure that no relationship fields are being set via the attributes
|
||||
// resource object member.
|
||||
if (isset($document['data']['attributes'])) {
|
||||
$received_attribute_field_names = array_keys($document['data']['attributes']);
|
||||
$relationship_field_names = array_keys($resource_type->getRelatableResourceTypes());
|
||||
if ($relationship_fields_sent_as_attributes = array_intersect($received_attribute_field_names, $relationship_field_names)) {
|
||||
throw new UnprocessableEntityHttpException(sprintf("The following relationship fields were provided as attributes: [ %s ]", implode(', ', $relationship_fields_sent_as_attributes)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes an omitted link.
|
||||
*
|
||||
* @param string $salt
|
||||
* A hash salt.
|
||||
* @param string $link_href
|
||||
* The omitted link.
|
||||
*
|
||||
* @return string
|
||||
* A 7 character hash.
|
||||
*/
|
||||
protected static function getLinkHash($salt, $link_href) {
|
||||
return substr(str_replace(['-', '_'], '', Crypt::hashBase64($salt . $link_href)), 0, 7);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Component\Utility\Crypt;
|
||||
use Drupal\jsonapi\JsonApiResource\LinkCollection;
|
||||
use Drupal\jsonapi\JsonApiResource\Link;
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
|
||||
|
||||
/**
|
||||
* Normalizes a LinkCollection object.
|
||||
*
|
||||
* The JSON:API specification has the concept of a "links collection". A links
|
||||
* collection is a JSON object where each member of the object is a
|
||||
* "link object". Unfortunately, this means that it is not possible to have more
|
||||
* than one link for a given key.
|
||||
*
|
||||
* When normalizing more than one link in a LinkCollection with the same key, a
|
||||
* unique and random string is appended to the link's key after a double dash
|
||||
* (--) to differentiate the links.
|
||||
*
|
||||
* This may change with a later version of the JSON:API specification.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class LinkCollectionNormalizer extends NormalizerBase {
|
||||
|
||||
/**
|
||||
* The normalizer $context key name for the key of an individual link.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const LINK_KEY = 'jsonapi_links_object_link_key';
|
||||
|
||||
/**
|
||||
* The normalizer $context key name for the context object of the link.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const LINK_CONTEXT = 'jsonapi_links_object_context';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = LinkCollection::class;
|
||||
|
||||
/**
|
||||
* A random string to use when hashing links.
|
||||
*
|
||||
* This string is unique per instance of a link collection, but always the
|
||||
* same within it. This means that link key hashes will be non-deterministic
|
||||
* for outside observers, but two links within the same collection will always
|
||||
* have the same hash value.
|
||||
*
|
||||
* This is not used for cryptographic purposes.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $hashSalt;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function normalize($object, $format = NULL, array $context = []) {
|
||||
assert($object instanceof LinkCollection);
|
||||
$normalized = [];
|
||||
/* @var \Drupal\jsonapi\JsonApiResource\Link $link */
|
||||
foreach ($object as $key => $links) {
|
||||
$is_multiple = count($links) > 1;
|
||||
foreach ($links as $link) {
|
||||
$link_key = $is_multiple ? sprintf('%s--%s', $key, $this->hashByHref($link)) : $key;
|
||||
$attributes = $link->getTargetAttributes();
|
||||
$normalization = array_merge(['href' => $link->getHref()], !empty($attributes) ? ['meta' => $attributes] : []);
|
||||
$normalized[$link_key] = new CacheableNormalization($link, $normalization);
|
||||
}
|
||||
}
|
||||
return CacheableNormalization::aggregate($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes a link by its href.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\Link $link
|
||||
* A link to be hashed.
|
||||
*
|
||||
* @return string
|
||||
* A 7 character alphanumeric hash.
|
||||
*/
|
||||
protected function hashByHref(Link $link) {
|
||||
if (!$this->hashSalt) {
|
||||
$this->hashSalt = Crypt::randomBytesBase64();
|
||||
}
|
||||
return substr(str_replace(['-', '_'], '', Crypt::hashBase64($this->hashSalt . $link->getHref())), 0, 7);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
|
||||
use Drupal\serialization\Normalizer\NormalizerBase as SerializationNormalizerBase;
|
||||
|
||||
/**
|
||||
* Base normalizer used in all JSON:API normalizers.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
abstract class NormalizerBase extends SerializationNormalizerBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $format = 'api_json';
|
||||
|
||||
/**
|
||||
* 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 static function rasterizeValueRecursive($value) {
|
||||
if (!$value || is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$output = [];
|
||||
foreach ($value as $key => $item) {
|
||||
$output[$key] = static::rasterizeValueRecursive($item);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
if ($value instanceof CacheableNormalization) {
|
||||
return $value->getNormalization();
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function checkFormat($format = NULL) {
|
||||
// The parent implementation allows format-specific normalizers to be used
|
||||
// for formatless normalization. The JSON:API module wants to be cautious.
|
||||
// Hence it only allows its normalizers to be used for the JSON:API format,
|
||||
// to avoid JSON:API-specific normalizations showing up in the REST API.
|
||||
return $format === $this->format;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\jsonapi\JsonApiResource\Relationship;
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
|
||||
|
||||
/**
|
||||
* Normalizes a JSON:API relationship object.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class RelationshipNormalizer extends NormalizerBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = Relationship::class;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function normalize($object, $format = NULL, array $context = []) {
|
||||
assert($object instanceof Relationship);
|
||||
return CacheableNormalization::aggregate([
|
||||
'data' => $this->serializer->normalize($object->getData(), $format, $context),
|
||||
'links' => $this->serializer->normalize($object->getLinks(), $format, $context)->omitIfEmpty(),
|
||||
'meta' => CacheableNormalization::permanent($object->getMeta())->omitIfEmpty(),
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Core\Entity\EntityFieldManagerInterface;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceIdentifier;
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
|
||||
/**
|
||||
* Normalizes a Relationship according to the JSON:API specification.
|
||||
*
|
||||
* Normalizer class for relationship elements. A relationship can be anything
|
||||
* that points to an entity in a JSON:API resource.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class ResourceIdentifierNormalizer extends NormalizerBase implements DenormalizerInterface {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = ResourceIdentifier::class;
|
||||
|
||||
/**
|
||||
* The entity field manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
|
||||
*/
|
||||
protected $fieldManager;
|
||||
|
||||
/**
|
||||
* RelationshipNormalizer constructor.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
|
||||
* The entity field manager.
|
||||
*/
|
||||
public function __construct(EntityFieldManagerInterface $field_manager) {
|
||||
$this->fieldManager = $field_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function normalize($object, $format = NULL, array $context = []) {
|
||||
assert($object instanceof ResourceIdentifier);
|
||||
$normalization = [
|
||||
'type' => $object->getTypeName(),
|
||||
'id' => $object->getId(),
|
||||
];
|
||||
if ($object->getMeta()) {
|
||||
$normalization['meta'] = $this->serializer->normalize($object->getMeta(), $format, $context);
|
||||
}
|
||||
return CacheableNormalization::permanent($normalization);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function denormalize($data, $class, $format = NULL, array $context = []) {
|
||||
// If we get here, it's via a relationship POST/PATCH.
|
||||
/** @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 BadRequestHttpException('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_resource_types = $resource_type->getRelatableResourceTypesByField($resource_type->getPublicName($context['related']));
|
||||
$target_resource_type_names = array_map(function (ResourceType $resource_type) {
|
||||
return $resource_type->getTypeName();
|
||||
}, $target_resource_types);
|
||||
|
||||
$is_multiple = $field_definition->getFieldStorageDefinition()->isMultiple();
|
||||
$data = $this->massageRelationshipInput($data, $is_multiple);
|
||||
$resource_identifiers = array_map(function ($value) use ($property_key, $target_resource_type_names) {
|
||||
// Make sure that the provided type is compatible with the targeted
|
||||
// resource.
|
||||
if (!in_array($value['type'], $target_resource_type_names)) {
|
||||
throw new BadRequestHttpException(sprintf(
|
||||
'The provided type (%s) does not mach the destination resource types (%s).',
|
||||
$value['type'],
|
||||
implode(', ', $target_resource_type_names)
|
||||
));
|
||||
}
|
||||
return new ResourceIdentifier($value['type'], $value['id'], isset($value['meta']) ? $value['meta'] : []);
|
||||
}, $data['data']);
|
||||
if (!ResourceIdentifier::areResourceIdentifiersUnique($resource_identifiers)) {
|
||||
throw new BadRequestHttpException('Duplicate relationships are not permitted. Use `meta.arity` to distinguish resource identifiers with matching `type` and `id` values.');
|
||||
}
|
||||
return $resource_identifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(array $data, $is_multiple) {
|
||||
if ($is_multiple) {
|
||||
if (!is_array($data['data'])) {
|
||||
throw new BadRequestHttpException('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 BadRequestHttpException('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 BadRequestHttpException('Invalid body payload for the relationship.');
|
||||
}
|
||||
$data['data'] = [$data['data']];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
|
||||
use Drupal\Core\Field\FieldItemListInterface;
|
||||
use Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher;
|
||||
use Drupal\jsonapi\JsonApiResource\Relationship;
|
||||
use Drupal\jsonapi\JsonApiResource\ResourceObject;
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
|
||||
use Drupal\jsonapi\Normalizer\Value\CacheableOmission;
|
||||
|
||||
/**
|
||||
* Converts the JSON:API module ResourceObject into a JSON:API array structure.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class ResourceObjectNormalizer extends NormalizerBase {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = ResourceObject::class;
|
||||
|
||||
/**
|
||||
* The entity normalization cacher.
|
||||
*
|
||||
* @var \Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher
|
||||
*/
|
||||
protected $cacher;
|
||||
|
||||
/**
|
||||
* Constructs a ResourceObjectNormalizer object.
|
||||
*
|
||||
* @param \Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher $cacher
|
||||
* The entity normalization cacher.
|
||||
*/
|
||||
public function __construct(ResourceObjectNormalizationCacher $cacher) {
|
||||
$this->cacher = $cacher;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function supportsDenormalization($data, $type, $format = NULL) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function normalize($object, $format = NULL, array $context = []) {
|
||||
assert($object instanceof ResourceObject);
|
||||
// If the fields to use were specified, only output those field values.
|
||||
$context['resource_object'] = $object;
|
||||
$resource_type = $object->getResourceType();
|
||||
$resource_type_name = $resource_type->getTypeName();
|
||||
$fields = $object->getFields();
|
||||
// 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.
|
||||
if (!empty($context['sparse_fieldset'][$resource_type_name])) {
|
||||
$field_names = $context['sparse_fieldset'][$resource_type_name];
|
||||
}
|
||||
else {
|
||||
$field_names = array_keys($fields);
|
||||
}
|
||||
|
||||
$normalization_parts = $this->getNormalization($field_names, $object, $format, $context);
|
||||
|
||||
// Keep only the requested fields (the cached normalization gradually grows
|
||||
// to the complete set of fields).
|
||||
$fields = $normalization_parts[ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_FIELDS];
|
||||
$field_normalizations = array_intersect_key($fields, array_flip($field_names));
|
||||
|
||||
$relationship_field_names = array_keys($resource_type->getRelatableResourceTypes());
|
||||
$attributes = array_diff_key($field_normalizations, array_flip($relationship_field_names));
|
||||
$relationships = array_intersect_key($field_normalizations, array_flip($relationship_field_names));
|
||||
$entity_normalization = array_filter(
|
||||
$normalization_parts[ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_BASE] + [
|
||||
'attributes' => CacheableNormalization::aggregate($attributes)->omitIfEmpty(),
|
||||
'relationships' => CacheableNormalization::aggregate($relationships)->omitIfEmpty(),
|
||||
]
|
||||
);
|
||||
return CacheableNormalization::aggregate($entity_normalization)->withCacheableDependency($object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an entity using the given fieldset.
|
||||
*
|
||||
* @param string[] $field_names
|
||||
* The field names to normalize (the sparse fieldset, if any).
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
|
||||
* The resource object to partially normalize.
|
||||
* @param string $format
|
||||
* The format in which the normalization will be encoded.
|
||||
* @param array $context
|
||||
* Context options for the normalizer.
|
||||
*
|
||||
* @return array
|
||||
* An array with two key-value pairs:
|
||||
* - 'base': array, the base normalization of the entity, that does not
|
||||
* depend on which sparse fieldset was requested.
|
||||
* - 'fields': CacheableNormalization for all requested fields.
|
||||
*
|
||||
* @see ::normalize()
|
||||
*/
|
||||
protected function getNormalization(array $field_names, ResourceObject $object, $format = NULL, array $context = []) {
|
||||
$cached_normalization_parts = $this->cacher->get($object);
|
||||
$normalizer_values = $cached_normalization_parts !== FALSE
|
||||
? $cached_normalization_parts
|
||||
: static::buildEmptyNormalization($object);
|
||||
$fields = &$normalizer_values[ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_FIELDS];
|
||||
$non_cached_fields = array_diff_key($object->getFields(), $fields);
|
||||
$non_cached_requested_fields = array_intersect_key($non_cached_fields, array_flip($field_names));
|
||||
foreach ($non_cached_requested_fields as $field_name => $field) {
|
||||
$fields[$field_name] = $this->serializeField($field, $context, $format);
|
||||
}
|
||||
// Add links if missing.
|
||||
$base = &$normalizer_values[ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_BASE];
|
||||
$base['links'] = isset($base['links'])
|
||||
? $base['links']
|
||||
: $this->serializer
|
||||
->normalize($object->getLinks(), $format, $context)
|
||||
->omitIfEmpty();
|
||||
|
||||
if (!empty($non_cached_requested_fields)) {
|
||||
$this->cacher->saveOnTerminate($object, $normalizer_values);
|
||||
}
|
||||
|
||||
return $normalizer_values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the empty normalization structure for cache misses.
|
||||
*
|
||||
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
|
||||
* The resource object being normalized.
|
||||
*
|
||||
* @return array
|
||||
* The normalization structure as defined in ::getNormalization().
|
||||
*
|
||||
* @see ::getNormalization()
|
||||
*/
|
||||
protected static function buildEmptyNormalization(ResourceObject $object) {
|
||||
return [
|
||||
ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_BASE => [
|
||||
'type' => CacheableNormalization::permanent($object->getResourceType()->getTypeName()),
|
||||
'id' => CacheableNormalization::permanent($object->getId()),
|
||||
],
|
||||
ResourceObjectNormalizationCacher::RESOURCE_CACHE_SUBSET_FIELDS => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a given field.
|
||||
*
|
||||
* @param mixed $field
|
||||
* The field to serialize.
|
||||
* @param array $context
|
||||
* The normalization context.
|
||||
* @param string $format
|
||||
* The serialization format.
|
||||
*
|
||||
* @return \Drupal\jsonapi\Normalizer\Value\CacheableNormalization
|
||||
* The normalized value.
|
||||
*/
|
||||
protected function serializeField($field, array $context, $format) {
|
||||
// Only content entities contain FieldItemListInterface fields. Since config
|
||||
// entities do not have "real" fields and therefore do not have field access
|
||||
// restrictions.
|
||||
if ($field instanceof FieldItemListInterface) {
|
||||
$field_access_result = $field->access('view', $context['account'], TRUE);
|
||||
if (!$field_access_result->isAllowed()) {
|
||||
return new CacheableOmission(CacheableMetadata::createFromObject($field_access_result));
|
||||
}
|
||||
if ($field instanceof EntityReferenceFieldItemListInterface) {
|
||||
// Build the relationship object based on the entity reference and
|
||||
// normalize that object instead.
|
||||
assert(!empty($context['resource_object']) && $context['resource_object'] instanceof ResourceObject);
|
||||
$resource_object = $context['resource_object'];
|
||||
$relationship = Relationship::createFromEntityReferenceField($resource_object, $field);
|
||||
$normalized_field = $this->serializer->normalize($relationship, $format, $context);
|
||||
}
|
||||
else {
|
||||
$normalized_field = $this->serializer->normalize($field, $format, $context);
|
||||
}
|
||||
assert($normalized_field instanceof CacheableNormalization);
|
||||
return $normalized_field->withCacheableDependency(CacheableMetadata::createFromObject($field_access_result));
|
||||
}
|
||||
else {
|
||||
// @todo Replace this workaround after https://www.drupal.org/node/3043245
|
||||
// or remove the need for this in https://www.drupal.org/node/2942975.
|
||||
// See \Drupal\layout_builder\Normalizer\LayoutEntityDisplayNormalizer.
|
||||
if ($context['resource_object']->getResourceType()->getDeserializationTargetClass() === 'Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay' && $context['resource_object']->getField('third_party_settings') === $field) {
|
||||
unset($field['layout_builder']['sections']);
|
||||
}
|
||||
|
||||
// Config "fields" in this case are arrays or primitives and do not need
|
||||
// to be normalized.
|
||||
return CacheableNormalization::permanent($field);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer;
|
||||
|
||||
use Drupal\Component\Render\PlainTextOutput;
|
||||
use Drupal\jsonapi\Exception\UnprocessableHttpEntityException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
/**
|
||||
* Normalizes and UnprocessableHttpEntityException.
|
||||
*
|
||||
* Normalizes an UnprocessableHttpEntityException in compliance with the JSON
|
||||
* API specification. A source pointer is added to help client applications
|
||||
* report validation errors, for example on an Entity edit form.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see http://jsonapi.org/format/#error-objects
|
||||
*/
|
||||
class UnprocessableHttpEntityExceptionNormalizer extends HttpExceptionNormalizer {
|
||||
|
||||
/**
|
||||
* The interface or class that this Normalizer supports.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $supportedInterfaceOrClass = UnprocessableHttpEntityException::class;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function buildErrorObjects(HttpException $exception) {
|
||||
/* @var $exception \Drupal\jsonapi\Exception\UnprocessableHttpEntityException */
|
||||
$errors = parent::buildErrorObjects($exception);
|
||||
$error = $errors[0];
|
||||
unset($error['links']);
|
||||
|
||||
$errors = [];
|
||||
$violations = $exception->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() . ': '
|
||||
. PlainTextOutput::renderFromHtml($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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer\Value;
|
||||
|
||||
use Drupal\Component\Assertion\Inspector;
|
||||
use Drupal\Core\Cache\CacheableDependencyInterface;
|
||||
use Drupal\Core\Cache\CacheableDependencyTrait;
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
|
||||
/**
|
||||
* Use to store normalized data and its cacheability.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class CacheableNormalization implements CacheableDependencyInterface {
|
||||
|
||||
use CacheableDependencyTrait;
|
||||
|
||||
/**
|
||||
* A normalized value.
|
||||
*
|
||||
* @var mixed
|
||||
*/
|
||||
protected $normalization;
|
||||
|
||||
/**
|
||||
* CacheableNormalization constructor.
|
||||
*
|
||||
* @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
|
||||
* The cacheability metadata for the normalized data.
|
||||
* @param array|string|int|float|bool|null $normalization
|
||||
* The normalized data. This value must not contain any
|
||||
* CacheableNormalizations.
|
||||
*/
|
||||
public function __construct(CacheableDependencyInterface $cacheability, $normalization) {
|
||||
assert((is_array($normalization) && static::hasNoNestedInstances($normalization)) || is_string($normalization) || is_int($normalization) || is_float($normalization) || is_bool($normalization) || is_null($normalization));
|
||||
$this->normalization = $normalization;
|
||||
$this->setCacheability($cacheability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a CacheableNormalization instance without any special cacheability.
|
||||
*
|
||||
* @param array|string|int|float|bool|null $normalization
|
||||
* The normalized data. This value must not contain any
|
||||
* CacheableNormalizations.
|
||||
*
|
||||
* @return static
|
||||
* The CacheableNormalization.
|
||||
*/
|
||||
public static function permanent($normalization) {
|
||||
return new static(new CacheableMetadata(), $normalization);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the decorated normalization.
|
||||
*
|
||||
* @return array|string|int|float|bool|null
|
||||
* The normalization.
|
||||
*/
|
||||
public function getNormalization() {
|
||||
return $this->normalization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the object to a CacheableOmission if the normalization is empty.
|
||||
*
|
||||
* @return self|\Drupal\jsonapi\Normalizer\Value\CacheableOmission
|
||||
* A CacheableOmission if the normalization is considered empty, self
|
||||
* otherwise.
|
||||
*/
|
||||
public function omitIfEmpty() {
|
||||
return empty($this->normalization) ? new CacheableOmission($this) : $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new CacheableNormalization with an additional dependency.
|
||||
*
|
||||
* @param \Drupal\Core\Cache\CacheableDependencyInterface $dependency
|
||||
* The new cacheable dependency.
|
||||
*
|
||||
* @return static
|
||||
* A new object based on the current value with an additional cacheable
|
||||
* dependency.
|
||||
*/
|
||||
public function withCacheableDependency(CacheableDependencyInterface $dependency) {
|
||||
return new static(CacheableMetadata::createFromObject($this)->addCacheableDependency($dependency), $this->normalization);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects an array of CacheableNormalizations into a single instance.
|
||||
*
|
||||
* @param \Drupal\jsonapi\Normalizer\Value\CacheableNormalization[] $cacheable_normalizations
|
||||
* An array of CacheableNormalizations.
|
||||
*
|
||||
* @return static
|
||||
* A new CacheableNormalization. Each input value's cacheability will be
|
||||
* merged into the return value's cacheability. The return value's
|
||||
* normalization will be an array of the input's normalizations. This method
|
||||
* does *not* behave like array_merge() or NestedArray::mergeDeep().
|
||||
*/
|
||||
public static function aggregate(array $cacheable_normalizations) {
|
||||
assert(Inspector::assertAllObjects($cacheable_normalizations, CacheableNormalization::class));
|
||||
return new static(
|
||||
array_reduce($cacheable_normalizations, function (CacheableMetadata $merged, CacheableNormalization $item) {
|
||||
return $merged->addCacheableDependency($item);
|
||||
}, new CacheableMetadata()),
|
||||
array_reduce(array_keys($cacheable_normalizations), function ($merged, $key) use ($cacheable_normalizations) {
|
||||
if (!$cacheable_normalizations[$key] instanceof CacheableOmission) {
|
||||
$merged[$key] = $cacheable_normalizations[$key]->getNormalization();
|
||||
}
|
||||
return $merged;
|
||||
}, [])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that no nested values are instances of this class.
|
||||
*
|
||||
* @param array|\Traversable $array
|
||||
* The traversable object which may contain instance of this object.
|
||||
*
|
||||
* @return bool
|
||||
* Whether the given object or its children have CacheableNormalizations in
|
||||
* them.
|
||||
*/
|
||||
protected static function hasNoNestedInstances($array) {
|
||||
foreach ($array as $value) {
|
||||
if ((is_array($value) || $value instanceof \Traversable) && !static::hasNoNestedInstances($value) || $value instanceof static) {
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer\Value;
|
||||
|
||||
use Drupal\Core\Cache\CacheableDependencyInterface;
|
||||
|
||||
/**
|
||||
* Represents the cacheability associated with the omission of a value.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
final class CacheableOmission extends CacheableNormalization {
|
||||
|
||||
/**
|
||||
* CacheableOmission constructor.
|
||||
*
|
||||
* @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
|
||||
* Cacheability related to the omission of the normalization. For example,
|
||||
* if a field is omitted because of an access result that varies by the
|
||||
* `user.permissions` cache context, we need to associate that information
|
||||
* with the response so that it will appear for a user *with* the
|
||||
* appropriate permissions for that field.
|
||||
*/
|
||||
public function __construct(CacheableDependencyInterface $cacheability) {
|
||||
parent::__construct($cacheability, NULL);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function permanent($no_op = NULL) {
|
||||
return parent::permanent(NULL);
|
||||
}
|
||||
|
||||
/**
|
||||
* A CacheableOmission should never have its normalization retrieved.
|
||||
*/
|
||||
public function getNormalization() {
|
||||
throw new \LogicException('A CacheableOmission should never have its normalization retrieved.');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Normalizer\Value;
|
||||
|
||||
/**
|
||||
* Helps normalize exceptions in compliance with the JSON:API spec.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class HttpExceptionNormalizerValue extends CacheableNormalization {}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\ParamConverter;
|
||||
|
||||
use Drupal\Core\Entity\TranslatableInterface;
|
||||
use Drupal\Core\Language\LanguageInterface;
|
||||
use Drupal\Core\Language\LanguageManagerInterface;
|
||||
use Drupal\Core\ParamConverter\EntityConverter;
|
||||
use Drupal\jsonapi\Routing\Routes;
|
||||
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||
use Symfony\Component\Routing\Route;
|
||||
|
||||
/**
|
||||
* Parameter converter for upcasting entity UUIDs to full objects.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see \Drupal\Core\ParamConverter\EntityConverter
|
||||
*
|
||||
* @todo Remove when https://www.drupal.org/node/2353611 lands.
|
||||
*/
|
||||
class EntityUuidConverter extends EntityConverter {
|
||||
|
||||
/**
|
||||
* The language manager.
|
||||
*
|
||||
* @var \Drupal\Core\Language\LanguageManagerInterface
|
||||
*/
|
||||
protected $languageManager;
|
||||
|
||||
/**
|
||||
* Injects the language manager.
|
||||
*
|
||||
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
|
||||
* The language manager to get the current content language.
|
||||
*/
|
||||
public function setLanguageManager(LanguageManagerInterface $language_manager) {
|
||||
$this->languageManager = $language_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function convert($value, $definition, $name, array $defaults) {
|
||||
$entity_type_id = $this->getEntityTypeFromDefaults($definition, $name, $defaults);
|
||||
// @see https://www.drupal.org/project/drupal/issues/2624770
|
||||
$entity_type_manager = isset($this->entityTypeManager)
|
||||
? $this->entityTypeManager
|
||||
: $this->entityManager;
|
||||
$uuid_key = $entity_type_manager->getDefinition($entity_type_id)
|
||||
->getKey('uuid');
|
||||
if ($storage = $entity_type_manager->getStorage($entity_type_id)) {
|
||||
if (!$entities = $storage->loadByProperties([$uuid_key => $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 TranslatableInterface && $entity->isTranslatable()) {
|
||||
// @see https://www.drupal.org/project/drupal/issues/2624770
|
||||
$entity_repository = isset($this->entityRepository) ? $this->entityRepository : $this->entityManager;
|
||||
$entity = $entity_repository->getTranslationFromContext($entity, NULL, ['operation' => 'entity_upcast']);
|
||||
// JSON:API always has only one method per route.
|
||||
$method = $defaults[RouteObjectInterface::ROUTE_OBJECT]->getMethods()[0];
|
||||
if (in_array($method, ['PATCH', 'DELETE'], TRUE)) {
|
||||
$current_content_language = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId();
|
||||
if ($method === 'DELETE' && (!$entity->isDefaultTranslation() || $entity->language()->getId() !== $current_content_language)) {
|
||||
throw new MethodNotAllowedHttpException(['GET'], 'Deleting a resource object translation is not yet supported. See https://www.drupal.org/docs/8/modules/jsonapi/translations.');
|
||||
}
|
||||
if ($method === 'PATCH' && $entity->language()->getId() !== $current_content_language) {
|
||||
$available_translations = implode(', ', array_keys($entity->getTranslationLanguages()));
|
||||
throw new MethodNotAllowedHttpException(['GET'], sprintf('The requested translation of the resource object does not exist, instead modify one of the translations that do exist: %s.', $available_translations));
|
||||
}
|
||||
}
|
||||
}
|
||||
return $entity;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function applies($definition, $name, Route $route) {
|
||||
return (
|
||||
(bool) Routes::getResourceTypeNameFromParameters($route->getDefaults()) &&
|
||||
!empty($definition['type']) && strpos($definition['type'], 'entity') === 0
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\ParamConverter;
|
||||
|
||||
use Drupal\Core\ParamConverter\ParamConverterInterface;
|
||||
use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
|
||||
use Symfony\Component\Routing\Route;
|
||||
|
||||
/**
|
||||
* Parameter converter for upcasting JSON:API resource type names to objects.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class ResourceTypeConverter implements ParamConverterInterface {
|
||||
|
||||
/**
|
||||
* The route parameter type to match.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const PARAM_TYPE_ID = 'jsonapi_resource_type';
|
||||
|
||||
/**
|
||||
* The JSON:API resource type repository.
|
||||
*
|
||||
* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
|
||||
*/
|
||||
protected $resourceTypeRepository;
|
||||
|
||||
/**
|
||||
* ResourceTypeConverter constructor.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
|
||||
* The JSON:API resource type repository.
|
||||
*/
|
||||
public function __construct(ResourceTypeRepositoryInterface $resource_type_repository) {
|
||||
$this->resourceTypeRepository = $resource_type_repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function convert($value, $definition, $name, array $defaults) {
|
||||
return $this->resourceTypeRepository->getByTypeName($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function applies($definition, $name, Route $route) {
|
||||
return (!empty($definition['type']) && $definition['type'] === static::PARAM_TYPE_ID);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Query;
|
||||
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
|
||||
|
||||
/**
|
||||
* A condition object for the EntityQuery.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class EntityCondition {
|
||||
|
||||
/**
|
||||
* The field key in the filter condition: filter[lorem][condition][<field>].
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const PATH_KEY = 'path';
|
||||
|
||||
/**
|
||||
* The value key in the filter condition: filter[lorem][condition][<value>].
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const VALUE_KEY = 'value';
|
||||
|
||||
/**
|
||||
* The operator key in the condition: filter[lorem][condition][<operator>].
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const OPERATOR_KEY = 'operator';
|
||||
|
||||
/**
|
||||
* The allowed condition operators.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
public static $allowedOperators = [
|
||||
'=', '<>',
|
||||
'>', '>=', '<', '<=',
|
||||
'STARTS_WITH', 'CONTAINS', 'ENDS_WITH',
|
||||
'IN', 'NOT IN',
|
||||
'BETWEEN', 'NOT BETWEEN',
|
||||
'IS NULL', 'IS NOT NULL',
|
||||
];
|
||||
|
||||
/**
|
||||
* The field to be evaluated.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $field;
|
||||
|
||||
/**
|
||||
* The condition operator.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $operator;
|
||||
|
||||
/**
|
||||
* The value against which the field should be evaluated.
|
||||
*
|
||||
* @var mixed
|
||||
*/
|
||||
protected $value;
|
||||
|
||||
/**
|
||||
* Constructs a new EntityCondition object.
|
||||
*/
|
||||
public function __construct($field, $value, $operator = NULL) {
|
||||
$this->field = $field;
|
||||
$this->value = $value;
|
||||
$this->operator = ($operator) ? $operator : '=';
|
||||
}
|
||||
|
||||
/**
|
||||
* The field to be evaluated.
|
||||
*
|
||||
* @return string
|
||||
* The field upon which to evaluate the condition.
|
||||
*/
|
||||
public function field() {
|
||||
return $this->field;
|
||||
}
|
||||
|
||||
/**
|
||||
* The comparison operator to use for the evaluation.
|
||||
*
|
||||
* For a list of allowed operators:
|
||||
*
|
||||
* @see \Drupal\jsonapi\Query\EntityCondition::allowedOperators
|
||||
*
|
||||
* @return string
|
||||
* The condition operator.
|
||||
*/
|
||||
public function operator() {
|
||||
return $this->operator;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value against which the condition should be evaluated.
|
||||
*
|
||||
* @return mixed
|
||||
* The condition comparison value.
|
||||
*/
|
||||
public function value() {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an EntityCondition object from a query parameter.
|
||||
*
|
||||
* @param mixed $parameter
|
||||
* The `filter[condition]` query parameter from the request.
|
||||
*
|
||||
* @return self
|
||||
* An EntityCondition object with defaults.
|
||||
*/
|
||||
public static function createFromQueryParameter($parameter) {
|
||||
static::validate($parameter);
|
||||
$field = $parameter[static::PATH_KEY];
|
||||
$value = (isset($parameter[static::VALUE_KEY])) ? $parameter[static::VALUE_KEY] : NULL;
|
||||
$operator = (isset($parameter[static::OPERATOR_KEY])) ? $parameter[static::OPERATOR_KEY] : NULL;
|
||||
return new static($field, $value, $operator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the filter has the required fields.
|
||||
*/
|
||||
protected static function validate($parameter) {
|
||||
$valid_key_combinations = [
|
||||
[static::PATH_KEY, static::VALUE_KEY],
|
||||
[static::PATH_KEY, static::OPERATOR_KEY],
|
||||
[static::PATH_KEY, static::VALUE_KEY, static::OPERATOR_KEY],
|
||||
];
|
||||
|
||||
$given_keys = array_keys($parameter);
|
||||
$valid_key_set = array_reduce($valid_key_combinations, function ($valid, $set) use ($given_keys) {
|
||||
return ($valid) ? $valid : count(array_diff($set, $given_keys)) === 0;
|
||||
}, FALSE);
|
||||
|
||||
$has_operator_key = isset($parameter[static::OPERATOR_KEY]);
|
||||
$has_path_key = isset($parameter[static::PATH_KEY]);
|
||||
$has_value_key = isset($parameter[static::VALUE_KEY]);
|
||||
|
||||
$cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:filter']);
|
||||
if (!$valid_key_set) {
|
||||
// Try to provide a more specific exception is a key is missing.
|
||||
if (!$has_operator_key) {
|
||||
if (!$has_path_key) {
|
||||
throw new CacheableBadRequestHttpException($cacheability, "Filter parameter is missing a '" . static::PATH_KEY . "' key.");
|
||||
}
|
||||
if (!$has_value_key) {
|
||||
throw new CacheableBadRequestHttpException($cacheability, "Filter parameter is missing a '" . static::VALUE_KEY . "' key.");
|
||||
}
|
||||
}
|
||||
|
||||
// Catchall exception.
|
||||
$reason = "You must provide a valid filter condition. Check that you have set the required keys for your filter.";
|
||||
throw new CacheableBadRequestHttpException($cacheability, $reason);
|
||||
}
|
||||
|
||||
if ($has_operator_key) {
|
||||
$operator = $parameter[static::OPERATOR_KEY];
|
||||
if (!in_array($operator, static::$allowedOperators)) {
|
||||
$reason = "The '" . $operator . "' operator is not allowed in a filter parameter.";
|
||||
throw new CacheableBadRequestHttpException($cacheability, $reason);
|
||||
}
|
||||
|
||||
if (in_array($operator, ['IS NULL', 'IS NOT NULL']) && $has_value_key) {
|
||||
$reason = "Filters using the '" . $operator . "' operator should not provide a value.";
|
||||
throw new CacheableBadRequestHttpException($cacheability, $reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Query;
|
||||
|
||||
/**
|
||||
* A condition group for the EntityQuery.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class EntityConditionGroup {
|
||||
|
||||
/**
|
||||
* The AND conjunction value.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected static $allowedConjunctions = ['AND', 'OR'];
|
||||
|
||||
/**
|
||||
* The conjunction.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $conjunction;
|
||||
|
||||
/**
|
||||
* The members of the condition group.
|
||||
*
|
||||
* @var \Drupal\jsonapi\Query\EntityCondition[]
|
||||
*/
|
||||
protected $members;
|
||||
|
||||
/**
|
||||
* Constructs a new condition group object.
|
||||
*
|
||||
* @param string $conjunction
|
||||
* The group conjunction to use.
|
||||
* @param array $members
|
||||
* (optional) The group conjunction to use.
|
||||
*/
|
||||
public function __construct($conjunction, array $members = []) {
|
||||
if (!in_array($conjunction, self::$allowedConjunctions)) {
|
||||
throw new \InvalidArgumentException('Allowed conjunctions: AND, OR.');
|
||||
}
|
||||
$this->conjunction = $conjunction;
|
||||
$this->members = $members;
|
||||
}
|
||||
|
||||
/**
|
||||
* The condition group conjunction.
|
||||
*
|
||||
* @return string
|
||||
* The condition group conjunction.
|
||||
*/
|
||||
public function conjunction() {
|
||||
return $this->conjunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* The members which belong to the condition group.
|
||||
*
|
||||
* @return \Drupal\jsonapi\Query\EntityCondition[]
|
||||
* The member conditions of this condition group.
|
||||
*/
|
||||
public function members() {
|
||||
return $this->members;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Query;
|
||||
|
||||
use Drupal\Core\Entity\Query\QueryInterface;
|
||||
use Drupal\jsonapi\Context\FieldResolver;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
|
||||
/**
|
||||
* Gathers information about the filter parameter.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class Filter {
|
||||
|
||||
/**
|
||||
* The JSON:API filter key name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const KEY_NAME = 'filter';
|
||||
|
||||
/**
|
||||
* The key for the implicit root group.
|
||||
*/
|
||||
const ROOT_ID = '@root';
|
||||
|
||||
/**
|
||||
* Key in the filter[<key>] parameter for conditions.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const CONDITION_KEY = 'condition';
|
||||
|
||||
/**
|
||||
* Key in the filter[<key>] parameter for groups.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const GROUP_KEY = 'group';
|
||||
|
||||
/**
|
||||
* Key in the filter[<id>][<key>] parameter for group membership.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const MEMBER_KEY = 'memberOf';
|
||||
|
||||
/**
|
||||
* The root condition group.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $root;
|
||||
|
||||
/**
|
||||
* Constructs a new Filter object.
|
||||
*
|
||||
* @param \Drupal\jsonapi\Query\EntityConditionGroup $root
|
||||
* An entity condition group which can be applied to an entity query.
|
||||
*/
|
||||
public function __construct(EntityConditionGroup $root) {
|
||||
$this->root = $root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the root condition group.
|
||||
*/
|
||||
public function root() {
|
||||
return $this->root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the root condition to the given query.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\Query\QueryInterface $query
|
||||
* The query for which the condition should be constructed.
|
||||
*
|
||||
* @return \Drupal\Core\Entity\Query\ConditionInterface
|
||||
* The compiled entity query condition.
|
||||
*/
|
||||
public function queryCondition(QueryInterface $query) {
|
||||
$condition = $this->buildGroup($query, $this->root());
|
||||
return $condition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the root condition to the given query.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\Query\QueryInterface $query
|
||||
* The query to which the filter should be applied.
|
||||
* @param \Drupal\jsonapi\Query\EntityConditionGroup $condition_group
|
||||
* The condition group to build.
|
||||
*
|
||||
* @return \Drupal\Core\Entity\Query\ConditionInterface
|
||||
* The query with the filter applied.
|
||||
*/
|
||||
protected function buildGroup(QueryInterface $query, EntityConditionGroup $condition_group) {
|
||||
// Create a condition group using the original query.
|
||||
switch ($condition_group->conjunction()) {
|
||||
case 'AND':
|
||||
$group = $query->andConditionGroup();
|
||||
break;
|
||||
|
||||
case 'OR':
|
||||
$group = $query->orConditionGroup();
|
||||
break;
|
||||
}
|
||||
|
||||
// Get all children of the group.
|
||||
$members = $condition_group->members();
|
||||
|
||||
foreach ($members as $member) {
|
||||
// If the child is simply a condition, add it to the new group.
|
||||
if ($member instanceof EntityCondition) {
|
||||
if ($member->operator() == 'IS NULL') {
|
||||
$group->notExists($member->field());
|
||||
}
|
||||
elseif ($member->operator() == 'IS NOT NULL') {
|
||||
$group->exists($member->field());
|
||||
}
|
||||
else {
|
||||
$group->condition($member->field(), $member->value(), $member->operator());
|
||||
}
|
||||
}
|
||||
// If the child is a group, then recursively construct a sub group.
|
||||
elseif ($member instanceof EntityConditionGroup) {
|
||||
// Add the subgroup to this new group.
|
||||
$subgroup = $this->buildGroup($query, $member);
|
||||
$group->condition($subgroup);
|
||||
}
|
||||
}
|
||||
|
||||
// Return the constructed group so that it can be added to the query.
|
||||
return $group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Sort object from a query parameter.
|
||||
*
|
||||
* @param mixed $parameter
|
||||
* The `filter` query parameter from the Symfony request object.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type.
|
||||
* @param \Drupal\jsonapi\Context\FieldResolver $field_resolver
|
||||
* The JSON:API field resolver.
|
||||
*
|
||||
* @return self
|
||||
* A Sort object with defaults.
|
||||
*/
|
||||
public static function createFromQueryParameter($parameter, ResourceType $resource_type, FieldResolver $field_resolver) {
|
||||
$expanded = static::expand($parameter);
|
||||
foreach ($expanded as &$filter_item) {
|
||||
if (isset($filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY])) {
|
||||
$unresolved = $filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY];
|
||||
$filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY] = $field_resolver->resolveInternalEntityQueryPath($resource_type, $unresolved);
|
||||
}
|
||||
}
|
||||
return new static(static::buildEntityConditionGroup($expanded));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands any filter parameters using shorthand notation.
|
||||
*
|
||||
* @param array $original
|
||||
* The unexpanded filter data.
|
||||
*
|
||||
* @return array
|
||||
* The expanded filter data.
|
||||
*/
|
||||
protected static function expand(array $original) {
|
||||
$expanded = [];
|
||||
foreach ($original as $key => $item) {
|
||||
// Allow extreme shorthand filters, f.e. `?filter[promote]=1`.
|
||||
if (!is_array($item)) {
|
||||
$item = [
|
||||
EntityCondition::VALUE_KEY => $item,
|
||||
];
|
||||
}
|
||||
|
||||
// Throw an exception if the query uses the reserved filter id for the
|
||||
// root group.
|
||||
if ($key == static::ROOT_ID) {
|
||||
$msg = sprintf("'%s' is a reserved filter id.", static::ROOT_ID);
|
||||
throw new \UnexpectedValueException($msg);
|
||||
}
|
||||
|
||||
// Add a memberOf key to all items.
|
||||
if (isset($item[static::CONDITION_KEY][static::MEMBER_KEY])) {
|
||||
$item[static::MEMBER_KEY] = $item[static::CONDITION_KEY][static::MEMBER_KEY];
|
||||
unset($item[static::CONDITION_KEY][static::MEMBER_KEY]);
|
||||
}
|
||||
elseif (isset($item[static::GROUP_KEY][static::MEMBER_KEY])) {
|
||||
$item[static::MEMBER_KEY] = $item[static::GROUP_KEY][static::MEMBER_KEY];
|
||||
unset($item[static::GROUP_KEY][static::MEMBER_KEY]);
|
||||
}
|
||||
else {
|
||||
$item[static::MEMBER_KEY] = static::ROOT_ID;
|
||||
}
|
||||
|
||||
// Add the filter id to all items.
|
||||
$item['id'] = $key;
|
||||
|
||||
// Expands shorthand filters.
|
||||
$expanded[$key] = static::expandItem($key, $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 static function expandItem($filter_index, array $filter_item) {
|
||||
if (isset($filter_item[EntityCondition::VALUE_KEY])) {
|
||||
if (!isset($filter_item[EntityCondition::PATH_KEY])) {
|
||||
$filter_item[EntityCondition::PATH_KEY] = $filter_index;
|
||||
}
|
||||
|
||||
$filter_item = [
|
||||
static::CONDITION_KEY => $filter_item,
|
||||
static::MEMBER_KEY => $filter_item[static::MEMBER_KEY],
|
||||
];
|
||||
}
|
||||
|
||||
if (!isset($filter_item[static::CONDITION_KEY][EntityCondition::OPERATOR_KEY])) {
|
||||
$filter_item[static::CONDITION_KEY][EntityCondition::OPERATOR_KEY] = '=';
|
||||
}
|
||||
|
||||
return $filter_item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Denormalizes the given filter items into a single EntityConditionGroup.
|
||||
*
|
||||
* @param array $items
|
||||
* The normalized entity conditions and groups.
|
||||
*
|
||||
* @return \Drupal\jsonapi\Query\EntityConditionGroup
|
||||
* A root group containing all the denormalized conditions and groups.
|
||||
*/
|
||||
protected static function buildEntityConditionGroup(array $items) {
|
||||
$root = [
|
||||
'id' => static::ROOT_ID,
|
||||
static::GROUP_KEY => ['conjunction' => 'AND'],
|
||||
];
|
||||
return static::buildTree($root, $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizes the flat, normalized filter items into a tree structure.
|
||||
*
|
||||
* @param array $root
|
||||
* The root of the tree to build.
|
||||
* @param array $items
|
||||
* The normalized entity conditions and groups.
|
||||
*
|
||||
* @return \Drupal\jsonapi\Query\EntityConditionGroup
|
||||
* The entity condition group
|
||||
*/
|
||||
protected static function buildTree(array $root, array $items) {
|
||||
$id = $root['id'];
|
||||
|
||||
// Recursively build a tree of denormalized conditions and condition groups.
|
||||
$members = [];
|
||||
foreach ($items as $item) {
|
||||
if ($item[static::MEMBER_KEY] == $id) {
|
||||
if (isset($item[static::GROUP_KEY])) {
|
||||
array_push($members, static::buildTree($item, $items));
|
||||
}
|
||||
elseif (isset($item[static::CONDITION_KEY])) {
|
||||
$condition = EntityCondition::createFromQueryParameter($item[static::CONDITION_KEY]);
|
||||
array_push($members, $condition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$root[static::GROUP_KEY]['members'] = $members;
|
||||
|
||||
// Denormalize the root into a condition group.
|
||||
return new EntityConditionGroup($root[static::GROUP_KEY]['conjunction'], $root[static::GROUP_KEY]['members']);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Query;
|
||||
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Value object for containing the requested offset and page parameters.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class OffsetPage {
|
||||
|
||||
/**
|
||||
* The JSON:API pagination key name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const KEY_NAME = 'page';
|
||||
|
||||
/**
|
||||
* The offset key in the page parameter: page[offset].
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const OFFSET_KEY = 'offset';
|
||||
|
||||
/**
|
||||
* The size key in the page parameter: page[limit].
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const SIZE_KEY = 'limit';
|
||||
|
||||
/**
|
||||
* Default offset.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const DEFAULT_OFFSET = 0;
|
||||
|
||||
/**
|
||||
* Max size.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const SIZE_MAX = 50;
|
||||
|
||||
/**
|
||||
* The offset for the query.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $offset;
|
||||
|
||||
/**
|
||||
* The size of the query.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $size;
|
||||
|
||||
/**
|
||||
* Instantiates an OffsetPage object.
|
||||
*
|
||||
* @param int $offset
|
||||
* The query offset.
|
||||
* @param int $size
|
||||
* The query size limit.
|
||||
*/
|
||||
public function __construct($offset, $size) {
|
||||
$this->offset = $offset;
|
||||
$this->size = $size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current offset.
|
||||
*
|
||||
* @return int
|
||||
* The query offset.
|
||||
*/
|
||||
public function getOffset() {
|
||||
return $this->offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the page size.
|
||||
*
|
||||
* @return int
|
||||
* The requested size of the query result.
|
||||
*/
|
||||
public function getSize() {
|
||||
return $this->size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an OffsetPage object from a query parameter.
|
||||
*
|
||||
* @param mixed $parameter
|
||||
* The `page` query parameter from the Symfony request object.
|
||||
*
|
||||
* @return static
|
||||
* An OffsetPage object with defaults.
|
||||
*/
|
||||
public static function createFromQueryParameter($parameter) {
|
||||
if (!is_array($parameter)) {
|
||||
$cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:page']);
|
||||
throw new CacheableBadRequestHttpException($cacheability, 'The page parameter needs to be an array.');
|
||||
}
|
||||
|
||||
$expanded = $parameter + [
|
||||
static::OFFSET_KEY => static::DEFAULT_OFFSET,
|
||||
static::SIZE_KEY => static::SIZE_MAX,
|
||||
];
|
||||
|
||||
if ($expanded[static::SIZE_KEY] > static::SIZE_MAX) {
|
||||
$expanded[static::SIZE_KEY] = static::SIZE_MAX;
|
||||
}
|
||||
|
||||
return new static($expanded[static::OFFSET_KEY], $expanded[static::SIZE_KEY]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Query;
|
||||
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Gathers information about the sort parameter.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class Sort {
|
||||
|
||||
/**
|
||||
* The JSON:API sort key name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const KEY_NAME = 'sort';
|
||||
|
||||
/**
|
||||
* The field key in the sort parameter: sort[lorem][<field>].
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const PATH_KEY = 'path';
|
||||
|
||||
/**
|
||||
* The direction key in the sort parameter: sort[lorem][<direction>].
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const DIRECTION_KEY = 'direction';
|
||||
|
||||
/**
|
||||
* The langcode key in the sort parameter: sort[lorem][<langcode>].
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const LANGUAGE_KEY = 'langcode';
|
||||
|
||||
/**
|
||||
* The fields on which to sort.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $fields;
|
||||
|
||||
/**
|
||||
* Constructs a new Sort object.
|
||||
*
|
||||
* Takes an array of sort fields. Example:
|
||||
* [
|
||||
* [
|
||||
* 'path' => 'changed',
|
||||
* 'direction' => 'DESC',
|
||||
* ],
|
||||
* [
|
||||
* 'path' => 'title',
|
||||
* 'direction' => 'ASC',
|
||||
* 'langcode' => 'en-US',
|
||||
* ],
|
||||
* ]
|
||||
*
|
||||
* @param array $fields
|
||||
* The entity query sort fields.
|
||||
*/
|
||||
public function __construct(array $fields) {
|
||||
$this->fields = $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the root condition group.
|
||||
*/
|
||||
public function fields() {
|
||||
return $this->fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Sort object from a query parameter.
|
||||
*
|
||||
* @param mixed $parameter
|
||||
* The `sort` query parameter from the Symfony request object.
|
||||
*
|
||||
* @return self
|
||||
* A Sort object with defaults.
|
||||
*/
|
||||
public static function createFromQueryParameter($parameter) {
|
||||
if (empty($parameter)) {
|
||||
$cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:sort']);
|
||||
throw new CacheableBadRequestHttpException($cacheability, '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($parameter)) {
|
||||
$parameter = static::expandFieldString($parameter);
|
||||
}
|
||||
|
||||
// Expand any defaults into the sort array.
|
||||
$expanded = [];
|
||||
foreach ($parameter as $sort_index => $sort_item) {
|
||||
$expanded[$sort_index] = static::expandItem($sort_item);
|
||||
}
|
||||
|
||||
return new static($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 static function expandFieldString($fields) {
|
||||
return array_map(function ($field) {
|
||||
$sort = [];
|
||||
|
||||
if ($field[0] == '-') {
|
||||
$sort[static::DIRECTION_KEY] = 'DESC';
|
||||
$sort[static::PATH_KEY] = substr($field, 1);
|
||||
}
|
||||
else {
|
||||
$sort[static::DIRECTION_KEY] = 'ASC';
|
||||
$sort[static::PATH_KEY] = $field;
|
||||
}
|
||||
|
||||
return $sort;
|
||||
}, explode(',', $fields));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a sort item in case a shortcut was used.
|
||||
*
|
||||
* @param array $sort_item
|
||||
* The raw sort item.
|
||||
*
|
||||
* @return array
|
||||
* The expanded sort item.
|
||||
*/
|
||||
protected static function expandItem(array $sort_item) {
|
||||
$cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:sort']);
|
||||
$defaults = [
|
||||
static::DIRECTION_KEY => 'ASC',
|
||||
static::LANGUAGE_KEY => NULL,
|
||||
];
|
||||
|
||||
if (!isset($sort_item[static::PATH_KEY])) {
|
||||
throw new CacheableBadRequestHttpException($cacheability, 'You need to provide a field name for the sort parameter.');
|
||||
}
|
||||
|
||||
$expected_keys = [
|
||||
static::PATH_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 CacheableBadRequestHttpException($cacheability, 'You have provided an invalid set of sort keys.');
|
||||
}
|
||||
|
||||
return $expanded;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi;
|
||||
|
||||
use Drupal\Core\Cache\CacheableResponseInterface;
|
||||
use Drupal\Core\Cache\CacheableResponseTrait;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Contains data for serialization before sending the response.
|
||||
*
|
||||
* We do not want to abuse the $content property on the Response class to store
|
||||
* our response data. $content implies that the provided data must either be a
|
||||
* string or an object with a __toString() method, which is not a requirement
|
||||
* for data used here.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see \Drupal\rest\ModifiedResourceResponse
|
||||
*/
|
||||
class ResourceResponse extends Response implements CacheableResponseInterface {
|
||||
|
||||
use CacheableResponseTrait;
|
||||
|
||||
/**
|
||||
* Response data that should be serialized.
|
||||
*
|
||||
* @var mixed
|
||||
*/
|
||||
protected $responseData;
|
||||
|
||||
/**
|
||||
* Constructor for ResourceResponse objects.
|
||||
*
|
||||
* @param mixed $data
|
||||
* Response data that should be serialized.
|
||||
* @param int $status
|
||||
* The response status code.
|
||||
* @param array $headers
|
||||
* An array of response headers.
|
||||
*/
|
||||
public function __construct($data = NULL, $status = 200, array $headers = []) {
|
||||
$this->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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\ResourceType;
|
||||
|
||||
use Drupal\Core\Entity\FieldableEntityInterface;
|
||||
use Drupal\Core\Field\FieldDefinitionInterface;
|
||||
use Drupal\Core\TypedData\DataReferenceTargetDefinition;
|
||||
|
||||
/**
|
||||
* Value object containing all metadata for a JSON:API resource type.
|
||||
*
|
||||
* Used to generate routes (collection, individual, etcetera), generate
|
||||
* relationship links, and so on.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
|
||||
*/
|
||||
class ResourceType {
|
||||
|
||||
/**
|
||||
* The entity type ID.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $entityTypeId;
|
||||
|
||||
/**
|
||||
* The bundle ID.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $bundle;
|
||||
|
||||
/**
|
||||
* The type name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $typeName;
|
||||
|
||||
/**
|
||||
* The class to which a payload converts to.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $deserializationTargetClass;
|
||||
|
||||
/**
|
||||
* Whether this resource type is internal.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $internal;
|
||||
|
||||
/**
|
||||
* Whether this resource type's resources are locatable.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $isLocatable;
|
||||
|
||||
/**
|
||||
* Whether this resource type's resources are mutable.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $isMutable;
|
||||
|
||||
/**
|
||||
* Whether this resource type's resources are versionable.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $isVersionable;
|
||||
|
||||
/**
|
||||
* The list of fields on the underlying entity type + bundle.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $fields;
|
||||
|
||||
/**
|
||||
* An array of arrays of relatable resource types, keyed by public field name.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $relatableResourceTypesByField;
|
||||
|
||||
/**
|
||||
* The mapping for field aliases: keys=public names, values=internal names.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $fieldMapping;
|
||||
|
||||
/**
|
||||
* Gets the entity type ID.
|
||||
*
|
||||
* @return string
|
||||
* The entity type ID.
|
||||
*
|
||||
* @see \Drupal\Core\Entity\EntityInterface::getEntityTypeId
|
||||
*/
|
||||
public function getEntityTypeId() {
|
||||
return $this->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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the entity field name to the public field name.
|
||||
*
|
||||
* This is only here so we can allow polymorphic implementations to take a
|
||||
* greater control on the field names.
|
||||
*
|
||||
* @return string
|
||||
* The public field name.
|
||||
*/
|
||||
public function getPublicName($field_name) {
|
||||
// By default the entity field name is the public field name.
|
||||
return isset($this->fields[$field_name])
|
||||
? $this->fields[$field_name]->getPublicName()
|
||||
: $field_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the public field name to the entity field name.
|
||||
*
|
||||
* This is only here so we can allow polymorphic implementations to take a
|
||||
* greater control on the field names.
|
||||
*
|
||||
* @return string
|
||||
* The internal field name as defined in the entity.
|
||||
*/
|
||||
public function getInternalName($field_name) {
|
||||
// By default the entity field name is the public field name.
|
||||
return $this->fieldMapping[$field_name] ?? $field_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the attribute and relationship fields of this resource type.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceTypeField[]
|
||||
* The field objects on this resource type.
|
||||
*/
|
||||
public function getFields() {
|
||||
return $this->fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a particular attribute or relationship field by public field name.
|
||||
*
|
||||
* @param string $public_field_name
|
||||
* The public field name of the desired field.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceTypeField|null
|
||||
* A resource type field object or NULL if the field does not exist on this
|
||||
* resource type.
|
||||
*/
|
||||
public function getFieldByPublicName($public_field_name) {
|
||||
return isset($this->fieldMapping[$public_field_name])
|
||||
? $this->getFieldByInternalName($this->fieldMapping[$public_field_name])
|
||||
: NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a particular attribute or relationship field by internal field name.
|
||||
*
|
||||
* @param string $internal_field_name
|
||||
* The internal field name of the desired field.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceTypeField|null
|
||||
* A resource type field object or NULL if the field does not exist on this
|
||||
* resource type.
|
||||
*/
|
||||
public function getFieldByInternalName($internal_field_name) {
|
||||
return $this->fields[$internal_field_name] ?? NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the field exists.
|
||||
*
|
||||
* Note: a minority of config entity types which do not define a
|
||||
* `config_export` in their entity type annotation will not have their fields
|
||||
* represented here because it is impossible to determine them without an
|
||||
* instance of config available.
|
||||
*
|
||||
* @todo Refactor this in Drupal 9, because thanks to https://www.drupal.org/project/drupal/issues/2949021, `config_export` will be guaranteed to exist, and this won't need an instance anymore.
|
||||
*
|
||||
* @param string $field_name
|
||||
* The internal field name.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the field is known to exist on the resource type; FALSE
|
||||
* otherwise.
|
||||
*/
|
||||
public function hasField($field_name) {
|
||||
return array_key_exists($field_name, $this->fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a field is enabled or not.
|
||||
*
|
||||
* This is only here so we can allow polymorphic implementations to take a
|
||||
* greater control on the data model.
|
||||
*
|
||||
* @param string $field_name
|
||||
* The internal field name.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the field exists and is enabled and should be considered as part
|
||||
* of the data model. FALSE otherwise.
|
||||
*/
|
||||
public function isFieldEnabled($field_name) {
|
||||
return $this->hasField($field_name) && $this->fields[$field_name]->isFieldEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether to include a collection count.
|
||||
*
|
||||
* @return bool
|
||||
* Whether to include a collection count.
|
||||
*/
|
||||
public function includeCount() {
|
||||
// By default, do not return counts in collection queries.
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this resource type is internal.
|
||||
*
|
||||
* This must not be used as an access control mechanism.
|
||||
*
|
||||
* Internal resource types are not available via the HTTP API. They have no
|
||||
* routes and cannot be used for filtering or sorting. They cannot be included
|
||||
* in the response using the `include` query parameter.
|
||||
*
|
||||
* However, relationship fields on public resources *will include* a resource
|
||||
* identifier for the referenced internal resource.
|
||||
*
|
||||
* This method exists to remove data that should not logically be exposed by
|
||||
* the HTTP API. For example, read-only data from an internal resource might
|
||||
* be embedded in a public resource using computed fields. Therefore,
|
||||
* including the internal resource as a relationship with distinct routes
|
||||
* might uneccesarilly expose internal implementation details.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the resource type is internal. FALSE otherwise.
|
||||
*/
|
||||
public function isInternal() {
|
||||
return $this->internal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether resources of this resource type are locatable.
|
||||
*
|
||||
* A resource type may for example not be locatable when it is not stored.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the resource type's resources are locatable. FALSE otherwise.
|
||||
*/
|
||||
public function isLocatable() {
|
||||
return $this->isLocatable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether resources of this resource type are mutable.
|
||||
*
|
||||
* Indicates that resources of this type may not be created, updated or
|
||||
* deleted (POST, PATCH or DELETE, respectively).
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the resource type's resources are mutable. FALSE otherwise.
|
||||
*/
|
||||
public function isMutable() {
|
||||
return $this->isMutable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether resources of this resource type are versionable.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the resource type's resources are versionable. FALSE otherwise.
|
||||
*/
|
||||
public function isVersionable() {
|
||||
return $this->isVersionable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param bool $internal
|
||||
* (optional) Whether the resource type should be internal.
|
||||
* @param bool $is_locatable
|
||||
* (optional) Whether the resource type is locatable.
|
||||
* @param bool $is_mutable
|
||||
* (optional) Whether the resource type is mutable.
|
||||
* @param bool $is_versionable
|
||||
* (optional) Whether the resource type is versionable.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceTypeField[] $fields
|
||||
* (optional) The resource type fields, keyed by internal field name.
|
||||
*/
|
||||
public function __construct($entity_type_id, $bundle, $deserialization_target_class, $internal = FALSE, $is_locatable = TRUE, $is_mutable = TRUE, $is_versionable = FALSE, array $fields = []) {
|
||||
if (!empty($fields) && !reset($fields) instanceof ResourceTypeField) {
|
||||
$fields = $this->updateDeprecatedFieldMapping($fields, $entity_type_id, $bundle);
|
||||
}
|
||||
$this->entityTypeId = $entity_type_id;
|
||||
$this->bundle = $bundle;
|
||||
$this->deserializationTargetClass = $deserialization_target_class;
|
||||
$this->internal = $internal;
|
||||
$this->isLocatable = $is_locatable;
|
||||
$this->isMutable = $is_mutable;
|
||||
$this->isVersionable = $is_versionable;
|
||||
$this->fields = $fields;
|
||||
|
||||
$this->typeName = $this->bundle === '?'
|
||||
? 'unknown'
|
||||
: sprintf('%s--%s', $this->entityTypeId, $this->bundle);
|
||||
|
||||
$this->fieldMapping = array_flip(array_map(function (ResourceTypeField $field) {
|
||||
return $field->getPublicName();
|
||||
}, $this->fields));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the relatable resource types.
|
||||
*
|
||||
* @param array $relatable_resource_types
|
||||
* The resource types with which this resource type may have a relationship.
|
||||
* The array should be a multi-dimensional array keyed by public field name
|
||||
* whose values are an array of resource types. There may be duplicate
|
||||
* across resource types across fields, but not within a field.
|
||||
*/
|
||||
public function setRelatableResourceTypes(array $relatable_resource_types) {
|
||||
$this->fields = array_reduce(array_keys($relatable_resource_types), function ($fields, $public_field_name) use ($relatable_resource_types) {
|
||||
if (!isset($this->fieldMapping[$public_field_name])) {
|
||||
throw new \LogicException('A field must exist for relatable resource types to be set on it.');
|
||||
}
|
||||
$internal_field_name = $this->fieldMapping[$public_field_name];
|
||||
$field = $fields[$internal_field_name];
|
||||
assert($field instanceof ResourceTypeRelationship);
|
||||
$fields[$internal_field_name] = $field->withRelatableResourceTypes($relatable_resource_types[$public_field_name]);
|
||||
return $fields;
|
||||
}, $this->fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all resource types with which this type may have a relationship.
|
||||
*
|
||||
* @return array
|
||||
* The relatable resource types, keyed by relationship field names.
|
||||
*
|
||||
* @see self::setRelatableResourceTypes()
|
||||
*/
|
||||
public function getRelatableResourceTypes() {
|
||||
if (!isset($this->relatableResourceTypesByField)) {
|
||||
$this->relatableResourceTypesByField = array_reduce(array_map(function (ResourceTypeRelationship $field) {
|
||||
return [$field->getPublicName() => $field->getRelatableResourceTypes()];
|
||||
}, array_filter($this->fields, function (ResourceTypeField $field) {
|
||||
return $field instanceof ResourceTypeRelationship;
|
||||
})), 'array_merge', []);
|
||||
}
|
||||
return $this->relatableResourceTypesByField;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all resource types with which the given field may have a relationship.
|
||||
*
|
||||
* @param string $field_name
|
||||
* The public field name.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceType[]
|
||||
* The relatable JSON:API resource types.
|
||||
*
|
||||
* @see self::getRelatableResourceTypes()
|
||||
*/
|
||||
public function getRelatableResourceTypesByField($field_name) {
|
||||
return ($field = $this->getFieldByPublicName($field_name)) && $field instanceof ResourceTypeRelationship
|
||||
? $field->getRelatableResourceTypes()
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resource path.
|
||||
*
|
||||
* @return string
|
||||
* The path to access this resource type. Default: /entity_type_id/bundle.
|
||||
*
|
||||
* @see jsonapi.base_path
|
||||
*/
|
||||
public function getPath() {
|
||||
return sprintf('/%s/%s', $this->getEntityTypeId(), $this->getBundle());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function __get($name) {
|
||||
$class_name = self::class;
|
||||
@trigger_error("Using the ${$name} protected property of a {$class_name} is deprecated in Drupal 8.8.0 and will not be allowed in Drupal 9.0.0. Use {$class_name}::getFields() instead. See https://www.drupal.org/node/3084721.", E_USER_DEPRECATED);
|
||||
if ($name === 'disabledFields') {
|
||||
return array_map(function (ResourceTypeField $field) {
|
||||
return $field->getInternalName();
|
||||
}, array_filter($this->getFields(), function (ResourceTypeField $field) {
|
||||
return !$field->isFieldEnabled();
|
||||
}));
|
||||
}
|
||||
if ($name === 'invertedFieldMapping') {
|
||||
return array_reduce($this->getFields(), function ($inverted_field_mapping, ResourceTypeField $field) {
|
||||
$internal_field_name = $field->getInternalName();
|
||||
$public_field_name = $field->getPublicName();
|
||||
if ($field->isFieldEnabled() && $internal_field_name !== $public_field_name) {
|
||||
$inverted_field_mapping[$public_field_name] = $internal_field_name;
|
||||
}
|
||||
return $inverted_field_mapping;
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a deprecated field mapping and converts it to ResourceTypeFields.
|
||||
*
|
||||
* @param array $field_mapping
|
||||
* The deprecated field mapping.
|
||||
* @param string $entity_type_id
|
||||
* The entity type ID of the field mapping.
|
||||
* @param string $bundle
|
||||
* The bundle ID of the field mapping or the entity type ID if the entity
|
||||
* type does not have bundles.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceTypeField[]
|
||||
* The updated field mapping objects.
|
||||
*
|
||||
* @deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use
|
||||
* self::getFields() instead.
|
||||
*
|
||||
* @see https://www.drupal.org/project/drupal/issues/3014277
|
||||
*/
|
||||
private function updateDeprecatedFieldMapping(array $field_mapping, $entity_type_id, $bundle) {
|
||||
$class_name = self::class;
|
||||
@trigger_error("Passing an array with strings or booleans as a field mapping to {$class_name}::__construct() is deprecated in Drupal 8.8.0 and will not be allowed in Drupal 9.0.0. See \Drupal\jsonapi\ResourceTypeRepository::getFields(). See https://www.drupal.org/node/3084746.", E_USER_DEPRECATED);
|
||||
|
||||
// See \Drupal\jsonapi\ResourceType\ResourceTypeRepository::isReferenceFieldDefinition().
|
||||
$is_reference_field_definition = function (FieldDefinitionInterface $field_definition) {
|
||||
static $field_type_is_reference = [];
|
||||
|
||||
if (isset($field_type_is_reference[$field_definition->getType()])) {
|
||||
return $field_type_is_reference[$field_definition->getType()];
|
||||
}
|
||||
|
||||
/* @var \Drupal\Core\Field\TypedData\FieldItemDataDefinition $item_definition */
|
||||
$item_definition = $field_definition->getItemDefinition();
|
||||
$main_property = $item_definition->getMainPropertyName();
|
||||
$property_definition = $item_definition->getPropertyDefinition($main_property);
|
||||
|
||||
return $field_type_is_reference[$field_definition->getType()] = $property_definition instanceof DataReferenceTargetDefinition;
|
||||
};
|
||||
|
||||
/** @var \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager */
|
||||
$entity_type_manager = \Drupal::service('entity_type.manager');
|
||||
$is_fieldable = $entity_type_manager->getDefinition($entity_type_id)->entityClassImplements(FieldableEntityInterface::class);
|
||||
$field_definitions = $is_fieldable
|
||||
? \Drupal::service('entity_field.manager')->getFieldDefinitions($entity_type_id, $bundle)
|
||||
: [];
|
||||
|
||||
$fields = [];
|
||||
foreach ($field_mapping as $internal_field_name => $public_field_name) {
|
||||
assert(is_bool($public_field_name) || is_string($public_field_name));
|
||||
$field_definition = $is_fieldable && !empty($field_definitions[$internal_field_name])
|
||||
? $field_definitions[$internal_field_name]
|
||||
: NULL;
|
||||
$is_relationship_field = $field_definition && $is_reference_field_definition($field_definition);
|
||||
$has_one = !$field_definition || $field_definition->getFieldStorageDefinition()->getCardinality() === 1;
|
||||
$alias = is_string($public_field_name) ? $public_field_name : NULL;
|
||||
$fields[$internal_field_name] = $is_relationship_field
|
||||
? new ResourceTypeRelationship($internal_field_name, $alias, $public_field_name !== FALSE, $has_one)
|
||||
: new ResourceTypeAttribute($internal_field_name, $alias, $public_field_name !== FALSE, $has_one);
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\ResourceType;
|
||||
|
||||
/**
|
||||
* Specialization of a ResourceTypeField to represent a resource type attribute.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
|
||||
*/
|
||||
class ResourceTypeAttribute extends ResourceTypeField {}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\ResourceType;
|
||||
|
||||
use Drupal\Component\Assertion\Inspector;
|
||||
use Drupal\Core\Entity\EntityTypeInterface;
|
||||
use Symfony\Component\EventDispatcher\Event;
|
||||
|
||||
/**
|
||||
* An event used to configure the construction of a JSON:API resource type.
|
||||
*
|
||||
* @see \Drupal\jsonapi\ResourceType\ResourceTypeBuildEvents
|
||||
* @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
|
||||
*/
|
||||
class ResourceTypeBuildEvent extends Event {
|
||||
|
||||
/**
|
||||
* The JSON:API resource type name of the instance to be built.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $resourceTypeName;
|
||||
|
||||
/**
|
||||
* The fields of the resource type to be built.
|
||||
*
|
||||
* @var \Drupal\jsonapi\ResourceType\ResourceTypeField[]
|
||||
*/
|
||||
protected $fields;
|
||||
|
||||
/**
|
||||
* Whether the JSON:API resource type to be built should be disabled.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $disabled = FALSE;
|
||||
|
||||
/**
|
||||
* ResourceTypeBuildEvent constructor.
|
||||
*
|
||||
* This constructor is protected by design. Use
|
||||
* static::createFromEntityTypeAndBundle() instead.
|
||||
*
|
||||
* @param string $resource_type_name
|
||||
* A JSON:API resource type name.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceTypeField[] $fields
|
||||
* The fields of the resource type to be built.
|
||||
*/
|
||||
protected function __construct($resource_type_name, array $fields) {
|
||||
assert(Inspector::assertAllObjects($fields, ResourceTypeField::class));
|
||||
$this->resourceTypeName = $resource_type_name;
|
||||
$this->fields = $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ResourceTypeBuildEvent.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
|
||||
* An entity type for the resource type to be built.
|
||||
* @param string $bundle
|
||||
* A bundle name for the resource type to be built. If the entity type does
|
||||
* not have bundles, the entity type ID.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceTypeField[] $fields
|
||||
* The fields of the resource type to be built.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceTypeBuildEvent
|
||||
* A new event.
|
||||
*/
|
||||
public static function createFromEntityTypeAndBundle(EntityTypeInterface $entity_type, $bundle, array $fields) {
|
||||
return new static(sprintf('%s--%s', $entity_type->id(), $bundle), $fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets current resource type name of the resource type to be built.
|
||||
*
|
||||
* @return string
|
||||
* The resource type name.
|
||||
*/
|
||||
public function getResourceTypeName() {
|
||||
return $this->resourceTypeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the resource type to be built.
|
||||
*/
|
||||
public function disableResourceType() {
|
||||
$this->disabled = TRUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the resource type to be built should be disabled.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the resource type should be disabled, FALSE otherwise.
|
||||
*/
|
||||
public function resourceTypeShouldBeDisabled() {
|
||||
return $this->disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current fields of the resource type to be built.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceTypeField[]
|
||||
* The current fields of the resource type to be built.
|
||||
*/
|
||||
public function getFields() {
|
||||
return $this->fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the public name of the given field on the resource type to be built.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceTypeField $field
|
||||
* The field for which to set a public name.
|
||||
* @param string $public_field_name
|
||||
* The public field name to set.
|
||||
*/
|
||||
public function setPublicFieldName(ResourceTypeField $field, $public_field_name) {
|
||||
foreach ($this->fields as $index => $value) {
|
||||
if ($field === $value) {
|
||||
$this->fields[$index] = $value->withPublicName($public_field_name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables the given field on the resource type to be built.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceTypeField $field
|
||||
* The field for which to set a public name.
|
||||
*/
|
||||
public function disableField(ResourceTypeField $field) {
|
||||
foreach ($this->fields as $index => $value) {
|
||||
if ($field === $value) {
|
||||
$this->fields[$index] = $value->disabled();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\ResourceType;
|
||||
|
||||
/**
|
||||
* Contains all events emitted during the resource type build process.
|
||||
*
|
||||
* @see \Drupal\jsonapi\ResourceType\ResourceTypeBuildEvent
|
||||
* @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
|
||||
*/
|
||||
final class ResourceTypeBuildEvents {
|
||||
|
||||
/**
|
||||
* Emitted during the resource type build process.
|
||||
*/
|
||||
const BUILD = 'jsonapi.resource_type.build';
|
||||
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\ResourceType;
|
||||
|
||||
/**
|
||||
* Abstract value object containing all metadata for a JSON:API resource field.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
|
||||
*/
|
||||
abstract class ResourceTypeField {
|
||||
|
||||
/**
|
||||
* The internal field name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $internalName;
|
||||
|
||||
/**
|
||||
* The public field name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $publicName;
|
||||
|
||||
/**
|
||||
* Whether the field is disabled.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $enabled;
|
||||
|
||||
/**
|
||||
* Whether the field can only have one value.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $hasOne;
|
||||
|
||||
/**
|
||||
* ResourceTypeField constructor.
|
||||
*
|
||||
* @param string $internal_name
|
||||
* The internal field name.
|
||||
* @param string $public_name
|
||||
* (optional) The public field name. Defaults to the internal field name.
|
||||
* @param bool $enabled
|
||||
* (optional) Whether the field is enabled. Defaults to TRUE.
|
||||
* @param bool $has_one
|
||||
* (optional) Whether the field can only have ony value. Defaults to TRUE.
|
||||
*/
|
||||
public function __construct($internal_name, $public_name = NULL, $enabled = TRUE, $has_one = TRUE) {
|
||||
$this->internalName = $internal_name;
|
||||
$this->publicName = $public_name ?: $internal_name;
|
||||
$this->enabled = $enabled;
|
||||
$this->hasOne = $has_one;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the internal name of the field.
|
||||
*
|
||||
* @return string
|
||||
* The internal name of the field.
|
||||
*/
|
||||
public function getInternalName() {
|
||||
return $this->internalName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the public name of the field.
|
||||
*
|
||||
* @return string
|
||||
* The public name of the field.
|
||||
*/
|
||||
public function getPublicName() {
|
||||
return $this->publicName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes a new public name for the field.
|
||||
*
|
||||
* @param string $public_name
|
||||
* The public name.
|
||||
*
|
||||
* @return static
|
||||
* A new instance of the field with the given public name.
|
||||
*/
|
||||
public function withPublicName($public_name) {
|
||||
return new static($this->internalName, $public_name, $this->enabled, $this->hasOne);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new instance of the field that is disabled.
|
||||
*
|
||||
* @return static
|
||||
* A new instance of the field that is disabled.
|
||||
*/
|
||||
public function disabled() {
|
||||
return new static($this->internalName, $this->publicName, FALSE, $this->hasOne);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the field is enabled.
|
||||
*
|
||||
* @return bool
|
||||
* Whether the field is enabled. FALSE if the field should not be in the
|
||||
* JSON:API response.
|
||||
*/
|
||||
public function isFieldEnabled() {
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the field can only have one value.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the field can have only one value, FALSE otherwise.
|
||||
*/
|
||||
public function hasOne() {
|
||||
return $this->hasOne;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the field can have many values.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the field can have more than one value, FALSE otherwise.
|
||||
*/
|
||||
public function hasMany() {
|
||||
return !$this->hasOne;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\ResourceType;
|
||||
|
||||
/**
|
||||
* Specialization of a ResourceTypeField to represent a resource relationship.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
|
||||
*/
|
||||
class ResourceTypeRelationship extends ResourceTypeField {
|
||||
|
||||
/**
|
||||
* The resource type to which this relationships can relate.
|
||||
*
|
||||
* @var \Drupal\jsonapi\ResourceType\ResourceType[]
|
||||
*/
|
||||
protected $relatableResourceTypes;
|
||||
|
||||
/**
|
||||
* Establishes the relatable resource types of this field.
|
||||
*
|
||||
* @param array $resource_types
|
||||
* The array of relatable resource types.
|
||||
*
|
||||
* @return static
|
||||
* A new instance of the field with the given relatable resource types.
|
||||
*/
|
||||
public function withRelatableResourceTypes(array $resource_types) {
|
||||
$relationship = new static($this->internalName, $this->publicName, $this->enabled, $this->hasOne);
|
||||
$relationship->relatableResourceTypes = $resource_types;
|
||||
return $relationship;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the relatable resource types.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceType[]
|
||||
* The resource type to which this relationships can relate.
|
||||
*/
|
||||
public function getRelatableResourceTypes() {
|
||||
if (!isset($this->relatableResourceTypes)) {
|
||||
throw new \LogicException("withRelatableResourceTypes() must be called before getting relatable resource types.");
|
||||
}
|
||||
return $this->relatableResourceTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function withPublicName($public_name) {
|
||||
$relationship = parent::withPublicName($public_name);
|
||||
return isset($this->relatableResourceTypes)
|
||||
? $relationship->withRelatableResourceTypes($this->relatableResourceTypes)
|
||||
: $relationship;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function disabled() {
|
||||
$relationship = parent::disabled();
|
||||
return isset($this->relatableResourceTypes)
|
||||
? $relationship->withRelatableResourceTypes($this->relatableResourceTypes)
|
||||
: $relationship;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\ResourceType;
|
||||
|
||||
use Drupal\Component\Assertion\Inspector;
|
||||
use Drupal\Core\Cache\Cache;
|
||||
use Drupal\Core\Cache\CacheBackendInterface;
|
||||
use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
|
||||
use Drupal\Core\Entity\ContentEntityNullStorage;
|
||||
use Drupal\Core\Entity\ContentEntityTypeInterface;
|
||||
use Drupal\Core\Entity\EntityFieldManagerInterface;
|
||||
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
|
||||
use Drupal\Core\Entity\EntityTypeInterface;
|
||||
use Drupal\Core\Entity\EntityTypeManagerInterface;
|
||||
use Drupal\Core\Entity\FieldableEntityInterface;
|
||||
use Drupal\Core\Field\FieldDefinitionInterface;
|
||||
use Drupal\Core\TypedData\DataReferenceTargetDefinition;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
|
||||
|
||||
/**
|
||||
* Provides a repository of all JSON:API resource types.
|
||||
*
|
||||
* Contains the complete set of ResourceType value objects, which are auto-
|
||||
* generated based on the Entity Type Manager and Entity Type Bundle Info: one
|
||||
* JSON:API resource type per entity type bundle. So, for example:
|
||||
* - node--article
|
||||
* - node--page
|
||||
* - node--…
|
||||
* - user--user
|
||||
* - …
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see \Drupal\jsonapi\ResourceType\ResourceType
|
||||
*/
|
||||
class ResourceTypeRepository implements ResourceTypeRepositoryInterface {
|
||||
|
||||
/**
|
||||
* The entity type manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
|
||||
*/
|
||||
protected $entityTypeManager;
|
||||
|
||||
/**
|
||||
* The bundle manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
|
||||
*/
|
||||
protected $entityTypeBundleInfo;
|
||||
|
||||
/**
|
||||
* The entity field manager.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityFieldManagerInterface
|
||||
*/
|
||||
protected $entityFieldManager;
|
||||
|
||||
/**
|
||||
* The cache backend.
|
||||
*
|
||||
* @var \Drupal\Core\Cache\CacheBackendInterface
|
||||
*/
|
||||
protected $cache;
|
||||
|
||||
/**
|
||||
* The event dispatcher.
|
||||
*
|
||||
* @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
|
||||
*/
|
||||
protected $eventDispatcher;
|
||||
|
||||
/**
|
||||
* Cache tags used for caching the repository.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $cacheTags = [
|
||||
'jsonapi_resource_types',
|
||||
// Invalidate whenever field definitions are modified.
|
||||
'entity_field_info',
|
||||
// Invalidate whenever the set of bundles changes.
|
||||
'entity_bundles',
|
||||
// Invalidate whenever the set of entity types changes.
|
||||
'entity_types',
|
||||
];
|
||||
|
||||
/**
|
||||
* Instantiates a ResourceTypeRepository object.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
|
||||
* The entity type manager.
|
||||
* @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_bundle_info
|
||||
* The entity type bundle info service.
|
||||
* @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager
|
||||
* The entity field manager.
|
||||
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
|
||||
* The cache backend.
|
||||
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
|
||||
* The event dispatcher.
|
||||
*/
|
||||
public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_bundle_info, EntityFieldManagerInterface $entity_field_manager, CacheBackendInterface $cache, EventDispatcherInterface $dispatcher) {
|
||||
$this->entityTypeManager = $entity_type_manager;
|
||||
$this->entityTypeBundleInfo = $entity_bundle_info;
|
||||
$this->entityFieldManager = $entity_field_manager;
|
||||
$this->cache = $cache;
|
||||
$this->eventDispatcher = $dispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function all() {
|
||||
$cached = $this->cache->get('jsonapi.resource_types', FALSE);
|
||||
if ($cached === FALSE) {
|
||||
$resource_types = [];
|
||||
foreach ($this->entityTypeManager->getDefinitions() as $entity_type) {
|
||||
$bundles = array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type->id()));
|
||||
$resource_types = array_reduce($bundles, function ($resource_types, $bundle) use ($entity_type) {
|
||||
$resource_type = $this->createResourceType($entity_type, (string) $bundle);
|
||||
return array_merge($resource_types, [
|
||||
$resource_type->getTypeName() => $resource_type,
|
||||
]);
|
||||
}, $resource_types);
|
||||
}
|
||||
foreach ($resource_types as $resource_type) {
|
||||
$relatable_resource_types = $this->calculateRelatableResourceTypes($resource_type, $resource_types);
|
||||
$resource_type->setRelatableResourceTypes($relatable_resource_types);
|
||||
}
|
||||
$this->cache->set('jsonapi.resource_types', $resource_types, Cache::PERMANENT, $this->cacheTags);
|
||||
}
|
||||
return $cached ? $cached->data : $resource_types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ResourceType value object for the given entity type + bundle.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
|
||||
* The entity type to create a JSON:API resource type for.
|
||||
* @param string $bundle
|
||||
* The entity type bundle to create a JSON:API resource type for.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceType
|
||||
* A JSON:API resource type.
|
||||
*/
|
||||
protected function createResourceType(EntityTypeInterface $entity_type, $bundle) {
|
||||
$raw_fields = $this->getAllFieldNames($entity_type, $bundle);
|
||||
$internalize_resource_type = $entity_type->isInternal();
|
||||
$fields = static::getFields($raw_fields, $entity_type, $bundle);
|
||||
if (!$internalize_resource_type) {
|
||||
$event = ResourceTypeBuildEvent::createFromEntityTypeAndBundle($entity_type, $bundle, $fields);
|
||||
$this->eventDispatcher->dispatch(ResourceTypeBuildEvents::BUILD, $event);
|
||||
$internalize_resource_type = $event->resourceTypeShouldBeDisabled();
|
||||
$fields = $event->getFields();
|
||||
}
|
||||
return new ResourceType(
|
||||
$entity_type->id(),
|
||||
$bundle,
|
||||
$entity_type->getClass(),
|
||||
$internalize_resource_type,
|
||||
static::isLocatableResourceType($entity_type, $bundle),
|
||||
static::isMutableResourceType($entity_type, $bundle),
|
||||
static::isVersionableResourceType($entity_type),
|
||||
$fields
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function get($entity_type_id, $bundle) {
|
||||
assert(is_string($bundle) && !empty($bundle), 'A bundle ID is required. Bundleless entity types should pass the entity type ID again.');
|
||||
if (empty($entity_type_id)) {
|
||||
throw new PreconditionFailedHttpException('Server error. The current route is malformed.');
|
||||
}
|
||||
|
||||
return $this->getByTypeName("$entity_type_id--$bundle");
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getByTypeName($type_name) {
|
||||
$resource_types = $this->all();
|
||||
return isset($resource_types[$type_name]) ? $resource_types[$type_name] : NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the field mapping for the given field names and entity type + bundle.
|
||||
*
|
||||
* @param string[] $field_names
|
||||
* All field names on a bundle of the given entity type.
|
||||
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
|
||||
* The entity type for which to get the field mapping.
|
||||
* @param string $bundle
|
||||
* The bundle to assess.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceTypeField[]
|
||||
* An array of JSON:API resource type fields keyed by internal field names.
|
||||
*/
|
||||
protected function getFields(array $field_names, EntityTypeInterface $entity_type, $bundle) {
|
||||
assert(Inspector::assertAllStrings($field_names));
|
||||
assert($entity_type instanceof ContentEntityTypeInterface || $entity_type instanceof ConfigEntityTypeInterface);
|
||||
assert(is_string($bundle) && !empty($bundle), 'A bundle ID is required. Bundleless entity types should pass the entity type ID again.');
|
||||
|
||||
// JSON:API resource identifier objects are sufficient to identify
|
||||
// entities. By exposing all fields as attributes, we expose unwanted,
|
||||
// confusing or duplicate information:
|
||||
// - exposing an entity's ID (which is not a UUID) is bad, but it's
|
||||
// necessary for certain Drupal-coupled clients, so we alias it by
|
||||
// prefixing it with `drupal_internal__`.
|
||||
// - exposing an entity's UUID as an attribute is useless (it's already part
|
||||
// of the mandatory "id" attribute in JSON:API), so we disable it in most
|
||||
// cases.
|
||||
// - exposing its revision ID as an attribute will compete with any profile
|
||||
// defined meta members used for resource object versioning.
|
||||
// @see http://jsonapi.org/format/#document-resource-identifier-objects
|
||||
$id_field_name = $entity_type->getKey('id');
|
||||
$uuid_field_name = $entity_type->getKey('uuid');
|
||||
if ($uuid_field_name && $uuid_field_name !== 'id') {
|
||||
$fields[$uuid_field_name] = new ResourceTypeAttribute($uuid_field_name, NULL, FALSE);
|
||||
}
|
||||
$fields[$id_field_name] = new ResourceTypeAttribute($id_field_name, "drupal_internal__$id_field_name");
|
||||
if ($entity_type->isRevisionable() && ($revision_id_field_name = $entity_type->getKey('revision'))) {
|
||||
$fields[$revision_id_field_name] = new ResourceTypeAttribute($revision_id_field_name, "drupal_internal__$revision_id_field_name");
|
||||
}
|
||||
if ($entity_type instanceof ConfigEntityTypeInterface) {
|
||||
// The '_core' key is reserved by Drupal core to handle complex edge cases
|
||||
// correctly. Data in the '_core' key is irrelevant to clients reading
|
||||
// configuration, and is not allowed to be set by clients writing
|
||||
// configuration: it is for Drupal core only, and managed by Drupal core.
|
||||
// @see https://www.drupal.org/node/2653358
|
||||
$fields['_core'] = new ResourceTypeAttribute('_core', NULL, FALSE);
|
||||
}
|
||||
|
||||
$is_fieldable = $entity_type->entityClassImplements(FieldableEntityInterface::class);
|
||||
if ($is_fieldable) {
|
||||
$field_definitions = $this->entityFieldManager->getFieldDefinitions($entity_type->id(), $bundle);
|
||||
}
|
||||
|
||||
// For all other fields, use their internal field name also as their public
|
||||
// field name. Unless they're called "id" or "type": those names are
|
||||
// reserved by the JSON:API spec.
|
||||
// @see http://jsonapi.org/format/#document-resource-object-fields
|
||||
$reserved_field_names = ['id', 'type'];
|
||||
foreach (array_diff($field_names, array_keys($fields)) as $field_name) {
|
||||
$alias = $field_name;
|
||||
// Alias the fields reserved by the JSON:API spec with `{entity_type}_`.
|
||||
if (in_array($field_name, $reserved_field_names, TRUE)) {
|
||||
$alias = $entity_type->id() . '_' . $field_name;
|
||||
}
|
||||
|
||||
// The default, which applies to most fields: expose as-is.
|
||||
$field_definition = $is_fieldable && !empty($field_definitions[$field_name]) ? $field_definitions[$field_name] : NULL;
|
||||
$is_relationship_field = $field_definition && static::isReferenceFieldDefinition($field_definition);
|
||||
$has_one = !$field_definition || $field_definition->getFieldStorageDefinition()->getCardinality() === 1;
|
||||
$fields[$field_name] = $is_relationship_field
|
||||
? new ResourceTypeRelationship($field_name, $alias, TRUE, $has_one)
|
||||
: new ResourceTypeAttribute($field_name, $alias, TRUE, $has_one);
|
||||
}
|
||||
|
||||
// With all fields now aliased, detect any conflicts caused by the
|
||||
// automatically generated aliases above.
|
||||
foreach (array_intersect($reserved_field_names, array_keys($fields)) as $reserved_field_name) {
|
||||
/* @var \Drupal\jsonapi\ResourceType\ResourceTypeField $aliased_reserved_field */
|
||||
$aliased_reserved_field = $fields[$reserved_field_name];
|
||||
/* @var \Drupal\jsonapi\ResourceType\ResourceTypeField $field */
|
||||
foreach (array_diff_key($fields, array_flip([$reserved_field_name])) as $field) {
|
||||
if ($aliased_reserved_field->getPublicName() === $field->getPublicName()) {
|
||||
throw new \LogicException("The generated alias '{$aliased_reserved_field->getPublicName()}' for field name '{$aliased_reserved_field->getInternalName()}' conflicts with an existing field. Please report this in the JSON:API issue queue!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for user entities that allows a JSON:API user agent to
|
||||
// access the display name of a user. This is useful when displaying the
|
||||
// name of a node's author.
|
||||
// @see \Drupal\jsonapi\JsonApiResource\ResourceObject::extractContentEntityFields()
|
||||
// @todo: eliminate this special casing in https://www.drupal.org/project/drupal/issues/3079254.
|
||||
if ($entity_type->id() === 'user') {
|
||||
$fields['display_name'] = new ResourceTypeAttribute('display_name');
|
||||
}
|
||||
|
||||
return $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the field mapping for the given field names and entity type + bundle.
|
||||
*
|
||||
* @param string[] $field_names
|
||||
* All field names on a bundle of the given entity type.
|
||||
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
|
||||
* The entity type for which to get the field mapping.
|
||||
* @param string $bundle
|
||||
* The bundle to assess.
|
||||
*
|
||||
* @return array
|
||||
* An array with:
|
||||
* - keys are (real/internal) field names
|
||||
* - values are either FALSE (indicating the field is not exposed despite
|
||||
* not being internal), TRUE (indicating the field should be exposed under
|
||||
* its internal name) or a string (indicating the field should not be
|
||||
* exposed using its internal name, but the name specified in the string)
|
||||
*
|
||||
* @deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. Use
|
||||
* self::getFields() instead.
|
||||
*
|
||||
* @see https://www.drupal.org/project/drupal/issues/3014277
|
||||
*/
|
||||
protected function getFieldMapping(array $field_names, EntityTypeInterface $entity_type, $bundle) {
|
||||
$class_name = self::class;
|
||||
@trigger_error("{$class_name}::getFieldMapping() is deprecated in Drupal 8.8.0 and will not be allowed in Drupal 9.0.0. Use {$class_name}::getFields() instead. See https://www.drupal.org/project/drupal/issues/3014277.", E_USER_DEPRECATED);
|
||||
$fields = $this->getFields($field_names, $entity_type, $bundle);
|
||||
return array_map(function (ResourceTypeField $field) {
|
||||
if ($field->isFieldEnabled()) {
|
||||
return $field->getInternalName() !== $field->getPublicName()
|
||||
? $field->getPublicName()
|
||||
: TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}, $fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all field names for a given entity type and bundle.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
|
||||
* The entity type for which to get all field names.
|
||||
* @param string $bundle
|
||||
* The bundle for which to get all field names.
|
||||
*
|
||||
* @return string[]
|
||||
* All field names.
|
||||
*/
|
||||
protected function getAllFieldNames(EntityTypeInterface $entity_type, $bundle) {
|
||||
if ($entity_type instanceof ContentEntityTypeInterface) {
|
||||
$field_definitions = $this->entityFieldManager->getFieldDefinitions(
|
||||
$entity_type->id(),
|
||||
$bundle
|
||||
);
|
||||
return array_keys($field_definitions);
|
||||
}
|
||||
elseif ($entity_type instanceof ConfigEntityTypeInterface) {
|
||||
// @todo Uncomment the first line, remove everything else once https://www.drupal.org/project/drupal/issues/2483407 lands.
|
||||
// return array_keys($entity_type->getPropertiesToExport());
|
||||
$export_properties = $entity_type->getPropertiesToExport();
|
||||
if ($export_properties !== NULL) {
|
||||
return array_keys($export_properties);
|
||||
}
|
||||
else {
|
||||
return ['id', 'type', 'uuid', '_core'];
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new \LogicException("Only content and config entity types are supported.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an entity type + bundle maps to a mutable resource type.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
|
||||
* The entity type to assess.
|
||||
* @param string $bundle
|
||||
* The bundle to assess.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the entity type is mutable, FALSE otherwise.
|
||||
*/
|
||||
protected static function isMutableResourceType(EntityTypeInterface $entity_type, $bundle) {
|
||||
assert(is_string($bundle) && !empty($bundle), 'A bundle ID is required. Bundleless entity types should pass the entity type ID again.');
|
||||
return !$entity_type instanceof ConfigEntityTypeInterface;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an entity type + bundle maps to a locatable resource type.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
|
||||
* The entity type to assess.
|
||||
* @param string $bundle
|
||||
* The bundle to assess.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the entity type is locatable, FALSE otherwise.
|
||||
*/
|
||||
protected static function isLocatableResourceType(EntityTypeInterface $entity_type, $bundle) {
|
||||
assert(is_string($bundle) && !empty($bundle), 'A bundle ID is required. Bundleless entity types should pass the entity type ID again.');
|
||||
return $entity_type->getStorageClass() !== ContentEntityNullStorage::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an entity type is a versionable resource type.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
|
||||
* The entity type to assess.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the entity type is versionable, FALSE otherwise.
|
||||
*/
|
||||
protected static function isVersionableResourceType(EntityTypeInterface $entity_type) {
|
||||
// @todo: remove the following line and uncomment the next one when revisions have standardized access control. For now, it is unsafe to support all revisionable entity types.
|
||||
return in_array($entity_type->id(), ['node', 'media']);
|
||||
/* return $entity_type->isRevisionable(); */
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates relatable JSON:API resource types for a given resource type.
|
||||
*
|
||||
* This method has no affect after being called once.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The resource type repository.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
|
||||
* A list of JSON:API resource types.
|
||||
*
|
||||
* @return array
|
||||
* The relatable JSON:API resource types, keyed by field name.
|
||||
*/
|
||||
protected function calculateRelatableResourceTypes(ResourceType $resource_type, array $resource_types) {
|
||||
// For now, only fieldable entity types may contain relationships.
|
||||
$entity_type = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId());
|
||||
if ($entity_type->entityClassImplements(FieldableEntityInterface::class)) {
|
||||
$field_definitions = $this->entityFieldManager->getFieldDefinitions(
|
||||
$resource_type->getEntityTypeId(),
|
||||
$resource_type->getBundle()
|
||||
);
|
||||
|
||||
$relatable_internal = array_map(function ($field_definition) use ($resource_types) {
|
||||
return $this->getRelatableResourceTypesFromFieldDefinition($field_definition, $resource_types);
|
||||
}, array_filter($field_definitions, function ($field_definition) {
|
||||
return $this->isReferenceFieldDefinition($field_definition);
|
||||
}));
|
||||
|
||||
$relatable_public = [];
|
||||
foreach ($relatable_internal as $internal_field_name => $value) {
|
||||
$relatable_public[$resource_type->getPublicName($internal_field_name)] = $value;
|
||||
}
|
||||
return $relatable_public;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relatable resource types from a field definition.
|
||||
*
|
||||
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
|
||||
* The field definition from which to calculate relatable JSON:API resource
|
||||
* types.
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
|
||||
* A list of JSON:API resource types.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceType[]
|
||||
* The JSON:API resource types with which the given field may have a
|
||||
* relationship.
|
||||
*/
|
||||
protected function getRelatableResourceTypesFromFieldDefinition(FieldDefinitionInterface $field_definition, array $resource_types) {
|
||||
$item_definition = $field_definition->getItemDefinition();
|
||||
|
||||
$entity_type_id = $item_definition->getSetting('target_type');
|
||||
$handler_settings = $item_definition->getSetting('handler_settings');
|
||||
|
||||
$has_target_bundles = isset($handler_settings['target_bundles']) && !empty($handler_settings['target_bundles']);
|
||||
$target_bundles = $has_target_bundles ?
|
||||
$handler_settings['target_bundles']
|
||||
: $this->getAllBundlesForEntityType($entity_type_id);
|
||||
|
||||
return array_map(function ($target_bundle) use ($entity_type_id, $resource_types) {
|
||||
$type_name = "$entity_type_id--$target_bundle";
|
||||
return isset($resource_types[$type_name]) ? $resource_types[$type_name] : NULL;
|
||||
}, $target_bundles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a given field definition is a reference field.
|
||||
*
|
||||
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
|
||||
* The field definition to inspect.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the field definition is found to be a reference field. FALSE
|
||||
* otherwise.
|
||||
*/
|
||||
protected function isReferenceFieldDefinition(FieldDefinitionInterface $field_definition) {
|
||||
static $field_type_is_reference = [];
|
||||
|
||||
if (isset($field_type_is_reference[$field_definition->getType()])) {
|
||||
return $field_type_is_reference[$field_definition->getType()];
|
||||
}
|
||||
|
||||
/* @var \Drupal\Core\Field\TypedData\FieldItemDataDefinition $item_definition */
|
||||
$item_definition = $field_definition->getItemDefinition();
|
||||
$main_property = $item_definition->getMainPropertyName();
|
||||
$property_definition = $item_definition->getPropertyDefinition($main_property);
|
||||
|
||||
return $field_type_is_reference[$field_definition->getType()] = $property_definition instanceof DataReferenceTargetDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all bundle IDs for a given entity type.
|
||||
*
|
||||
* @param string $entity_type_id
|
||||
* The entity type for which to get bundles.
|
||||
*
|
||||
* @return string[]
|
||||
* The bundle IDs.
|
||||
*/
|
||||
protected function getAllBundlesForEntityType($entity_type_id) {
|
||||
return array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\ResourceType;
|
||||
|
||||
/**
|
||||
* Provides a repository of all JSON:API resource types.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
interface ResourceTypeRepositoryInterface {
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
/**
|
||||
* 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
|
||||
* The ID for the bundle to find. If the entity type does not have a bundle,
|
||||
* then the entity type ID again.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceType
|
||||
* The requested JSON:API resource type, if it exists. NULL otherwise.
|
||||
*
|
||||
* @see \Drupal\Core\Entity\EntityInterface::bundle()
|
||||
*/
|
||||
public function get($entity_type_id, $bundle);
|
||||
|
||||
/**
|
||||
* Gets a specific JSON:API resource type based on a supplied typename.
|
||||
*
|
||||
* @param string $type_name
|
||||
* The public typename of a JSON:API resource.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceType|null
|
||||
* The resource type, or NULL if none found.
|
||||
*/
|
||||
public function getByTypeName($type_name);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Revisions;
|
||||
|
||||
/**
|
||||
* Used when a version ID is invalid.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class InvalidVersionIdentifierException extends \InvalidArgumentException {}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Revisions;
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\EntityTypeManagerInterface;
|
||||
|
||||
/**
|
||||
* Base implementation for version negotiators.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
abstract class NegotiatorBase implements VersionNegotiatorInterface {
|
||||
|
||||
/**
|
||||
* The entity type manager to load the revision.
|
||||
*
|
||||
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
|
||||
*/
|
||||
protected $entityTypeManager;
|
||||
|
||||
/**
|
||||
* Constructs a version negotiator instance.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
|
||||
* The entity type manager.
|
||||
*/
|
||||
public function __construct(EntityTypeManagerInterface $entity_type_manager) {
|
||||
$this->entityTypeManager = $entity_type_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the revision ID.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity.
|
||||
* @param string $version_argument
|
||||
* A value used to derive a revision ID for the given entity.
|
||||
*
|
||||
* @return int
|
||||
* The revision ID.
|
||||
*
|
||||
* @throws \Drupal\jsonapi\Revisions\VersionNotFoundException
|
||||
* When the revision does not exist.
|
||||
* @throws \Drupal\jsonapi\Revisions\InvalidVersionIdentifierException
|
||||
* When the revision ID is not valid.
|
||||
*/
|
||||
abstract protected function getRevisionId(EntityInterface $entity, $version_argument);
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getRevision(EntityInterface $entity, $version_argument) {
|
||||
return $this->loadRevision($entity, $this->getRevisionId($entity, $version_argument));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an entity revision.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity for which to load a revision.
|
||||
* @param int $revision_id
|
||||
* The revision ID to be loaded.
|
||||
*
|
||||
* @return \Drupal\Core\Entity\EntityInterface|null
|
||||
* The revision or NULL if the revision does not exists.
|
||||
*
|
||||
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
|
||||
* Thrown if the entity type doesn't exist.
|
||||
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
|
||||
* Thrown if the storage handler couldn't be loaded.
|
||||
*/
|
||||
protected function loadRevision(EntityInterface $entity, $revision_id) {
|
||||
$storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
|
||||
$revision = static::ensureVersionExists($storage->loadRevision($revision_id));
|
||||
if ($revision->id() !== $entity->id()) {
|
||||
throw new VersionNotFoundException(sprintf('The requested resource does not have a version with ID %s.', $revision_id));
|
||||
}
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method that ensures that a version exists.
|
||||
*
|
||||
* @param int|\Drupal\Core\Entity\EntityInterface $revision
|
||||
* A revision ID, or NULL if one was not found.
|
||||
*
|
||||
* @return int|\Drupal\Core\Entity\EntityInterface
|
||||
* A revision or revision ID, if one was found.
|
||||
*
|
||||
* @throws \Drupal\jsonapi\Revisions\VersionNotFoundException
|
||||
* Thrown if the given value is NULL, meaning the requested version was not
|
||||
* found.
|
||||
*/
|
||||
protected static function ensureVersionExists($revision) {
|
||||
if (is_null($revision)) {
|
||||
throw new VersionNotFoundException();
|
||||
}
|
||||
return $revision;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Revisions;
|
||||
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
|
||||
use Drupal\Core\Http\Exception\CacheableHttpException;
|
||||
use Drupal\Core\Routing\EnhancerInterface;
|
||||
use Drupal\jsonapi\Routing\Routes;
|
||||
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* Loads an appropriate revision for the requested resource version.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
final class ResourceVersionRouteEnhancer implements EnhancerInterface {
|
||||
|
||||
/**
|
||||
* The route default parameter name.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const REVISION_ID_KEY = 'revision_id';
|
||||
|
||||
/**
|
||||
* The query parameter for providing a version (revision) value.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const RESOURCE_VERSION_QUERY_PARAMETER = 'resourceVersion';
|
||||
|
||||
/**
|
||||
* A route parameter key which indicates that working copies were requested.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const WORKING_COPIES_REQUESTED = 'working_copies_requested';
|
||||
|
||||
/**
|
||||
* The cache context by which vary the loaded entity revision.
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @todo When D8 requires PHP >=5.6, convert to expression using the RESOURCE_VERSION_QUERY_PARAMETER constant.
|
||||
*/
|
||||
const CACHE_CONTEXT = 'url.query_args:resourceVersion';
|
||||
|
||||
/**
|
||||
* Resource version validation regex.
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @todo When D8 requires PHP >=5.6, convert to expression using the VersionNegotiator::SEPARATOR constant.
|
||||
*/
|
||||
const VERSION_IDENTIFIER_VALIDATOR = '/^[a-z]+[a-z_]*[a-z]+:[a-zA-Z0-9\-]+(:[a-zA-Z0-9\-]+)*$/';
|
||||
|
||||
/**
|
||||
* The revision ID negotiator.
|
||||
*
|
||||
* @var \Drupal\jsonapi\Revisions\VersionNegotiator
|
||||
*/
|
||||
protected $versionNegotiator;
|
||||
|
||||
/**
|
||||
* ResourceVersionRouteEnhancer constructor.
|
||||
*
|
||||
* @param \Drupal\jsonapi\Revisions\VersionNegotiator $version_negotiator_manager
|
||||
* The version negotiator.
|
||||
*/
|
||||
public function __construct(VersionNegotiator $version_negotiator_manager) {
|
||||
$this->versionNegotiator = $version_negotiator_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function enhance(array $defaults, Request $request) {
|
||||
if (!Routes::isJsonApiRequest($defaults) || !($resource_type = Routes::getResourceTypeNameFromParameters($defaults))) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
$has_version_param = $request->query->has(static::RESOURCE_VERSION_QUERY_PARAMETER);
|
||||
|
||||
// If the resource type is not versionable, then nothing needs to be
|
||||
// enhanced.
|
||||
if (!$resource_type->isVersionable()) {
|
||||
// If the query parameter was provided but the resource type is not
|
||||
// versionable, provide a helpful error.
|
||||
if ($has_version_param) {
|
||||
// Until Drupal core has a generic revision access API, it is only safe
|
||||
// to support the `node` and `media` entity types because they are the
|
||||
// only // entity types that have revision access checks for forward
|
||||
// revisions that are not the default and not the latest revision.
|
||||
$cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', static::CACHE_CONTEXT]);
|
||||
/* Uncomment the next line and remove the following one when https://www.drupal.org/project/drupal/issues/3002352 lands in core. */
|
||||
/* throw new CacheableHttpException($cacheability, 501, 'Resource versioning is not yet supported for this resource type.'); */
|
||||
$message = 'JSON:API does not yet support resource versioning for this resource type.';
|
||||
$message .= ' For context, see https://www.drupal.org/project/jsonapi/issues/2992833#comment-12818258.';
|
||||
$message .= ' To contribute, see https://www.drupal.org/project/drupal/issues/2350939 and https://www.drupal.org/project/drupal/issues/2809177.';
|
||||
throw new CacheableHttpException($cacheability, 501, $message, NULL, []);
|
||||
}
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
// Since the resource type is versionable, responses must always vary by the
|
||||
// requested version, without regard for whether a version query parameter
|
||||
// was provided or not.
|
||||
if (isset($defaults['entity'])) {
|
||||
assert($defaults['entity'] instanceof EntityInterface);
|
||||
$defaults['entity']->addCacheContexts([static::CACHE_CONTEXT]);
|
||||
}
|
||||
|
||||
// If no version was specified, nothing is left to enhance.
|
||||
if (!$has_version_param) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
// Provide a helpful error when a version is specified with an unsafe
|
||||
// method.
|
||||
if (!$request->isMethodCacheable()) {
|
||||
throw new BadRequestHttpException(sprintf('%s requests with a `%s` query parameter are not supported.', $request->getMethod(), static::RESOURCE_VERSION_QUERY_PARAMETER));
|
||||
}
|
||||
|
||||
$resource_version_identifier = $request->query->get(static::RESOURCE_VERSION_QUERY_PARAMETER);
|
||||
|
||||
if (!static::isValidVersionIdentifier($resource_version_identifier)) {
|
||||
$cacheability = (new CacheableMetadata())->addCacheContexts([static::CACHE_CONTEXT]);
|
||||
$message = sprintf('A resource version identifier was provided in an invalid format: `%s`', $resource_version_identifier);
|
||||
throw new CacheableBadRequestHttpException($cacheability, $message);
|
||||
}
|
||||
|
||||
// Determine if the request is for a collection resource.
|
||||
if ($defaults[RouteObjectInterface::CONTROLLER_NAME] === Routes::CONTROLLER_SERVICE_NAME . ':getCollection') {
|
||||
$latest_version_identifier = 'rel' . VersionNegotiator::SEPARATOR . 'latest-version';
|
||||
$working_copy_identifier = 'rel' . VersionNegotiator::SEPARATOR . 'working-copy';
|
||||
// Until Drupal core has a revision access API that works on entity
|
||||
// queries, filtering is not permitted on non-default revisions.
|
||||
if ($request->query->has('filter') && $resource_version_identifier !== $latest_version_identifier) {
|
||||
$cache_contexts = [
|
||||
'url.path',
|
||||
static::CACHE_CONTEXT,
|
||||
'url.query_args:filter',
|
||||
];
|
||||
$cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts);
|
||||
$message = 'JSON:API does not support filtering on revisions other than the latest version because a secure Drupal core API does not yet exist to do so.';
|
||||
throw new CacheableHttpException($cacheability, 501, $message, NULL, []);
|
||||
}
|
||||
// 'latest-version' and 'working-copy' are the only acceptable version
|
||||
// identifiers for a collection resource.
|
||||
if (!in_array($resource_version_identifier, [$latest_version_identifier, $working_copy_identifier])) {
|
||||
$cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', static::CACHE_CONTEXT]);
|
||||
$message = sprintf('Collection resources only support the following resource version identifiers: %s', implode(', ', [
|
||||
$latest_version_identifier,
|
||||
$working_copy_identifier,
|
||||
]));
|
||||
throw new CacheableBadRequestHttpException($cacheability, $message);
|
||||
}
|
||||
// Whether the collection to be loaded should include only working copies.
|
||||
$defaults[static::WORKING_COPIES_REQUESTED] = $resource_version_identifier === $working_copy_identifier;
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
/** @var \Drupal\Core\Entity\EntityInterface $entity */
|
||||
$entity = $defaults['entity'];
|
||||
|
||||
/** @var \Drupal\jsonapi\Revisions\VersionNegotiatorInterface $negotiator */
|
||||
$resolved_revision = $this->versionNegotiator->getRevision($entity, $resource_version_identifier);
|
||||
// Ensure none of the original entity cacheability is lost, especially the
|
||||
// query argument's cache context.
|
||||
$resolved_revision->addCacheableDependency($entity);
|
||||
return ['entity' => $resolved_revision] + $defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the user input.
|
||||
*
|
||||
* @param string $resource_version
|
||||
* The requested resource version identifier.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if the received resource version value is valid, FALSE otherwise.
|
||||
*/
|
||||
protected static function isValidVersionIdentifier($resource_version) {
|
||||
return preg_match(static::VERSION_IDENTIFIER_VALIDATOR, $resource_version) === 1;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Revisions;
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
|
||||
/**
|
||||
* Defines a revision ID implementation for entity revision ID values.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class VersionById extends NegotiatorBase implements VersionNegotiatorInterface {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getRevisionId(EntityInterface $entity, $version_argument) {
|
||||
if (!is_numeric($version_argument)) {
|
||||
throw new InvalidVersionIdentifierException('The revision ID must be an integer.');
|
||||
}
|
||||
return $version_argument;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Revisions;
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Entity\RevisionableInterface;
|
||||
|
||||
/**
|
||||
* Revision ID implementation for the default or latest revisions.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class VersionByRel extends NegotiatorBase {
|
||||
|
||||
/**
|
||||
* Version argument which loads the revision known to be the "working copy".
|
||||
*
|
||||
* In Drupal terms, a "working copy" is the latest revision. It may or may not
|
||||
* be a "default" revision. This revision is the working copy because it is
|
||||
* the revision to which new work will be applied. In other words, it denotes
|
||||
* the most recent revision which might be considered a work-in-progress.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const WORKING_COPY = 'working-copy';
|
||||
|
||||
/**
|
||||
* Version argument which loads the revision known to be the "latest version".
|
||||
*
|
||||
* In Drupal terms, the "latest version" is the latest "default" revision. It
|
||||
* may or may not have later revisions after it, as long as none of them are
|
||||
* "default" revisions. This revision is the latest version because it is the
|
||||
* last revision where work was considered finished. Typically, this means
|
||||
* that it is the most recent "published" revision.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const LATEST_VERSION = 'latest-version';
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getRevisionId(EntityInterface $entity, $version_argument) {
|
||||
assert($entity instanceof RevisionableInterface);
|
||||
switch ($version_argument) {
|
||||
case static::WORKING_COPY:
|
||||
/* @var \Drupal\Core\Entity\RevisionableStorageInterface $entity_storage */
|
||||
$entity_storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
|
||||
return static::ensureVersionExists($entity_storage->getLatestRevisionId($entity->id()));
|
||||
|
||||
case static::LATEST_VERSION:
|
||||
// The already loaded revision will be the latest version by default.
|
||||
// @see \Drupal\Core\Entity\Sql\SqlContentEntityStorage::buildQuery().
|
||||
return $entity->getLoadedRevisionId();
|
||||
|
||||
default:
|
||||
$message = sprintf('The version specifier must be either `%s` or `%s`, `%s` given.', static::LATEST_VERSION, static::WORKING_COPY, $version_argument);
|
||||
throw new InvalidVersionIdentifierException($message);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Revisions;
|
||||
|
||||
use Drupal\Core\Cache\CacheableMetadata;
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
|
||||
use Drupal\Core\Http\Exception\CacheableNotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Provides a version negotiator manager.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see \Drupal\jsonapi\Revisions\VersionNegotiatorInterface
|
||||
*/
|
||||
class VersionNegotiator {
|
||||
|
||||
/**
|
||||
* The separator between the version negotiator name and the version argument.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const SEPARATOR = ':';
|
||||
|
||||
/**
|
||||
* An array of named version negotiators.
|
||||
*
|
||||
* @var \Drupal\jsonapi\Revisions\VersionNegotiatorInterface[]
|
||||
*/
|
||||
protected $negotiators = [];
|
||||
|
||||
/**
|
||||
* Adds a version negotiator.
|
||||
*
|
||||
* @param \Drupal\jsonapi\Revisions\VersionNegotiatorInterface $version_negotiator
|
||||
* The version negotiator.
|
||||
* @param string $negotiator_name
|
||||
* The name of the negotiation strategy used by the version negotiator.
|
||||
*/
|
||||
public function addVersionNegotiator(VersionNegotiatorInterface $version_negotiator, $negotiator_name) {
|
||||
assert(strpos(get_class($version_negotiator), 'Drupal\\jsonapi\\') === 0, 'Version negotiators are not a public API.');
|
||||
$this->negotiators[$negotiator_name] = $version_negotiator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a negotiated entity revision.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity.
|
||||
* @param string $resource_version_identifier
|
||||
* A value used to derive a revision for the given entity.
|
||||
*
|
||||
* @return \Drupal\Core\Entity\EntityInterface
|
||||
* The loaded revision.
|
||||
*
|
||||
* @throws \Drupal\Core\Http\Exception\CacheableNotFoundHttpException
|
||||
* When the revision does not exist.
|
||||
* @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException
|
||||
* When the revision ID cannot be negotiated.
|
||||
*/
|
||||
public function getRevision(EntityInterface $entity, $resource_version_identifier) {
|
||||
try {
|
||||
list($version_negotiator_name, $version_argument) = explode(VersionNegotiator::SEPARATOR, $resource_version_identifier, 2);
|
||||
if (!isset($this->negotiators[$version_negotiator_name])) {
|
||||
static::throwBadRequestHttpException($resource_version_identifier);
|
||||
}
|
||||
return $this->negotiators[$version_negotiator_name]->getRevision($entity, $version_argument);
|
||||
}
|
||||
catch (VersionNotFoundException $exception) {
|
||||
static::throwNotFoundHttpException($entity, $resource_version_identifier);
|
||||
}
|
||||
catch (InvalidVersionIdentifierException $exception) {
|
||||
static::throwBadRequestHttpException($resource_version_identifier);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a cacheable error exception.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity for which a revision was requested.
|
||||
* @param string $resource_version_identifier
|
||||
* The user input for the revision negotiation.
|
||||
*
|
||||
* @throws \Drupal\Core\Http\Exception\CacheableNotFoundHttpException
|
||||
*/
|
||||
protected static function throwNotFoundHttpException(EntityInterface $entity, $resource_version_identifier) {
|
||||
$cacheability = CacheableMetadata::createFromObject($entity)->addCacheContexts(['url.path', 'url.query_args:' . ResourceVersionRouteEnhancer::RESOURCE_VERSION_QUERY_PARAMETER]);
|
||||
$reason = sprintf('The requested version, identified by `%s`, could not be found.', $resource_version_identifier);
|
||||
throw new CacheableNotFoundHttpException($cacheability, $reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws a cacheable error exception.
|
||||
*
|
||||
* @param string $resource_version_identifier
|
||||
* The user input for the revision negotiation.
|
||||
*
|
||||
* @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException
|
||||
*/
|
||||
protected static function throwBadRequestHttpException($resource_version_identifier) {
|
||||
$cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:' . ResourceVersionRouteEnhancer::RESOURCE_VERSION_QUERY_PARAMETER]);
|
||||
$message = sprintf('An invalid resource version identifier, `%s`, was provided.', $resource_version_identifier);
|
||||
throw new CacheableBadRequestHttpException($cacheability, $message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Revisions;
|
||||
|
||||
use Drupal\Core\Entity\EntityInterface;
|
||||
|
||||
/**
|
||||
* Defines the common interface for all version negotiators.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*
|
||||
* @see \Drupal\jsonapi\Revisions\VersionNegotiator
|
||||
*/
|
||||
interface VersionNegotiatorInterface {
|
||||
|
||||
/**
|
||||
* Gets the identified revision.
|
||||
*
|
||||
* The JSON:API module exposes revisions in terms of RFC5829. As such, the
|
||||
* public API always refers to "versions" and "working copies" instead of
|
||||
* "revisions". There are multiple ways to request a specific revision. For
|
||||
* example, one might like to load a particular revision by its ID. On the
|
||||
* other hand, it may be useful if an HTTP consumer is able to always request
|
||||
* the "latest version" regardless of its ID. It is possible to imagine other
|
||||
* scenarios as well, like fetching a revision based on a date or time.
|
||||
*
|
||||
* Each version negotiator provides one of these strategies and is able to map
|
||||
* a version argument to an existing revision.
|
||||
*
|
||||
* @param \Drupal\Core\Entity\EntityInterface $entity
|
||||
* The entity for which a revision should be resolved.
|
||||
* @param string $version_argument
|
||||
* A value used to derive a revision for the given entity.
|
||||
*
|
||||
* @return \Drupal\Core\Entity\EntityInterface
|
||||
* The identified entity revision.
|
||||
*
|
||||
* @throws \Drupal\jsonapi\Revisions\VersionNotFoundException
|
||||
* When the revision does not exist.
|
||||
* @throws \Drupal\jsonapi\Revisions\InvalidVersionIdentifierException
|
||||
* When the revision ID is invalid.
|
||||
*/
|
||||
public function getRevision(EntityInterface $entity, $version_argument);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Revisions;
|
||||
|
||||
/**
|
||||
* Used when a version ID is valid, but the requested version does not exist.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class VersionNotFoundException extends \InvalidArgumentException {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function __construct($message = NULL, $code = 0, \Exception $previous = NULL) {
|
||||
parent::__construct(!is_null($message) ? $message : 'The identified version could not be found.', $code, $previous);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Routing;
|
||||
|
||||
use Drupal\Core\Routing\RequestFormatRouteFilter;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\RouteCollection;
|
||||
|
||||
/**
|
||||
* Sets the 'api_json' format for requests to JSON:API resources.
|
||||
*
|
||||
* Because this module places all JSON:API resources at paths prefixed with
|
||||
* /jsonapi, and therefore not shared with other formats,
|
||||
* \Drupal\Core\Routing\RequestFormatRouteFilter does correctly set the request
|
||||
* format for those requests. However, it does so after other filters, such as
|
||||
* \Drupal\Core\Routing\ContentTypeHeaderMatcher, run. If those other filters
|
||||
* throw exceptions, we'd like the error response to be in JSON:API format as
|
||||
* well, so we set that format here, in a higher priority (earlier running)
|
||||
* filter. This works so long as the resource format can be determined before
|
||||
* running any other filters, which is the case for JSON:API resources per
|
||||
* above.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class EarlyFormatSetter extends RequestFormatRouteFilter {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function filter(RouteCollection $collection, Request $request) {
|
||||
if (is_null($request->getRequestFormat(NULL))) {
|
||||
$possible_formats = static::getAvailableFormats($collection);
|
||||
if ($possible_formats === ['api_json']) {
|
||||
$request->setRequestFormat('api_json');
|
||||
}
|
||||
}
|
||||
return $collection;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Routing;
|
||||
|
||||
use Drupal\Core\Config\ConfigFactoryInterface;
|
||||
use Drupal\Core\Routing\FilterInterface;
|
||||
use Drupal\Core\Url;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||
use Symfony\Component\Routing\RouteCollection;
|
||||
|
||||
/**
|
||||
* Filters routes based on the HTTP method and JSON:API's read-only mode.
|
||||
*/
|
||||
class ReadOnlyModeMethodFilter implements FilterInterface {
|
||||
|
||||
/**
|
||||
* The decorated method filter.
|
||||
*
|
||||
* @var \Drupal\Core\Routing\FilterInterface
|
||||
*/
|
||||
protected $inner;
|
||||
|
||||
/**
|
||||
* Whether JSON:API's read-only mode is enabled.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
protected $readOnlyModeIsEnabled;
|
||||
|
||||
/**
|
||||
* ReadOnlyModeMethodFilter constructor.
|
||||
*
|
||||
* @param \Drupal\Core\Routing\FilterInterface $inner
|
||||
* The decorated method filter.
|
||||
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
|
||||
* The configuration factory.
|
||||
*/
|
||||
public function __construct(FilterInterface $inner, ConfigFactoryInterface $config_factory) {
|
||||
$this->inner = $inner;
|
||||
$this->readOnlyModeIsEnabled = $config_factory->get('jsonapi.settings')->get('read_only');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function filter(RouteCollection $collection, Request $request) {
|
||||
$all_supported_methods = [];
|
||||
foreach ($collection->all() as $name => $route) {
|
||||
$all_supported_methods = array_merge($all_supported_methods, $route->getMethods());
|
||||
}
|
||||
|
||||
$collection = $this->inner->filter($collection, $request);
|
||||
|
||||
if (!$this->readOnlyModeIsEnabled) {
|
||||
return $collection;
|
||||
}
|
||||
|
||||
$read_only_methods = ['GET', 'HEAD', 'OPTIONS', 'TRACE'];
|
||||
foreach ($collection->all() as $name => $route) {
|
||||
if (!$route->hasDefault(Routes::JSON_API_ROUTE_FLAG_KEY)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$supported_methods = $route->getMethods();
|
||||
assert(count($supported_methods) > 0, 'JSON:API routes always have a method specified.');
|
||||
$is_read_only_route = empty(array_diff($supported_methods, $read_only_methods));
|
||||
if (!$is_read_only_route) {
|
||||
$collection->remove($name);
|
||||
}
|
||||
}
|
||||
if (count($collection)) {
|
||||
return $collection;
|
||||
}
|
||||
throw new MethodNotAllowedHttpException(array_intersect($all_supported_methods, $read_only_methods), sprintf("JSON:API is configured to accept only read operations. Site administrators can configure this at %s.", Url::fromRoute('jsonapi.settings')->setAbsolute()->toString(TRUE)->getGeneratedUrl()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Routing;
|
||||
|
||||
use Drupal\Core\Routing\EnhancerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Ensures the loaded entity matches the requested resource type.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class RouteEnhancer implements EnhancerInterface {
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function enhance(array $defaults, Request $request) {
|
||||
if (!Routes::isJsonApiRequest($defaults)) {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
$resource_type = Routes::getResourceTypeNameFromParameters($defaults);
|
||||
$entity_type_id = $resource_type->getEntityTypeId();
|
||||
if (!isset($defaults[$entity_type_id]) || !($entity = $defaults[$entity_type_id])) {
|
||||
return $defaults;
|
||||
}
|
||||
$retrieved_bundle = $entity->bundle();
|
||||
$configured_bundle = $resource_type->getBundle();
|
||||
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 NotFoundHttpException(sprintf('The loaded entity bundle (%s) does not match the configured resource (%s).', $retrieved_bundle, $configured_bundle));
|
||||
}
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Routing;
|
||||
|
||||
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
|
||||
use Drupal\jsonapi\Access\RelationshipFieldAccess;
|
||||
use Drupal\jsonapi\Controller\EntryPoint;
|
||||
use Drupal\jsonapi\ParamConverter\ResourceTypeConverter;
|
||||
use Drupal\jsonapi\ResourceType\ResourceType;
|
||||
use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
|
||||
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||
use Symfony\Component\Routing\Route;
|
||||
use Symfony\Component\Routing\RouteCollection;
|
||||
|
||||
/**
|
||||
* Defines dynamic routes.
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
class Routes implements ContainerInjectionInterface {
|
||||
|
||||
/**
|
||||
* The service name for the primary JSON:API controller.
|
||||
*
|
||||
* All resources except the entrypoint are served by this controller.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const CONTROLLER_SERVICE_NAME = 'jsonapi.entity_resource';
|
||||
|
||||
/**
|
||||
* A key with which to flag a route as belonging to the JSON:API module.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const JSON_API_ROUTE_FLAG_KEY = '_is_jsonapi';
|
||||
|
||||
/**
|
||||
* The route default key for the route's resource type information.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const RESOURCE_TYPE_KEY = 'resource_type';
|
||||
|
||||
/**
|
||||
* The JSON:API resource type repository.
|
||||
*
|
||||
* @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
|
||||
*/
|
||||
protected $resourceTypeRepository;
|
||||
|
||||
/**
|
||||
* List of providers.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $providerIds;
|
||||
|
||||
/**
|
||||
* The JSON:API base path.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $jsonApiBasePath;
|
||||
|
||||
/**
|
||||
* Instantiates a Routes object.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
|
||||
* The JSON:API resource type repository.
|
||||
* @param string[] $authentication_providers
|
||||
* The authentication providers, keyed by ID.
|
||||
* @param string $jsonapi_base_path
|
||||
* The JSON:API base path.
|
||||
*/
|
||||
public function __construct(ResourceTypeRepositoryInterface $resource_type_repository, array $authentication_providers, $jsonapi_base_path) {
|
||||
$this->resourceTypeRepository = $resource_type_repository;
|
||||
$this->providerIds = array_keys($authentication_providers);
|
||||
assert(is_string($jsonapi_base_path));
|
||||
assert(
|
||||
$jsonapi_base_path[0] === '/',
|
||||
sprintf('The provided base path should contain a leading slash "/". Given: "%s".', $jsonapi_base_path)
|
||||
);
|
||||
assert(
|
||||
substr($jsonapi_base_path, -1) !== '/',
|
||||
sprintf('The provided base path should not contain a trailing slash "/". Given: "%s".', $jsonapi_base_path)
|
||||
);
|
||||
$this->jsonApiBasePath = $jsonapi_base_path;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public static function create(ContainerInterface $container) {
|
||||
return new static(
|
||||
$container->get('jsonapi.resource_type.repository'),
|
||||
$container->getParameter('authentication_providers'),
|
||||
$container->getParameter('jsonapi.base_path')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function routes() {
|
||||
$routes = new RouteCollection();
|
||||
$upload_routes = new RouteCollection();
|
||||
|
||||
// JSON:API's routes: entry point + routes for every resource type.
|
||||
foreach ($this->resourceTypeRepository->all() as $resource_type) {
|
||||
$routes->addCollection(static::getRoutesForResourceType($resource_type, $this->jsonApiBasePath));
|
||||
$upload_routes->addCollection(static::getFileUploadRoutesForResourceType($resource_type, $this->jsonApiBasePath));
|
||||
}
|
||||
$routes->add('jsonapi.resource_list', static::getEntryPointRoute($this->jsonApiBasePath));
|
||||
|
||||
// Require the JSON:API media type header on every route, except on file
|
||||
// upload routes, where we require `application/octet-stream`.
|
||||
$routes->addRequirements(['_content_type_format' => 'api_json']);
|
||||
$upload_routes->addRequirements(['_content_type_format' => 'bin']);
|
||||
|
||||
$routes->addCollection($upload_routes);
|
||||
|
||||
// Enable all available authentication providers.
|
||||
$routes->addOptions(['_auth' => $this->providerIds]);
|
||||
|
||||
// Flag every route as belonging to the JSON:API module.
|
||||
$routes->addDefaults([static::JSON_API_ROUTE_FLAG_KEY => TRUE]);
|
||||
|
||||
// All routes serve only the JSON:API media type.
|
||||
$routes->addRequirements(['_format' => 'api_json']);
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets applicable resource routes for a JSON:API resource type.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The JSON:API resource type for which to get the routes.
|
||||
* @param string $path_prefix
|
||||
* The root path prefix.
|
||||
*
|
||||
* @return \Symfony\Component\Routing\RouteCollection
|
||||
* A collection of routes for the given resource type.
|
||||
*/
|
||||
protected static function getRoutesForResourceType(ResourceType $resource_type, $path_prefix) {
|
||||
// Internal resources have no routes.
|
||||
if ($resource_type->isInternal()) {
|
||||
return new RouteCollection();
|
||||
}
|
||||
|
||||
$routes = new RouteCollection();
|
||||
|
||||
// Collection route like `/jsonapi/node/article`.
|
||||
if ($resource_type->isLocatable()) {
|
||||
$collection_route = new Route("/{$resource_type->getPath()}");
|
||||
$collection_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ':getCollection']);
|
||||
$collection_route->setMethods(['GET']);
|
||||
// Allow anybody access because "view" and "view label" access are checked
|
||||
// in the controller.
|
||||
$collection_route->setRequirement('_access', 'TRUE');
|
||||
$routes->add(static::getRouteName($resource_type, 'collection'), $collection_route);
|
||||
}
|
||||
|
||||
// Creation route.
|
||||
if ($resource_type->isMutable()) {
|
||||
$collection_create_route = new Route("/{$resource_type->getPath()}");
|
||||
$collection_create_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ':createIndividual']);
|
||||
$collection_create_route->setMethods(['POST']);
|
||||
$create_requirement = sprintf("%s:%s", $resource_type->getEntityTypeId(), $resource_type->getBundle());
|
||||
$collection_create_route->setRequirement('_entity_create_access', $create_requirement);
|
||||
$collection_create_route->setRequirement('_csrf_request_header_token', 'TRUE');
|
||||
$routes->add(static::getRouteName($resource_type, 'collection.post'), $collection_create_route);
|
||||
}
|
||||
|
||||
// Individual routes like `/jsonapi/node/article/{uuid}` or
|
||||
// `/jsonapi/node/article/{uuid}/relationships/uid`.
|
||||
$routes->addCollection(static::getIndividualRoutesForResourceType($resource_type));
|
||||
|
||||
// Add the resource type as a parameter to every resource route.
|
||||
foreach ($routes as $route) {
|
||||
static::addRouteParameter($route, static::RESOURCE_TYPE_KEY, ['type' => ResourceTypeConverter::PARAM_TYPE_ID]);
|
||||
$route->addDefaults([static::RESOURCE_TYPE_KEY => $resource_type->getTypeName()]);
|
||||
}
|
||||
|
||||
// Resource routes all have the same base path.
|
||||
$routes->addPrefix($path_prefix);
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file upload route collection for the given resource type.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The resource type for which the route collection should be created.
|
||||
* @param string $path_prefix
|
||||
* The root path prefix.
|
||||
*
|
||||
* @return \Symfony\Component\Routing\RouteCollection
|
||||
* The route collection.
|
||||
*/
|
||||
protected static function getFileUploadRoutesForResourceType(ResourceType $resource_type, $path_prefix) {
|
||||
$routes = new RouteCollection();
|
||||
|
||||
// Internal resources have no routes; individual routes require locations.
|
||||
if ($resource_type->isInternal() || !$resource_type->isLocatable()) {
|
||||
return $routes;
|
||||
}
|
||||
|
||||
// File upload routes are only necessary for resource types that have file
|
||||
// fields.
|
||||
$has_file_field = array_reduce($resource_type->getRelatableResourceTypes(), function ($carry, array $target_resource_types) {
|
||||
return $carry || static::hasNonInternalFileTargetResourceTypes($target_resource_types);
|
||||
}, FALSE);
|
||||
if (!$has_file_field) {
|
||||
return $routes;
|
||||
}
|
||||
|
||||
if ($resource_type->isMutable()) {
|
||||
$path = $resource_type->getPath();
|
||||
$entity_type_id = $resource_type->getEntityTypeId();
|
||||
|
||||
$new_resource_file_upload_route = new Route("/{$path}/{file_field_name}");
|
||||
$new_resource_file_upload_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => 'jsonapi.file_upload:handleFileUploadForNewResource']);
|
||||
$new_resource_file_upload_route->setMethods(['POST']);
|
||||
$new_resource_file_upload_route->setRequirement('_csrf_request_header_token', 'TRUE');
|
||||
$routes->add(static::getFileUploadRouteName($resource_type, 'new_resource'), $new_resource_file_upload_route);
|
||||
|
||||
$existing_resource_file_upload_route = new Route("/{$path}/{entity}/{file_field_name}");
|
||||
$existing_resource_file_upload_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => 'jsonapi.file_upload:handleFileUploadForExistingResource']);
|
||||
$existing_resource_file_upload_route->setMethods(['POST']);
|
||||
$existing_resource_file_upload_route->setRequirement('_csrf_request_header_token', 'TRUE');
|
||||
$routes->add(static::getFileUploadRouteName($resource_type, 'existing_resource'), $existing_resource_file_upload_route);
|
||||
|
||||
// Add entity parameter conversion to every route.
|
||||
$routes->addOptions(['parameters' => ['entity' => ['type' => 'entity:' . $entity_type_id]]]);
|
||||
|
||||
// Add the resource type as a parameter to every resource route.
|
||||
foreach ($routes as $route) {
|
||||
static::addRouteParameter($route, static::RESOURCE_TYPE_KEY, ['type' => ResourceTypeConverter::PARAM_TYPE_ID]);
|
||||
$route->addDefaults([static::RESOURCE_TYPE_KEY => $resource_type->getTypeName()]);
|
||||
}
|
||||
}
|
||||
|
||||
// File upload routes all have the same base path.
|
||||
$routes->addPrefix($path_prefix);
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the given request is for a JSON:API generated route.
|
||||
*
|
||||
* @param array $defaults
|
||||
* The request's route defaults.
|
||||
*
|
||||
* @return bool
|
||||
* Whether the request targets a generated route.
|
||||
*/
|
||||
public static function isJsonApiRequest(array $defaults) {
|
||||
return isset($defaults[RouteObjectInterface::CONTROLLER_NAME])
|
||||
&& strpos($defaults[RouteObjectInterface::CONTROLLER_NAME], static::CONTROLLER_SERVICE_NAME) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a route collection for the given resource type.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The resource type for which the route collection should be created.
|
||||
*
|
||||
* @return \Symfony\Component\Routing\RouteCollection
|
||||
* The route collection.
|
||||
*/
|
||||
protected static function getIndividualRoutesForResourceType(ResourceType $resource_type) {
|
||||
if (!$resource_type->isLocatable()) {
|
||||
return new RouteCollection();
|
||||
}
|
||||
|
||||
$routes = new RouteCollection();
|
||||
|
||||
$path = $resource_type->getPath();
|
||||
$entity_type_id = $resource_type->getEntityTypeId();
|
||||
|
||||
// Individual read, update and remove.
|
||||
$individual_route = new Route("/{$path}/{entity}");
|
||||
$individual_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ':getIndividual']);
|
||||
$individual_route->setMethods(['GET']);
|
||||
// No _entity_access requirement because "view" and "view label" access are
|
||||
// checked in the controller. So it's safe to allow anybody access.
|
||||
$individual_route->setRequirement('_access', 'TRUE');
|
||||
$routes->add(static::getRouteName($resource_type, 'individual'), $individual_route);
|
||||
if ($resource_type->isMutable()) {
|
||||
$individual_update_route = new Route($individual_route->getPath());
|
||||
$individual_update_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ':patchIndividual']);
|
||||
$individual_update_route->setMethods(['PATCH']);
|
||||
$individual_update_route->setRequirement('_entity_access', "entity.update");
|
||||
$individual_update_route->setRequirement('_csrf_request_header_token', 'TRUE');
|
||||
$routes->add(static::getRouteName($resource_type, 'individual.patch'), $individual_update_route);
|
||||
$individual_remove_route = new Route($individual_route->getPath());
|
||||
$individual_remove_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ':deleteIndividual']);
|
||||
$individual_remove_route->setMethods(['DELETE']);
|
||||
$individual_remove_route->setRequirement('_entity_access', "entity.delete");
|
||||
$individual_remove_route->setRequirement('_csrf_request_header_token', 'TRUE');
|
||||
$routes->add(static::getRouteName($resource_type, 'individual.delete'), $individual_remove_route);
|
||||
}
|
||||
|
||||
foreach ($resource_type->getRelatableResourceTypes() as $relationship_field_name => $target_resource_types) {
|
||||
// Read, update, add, or remove an individual resources relationships to
|
||||
// other resources.
|
||||
$relationship_route = new Route("/{$path}/{entity}/relationships/{$relationship_field_name}");
|
||||
$relationship_route->addDefaults(['_on_relationship' => TRUE]);
|
||||
$relationship_route->addDefaults(['related' => $relationship_field_name]);
|
||||
$relationship_route->setRequirement(RelationshipFieldAccess::ROUTE_REQUIREMENT_KEY, $relationship_field_name);
|
||||
$relationship_route->setRequirement('_csrf_request_header_token', 'TRUE');
|
||||
$relationship_route_methods = $resource_type->isMutable()
|
||||
? ['GET', 'POST', 'PATCH', 'DELETE']
|
||||
: ['GET'];
|
||||
$relationship_controller_methods = [
|
||||
'GET' => 'getRelationship',
|
||||
'POST' => 'addToRelationshipData',
|
||||
'PATCH' => 'replaceRelationshipData',
|
||||
'DELETE' => 'removeFromRelationshipData',
|
||||
];
|
||||
foreach ($relationship_route_methods as $method) {
|
||||
$method_specific_relationship_route = clone $relationship_route;
|
||||
$method_specific_relationship_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ":{$relationship_controller_methods[$method]}"]);
|
||||
$method_specific_relationship_route->setMethods($method);
|
||||
$routes->add(static::getRouteName($resource_type, sprintf("%s.relationship.%s", $relationship_field_name, strtolower($method))), $method_specific_relationship_route);
|
||||
}
|
||||
|
||||
// Only create routes for related routes that target at least one
|
||||
// non-internal resource type.
|
||||
if (static::hasNonInternalTargetResourceTypes($target_resource_types)) {
|
||||
// Get an individual resource's related resources.
|
||||
$related_route = new Route("/{$path}/{entity}/{$relationship_field_name}");
|
||||
$related_route->setMethods(['GET']);
|
||||
$related_route->addDefaults([RouteObjectInterface::CONTROLLER_NAME => static::CONTROLLER_SERVICE_NAME . ':getRelated']);
|
||||
$related_route->addDefaults(['related' => $relationship_field_name]);
|
||||
$related_route->setRequirement(RelationshipFieldAccess::ROUTE_REQUIREMENT_KEY, $relationship_field_name);
|
||||
$routes->add(static::getRouteName($resource_type, "$relationship_field_name.related"), $related_route);
|
||||
}
|
||||
}
|
||||
|
||||
// Add entity parameter conversion to every route.
|
||||
$routes->addOptions(['parameters' => ['entity' => ['type' => 'entity:' . $entity_type_id]]]);
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the entry point route.
|
||||
*
|
||||
* @param string $path_prefix
|
||||
* The root path prefix.
|
||||
*
|
||||
* @return \Symfony\Component\Routing\Route
|
||||
* The entry point route.
|
||||
*/
|
||||
protected function getEntryPointRoute($path_prefix) {
|
||||
$entry_point = new Route("/{$path_prefix}");
|
||||
$entry_point->addDefaults([RouteObjectInterface::CONTROLLER_NAME => EntryPoint::class . '::index']);
|
||||
$entry_point->setRequirement('_access', 'TRUE');
|
||||
$entry_point->setMethods(['GET']);
|
||||
return $entry_point;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a parameter option to a route, overrides options of the same name.
|
||||
*
|
||||
* The Symfony Route class only has a method for adding options which
|
||||
* overrides any previous values. Therefore, it is tedious to add a single
|
||||
* parameter while keeping those that are already set.
|
||||
*
|
||||
* @param \Symfony\Component\Routing\Route $route
|
||||
* The route to which the parameter is to be added.
|
||||
* @param string $name
|
||||
* The name of the parameter.
|
||||
* @param mixed $parameter
|
||||
* The parameter's options.
|
||||
*/
|
||||
protected static function addRouteParameter(Route $route, $name, $parameter) {
|
||||
$parameters = $route->getOption('parameters') ?: [];
|
||||
$parameters[$name] = $parameter;
|
||||
$route->setOption('parameters', $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a unique route name for the JSON:API resource type and route type.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The resource type for which the route collection should be created.
|
||||
* @param string $route_type
|
||||
* The route type. E.g. 'individual' or 'collection'.
|
||||
*
|
||||
* @return string
|
||||
* The generated route name.
|
||||
*/
|
||||
public static function getRouteName(ResourceType $resource_type, $route_type) {
|
||||
return sprintf('jsonapi.%s.%s', $resource_type->getTypeName(), $route_type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a unique route name for the file upload resource type and route type.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
|
||||
* The resource type for which the route collection should be created.
|
||||
* @param string $route_type
|
||||
* The route type. E.g. 'individual' or 'collection'.
|
||||
*
|
||||
* @return string
|
||||
* The generated route name.
|
||||
*/
|
||||
protected static function getFileUploadRouteName(ResourceType $resource_type, $route_type) {
|
||||
return sprintf('jsonapi.%s.%s.%s', $resource_type->getTypeName(), 'file_upload', $route_type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an array of resource types has any non-internal ones.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
|
||||
* The resource types to check.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if there is at least one non-internal resource type in the given
|
||||
* array; FALSE otherwise.
|
||||
*/
|
||||
protected static function hasNonInternalTargetResourceTypes(array $resource_types) {
|
||||
return array_reduce($resource_types, function ($carry, ResourceType $target) {
|
||||
return $carry || !$target->isInternal();
|
||||
}, FALSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an array of resource types lists non-internal "file" ones.
|
||||
*
|
||||
* @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
|
||||
* The resource types to check.
|
||||
*
|
||||
* @return bool
|
||||
* TRUE if there is at least one non-internal "file" resource type in the
|
||||
* given array; FALSE otherwise.
|
||||
*/
|
||||
protected static function hasNonInternalFileTargetResourceTypes(array $resource_types) {
|
||||
return array_reduce($resource_types, function ($carry, ResourceType $target) {
|
||||
return $carry || (!$target->isInternal() && $target->getEntityTypeId() === 'file');
|
||||
}, FALSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the resource type from a route or request's parameters.
|
||||
*
|
||||
* @param array $parameters
|
||||
* An array of parameters. These may be obtained from a route's
|
||||
* parameter defaults or from a request object.
|
||||
*
|
||||
* @return \Drupal\jsonapi\ResourceType\ResourceType|null
|
||||
* The resource type, NULL if one cannot be found from the given parameters.
|
||||
*/
|
||||
public static function getResourceTypeNameFromParameters(array $parameters) {
|
||||
if (isset($parameters[static::JSON_API_ROUTE_FLAG_KEY]) && $parameters[static::JSON_API_ROUTE_FLAG_KEY]) {
|
||||
return isset($parameters[static::RESOURCE_TYPE_KEY]) ? $parameters[static::RESOURCE_TYPE_KEY] : NULL;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates any JSON:API resource type dependent responses and routes.
|
||||
*/
|
||||
public static function rebuild() {
|
||||
\Drupal::service('cache_tags.invalidator')->invalidateTags(['jsonapi_resource_types']);
|
||||
\Drupal::service('router.builder')->setRebuildNeeded();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace Drupal\jsonapi\Serializer;
|
||||
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
use Symfony\Component\Serializer\Serializer as SymfonySerializer;
|
||||
|
||||
/**
|
||||
* Overrides the Symfony serializer to cordon off our incompatible normalizers.
|
||||
*
|
||||
* This service is for *internal* use only. It is not suitable for *any* reuse.
|
||||
* Backwards compatibility is in no way guaranteed and will almost certainly be
|
||||
* broken in the future.
|
||||
*
|
||||
* @link https://www.drupal.org/project/jsonapi/issues/2923779#comment-12407443
|
||||
*
|
||||
* @internal JSON:API maintains no PHP API since its API is the HTTP API. This
|
||||
* class may change at any time and this will break any dependencies on it.
|
||||
*
|
||||
* @see https://www.drupal.org/project/jsonapi/issues/3032787
|
||||
* @see jsonapi.api.php
|
||||
*/
|
||||
final class Serializer extends SymfonySerializer {
|
||||
|
||||
/**
|
||||
* A normalizer to fall back on when JSON:API cannot normalize an object.
|
||||
*
|
||||
* @var \Symfony\Component\Serializer\Normalizer\NormalizerInterface|\Symfony\Component\Serializer\Normalizer\DenormalizerInterface
|
||||
*/
|
||||
protected $fallbackNormalizer;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function __construct(array $normalizers = [], array $encoders = []) {
|
||||
foreach ($normalizers as $normalizer) {
|
||||
if (strpos(get_class($normalizer), 'Drupal\jsonapi\Normalizer') !== 0) {
|
||||
throw new \LogicException('JSON:API does not allow adding more normalizers!');
|
||||
}
|
||||
}
|
||||
parent::__construct($normalizers, $encoders);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a secondary normalizer.
|
||||
*
|
||||
* This normalizer will be attempted when JSON:API has no applicable
|
||||
* normalizer.
|
||||
*
|
||||
* @param \Symfony\Component\Serializer\Normalizer\NormalizerInterface $normalizer
|
||||
* The secondary normalizer.
|
||||
*/
|
||||
public function setFallbackNormalizer(NormalizerInterface $normalizer) {
|
||||
$this->fallbackNormalizer = $normalizer;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function normalize($data, $format = NULL, array $context = []) {
|
||||
if ($this->selfSupportsNormalization($data, $format, $context)) {
|
||||
return parent::normalize($data, $format, $context);
|
||||
}
|
||||
if ($this->fallbackNormalizer->supportsNormalization($data, $format, $context)) {
|
||||
return $this->fallbackNormalizer->normalize($data, $format, $context);
|
||||
}
|
||||
return parent::normalize($data, $format, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function denormalize($data, $type, $format = NULL, array $context = []) {
|
||||
if ($this->selfSupportsDenormalization($data, $type, $format, $context)) {
|
||||
return parent::denormalize($data, $type, $format, $context);
|
||||
}
|
||||
return $this->fallbackNormalizer->denormalize($data, $type, $format, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function supportsNormalization($data, $format = NULL, array $context = []) {
|
||||
return $this->selfSupportsNormalization($data, $format, $context) || $this->fallbackNormalizer->supportsNormalization($data, $format, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether this class alone supports normalization.
|
||||
*
|
||||
* @param mixed $data
|
||||
* Data to normalize.
|
||||
* @param string $format
|
||||
* The format being (de-)serialized from or into.
|
||||
* @param array $context
|
||||
* (optional) Options available to the normalizer.
|
||||
*
|
||||
* @return bool
|
||||
* Whether this class supports normalization for the given data.
|
||||
*/
|
||||
private function selfSupportsNormalization($data, $format = NULL, array $context = []) {
|
||||
return parent::supportsNormalization($data, $format, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function supportsDenormalization($data, $type, $format = NULL, array $context = []) {
|
||||
return $this->selfSupportsDenormalization($data, $type, $format, $context) || $this->fallbackNormalizer->supportsDenormalization($data, $type, $format, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether this class alone supports denormalization.
|
||||
*
|
||||
* @param mixed $data
|
||||
* Data to denormalize from.
|
||||
* @param string $type
|
||||
* The class to which the data should be denormalized.
|
||||
* @param string $format
|
||||
* The format being deserialized from.
|
||||
* @param array $context
|
||||
* (optional) Options available to the denormalizer.
|
||||
*
|
||||
* @return bool
|
||||
* Whether this class supports normalization for the given data and type.
|
||||
*/
|
||||
private function selfSupportsDenormalization($data, $type, $format = NULL, array $context = []) {
|
||||
return parent::supportsDenormalization($data, $type, $format, $context);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user