annotate core/modules/jsonapi/src/Context/FieldResolver.php @ 19:fa3358dc1485 tip

Add ndrum files
author Chris Cannam
date Wed, 28 Aug 2019 13:14:47 +0100
parents af1871eacc83
children
rev   line source
Chris@18 1 <?php
Chris@18 2
Chris@18 3 namespace Drupal\jsonapi\Context;
Chris@18 4
Chris@18 5 use Drupal\Component\Utility\NestedArray;
Chris@18 6 use Drupal\Core\Access\AccessResult;
Chris@18 7 use Drupal\Core\Access\AccessResultInterface;
Chris@18 8 use Drupal\Core\Access\AccessResultReasonInterface;
Chris@18 9 use Drupal\Core\Cache\CacheableMetadata;
Chris@18 10 use Drupal\Core\Entity\EntityFieldManagerInterface;
Chris@18 11 use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
Chris@18 12 use Drupal\Core\Entity\EntityTypeManagerInterface;
Chris@18 13 use Drupal\Core\Entity\FieldableEntityInterface;
Chris@18 14 use Drupal\Core\Entity\TypedData\EntityDataDefinitionInterface;
Chris@18 15 use Drupal\Core\Extension\ModuleHandlerInterface;
Chris@18 16 use Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface;
Chris@18 17 use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
Chris@18 18 use Drupal\Core\TypedData\ComplexDataDefinitionInterface;
Chris@18 19 use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
Chris@18 20 use Drupal\Core\TypedData\DataReferenceTargetDefinition;
Chris@18 21 use Drupal\jsonapi\ResourceType\ResourceType;
Chris@18 22 use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
Chris@18 23 use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
Chris@18 24
Chris@18 25 /**
Chris@18 26 * A service that evaluates external path expressions against Drupal fields.
Chris@18 27 *
Chris@18 28 * This class performs 3 essential functions, path resolution, path validation
Chris@18 29 * and path expansion.
Chris@18 30 *
Chris@18 31 * Path resolution:
Chris@18 32 * Path resolution refers to the ability to map a set of external field names to
Chris@18 33 * their internal counterparts. This is necessary because a resource type can
Chris@18 34 * provide aliases for its field names. For example, the resource type @code
Chris@18 35 * node--article @endcode might "alias" the internal field name @code
Chris@18 36 * uid @endcode to the external field name @code author @endcode. This permits
Chris@18 37 * an API consumer to request @code
Chris@18 38 * /jsonapi/node/article?include=author @endcode for a better developer
Chris@18 39 * experience.
Chris@18 40 *
Chris@18 41 * Path validation:
Chris@18 42 * Path validation refers to the ability to ensure that a requested path
Chris@18 43 * corresponds to a valid set of internal fields. For example, if an API
Chris@18 44 * consumer may send a @code GET @endcode request to @code
Chris@18 45 * /jsonapi/node/article?sort=author.field_first_name @endcode. The field
Chris@18 46 * resolver ensures that @code uid @endcode (which would have been resolved
Chris@18 47 * from @code author @endcode) exists on article nodes and that @code
Chris@18 48 * field_first_name @endcode exists on user entities. However, in the case of
Chris@18 49 * an @code include @endcode path, the field resolver would raise a client error
Chris@18 50 * because @code field_first_name @endcode is not an entity reference field,
Chris@18 51 * meaning it does not identify any related resources that can be included in a
Chris@18 52 * compound document.
Chris@18 53 *
Chris@18 54 * Path expansion:
Chris@18 55 * Path expansion refers to the ability to expand a path to an entity query
Chris@18 56 * compatible field expression. For example, a request URL might have a query
Chris@18 57 * string like @code ?filter[field_tags.name]=aviation @endcode, before
Chris@18 58 * constructing the appropriate entity query, the entity query system needs the
Chris@18 59 * path expression to be "expanded" into @code field_tags.entity.name @endcode.
Chris@18 60 * In some rare cases, the entity query system needs this to be expanded to
Chris@18 61 * @code field_tags.entity:taxonomy_term.name @endcode; the field resolver
Chris@18 62 * simply does this by default for every path.
Chris@18 63 *
Chris@18 64 * *Note:* path expansion is *not* performed for @code include @endcode paths.
Chris@18 65 *
Chris@18 66 * @internal JSON:API maintains no PHP API. The API is the HTTP API. This class
Chris@18 67 * may change at any time and could break any dependencies on it.
Chris@18 68 *
Chris@18 69 * @see https://www.drupal.org/project/jsonapi/issues/3032787
Chris@18 70 * @see jsonapi.api.php
Chris@18 71 */
Chris@18 72 class FieldResolver {
Chris@18 73
Chris@18 74 /**
Chris@18 75 * The entity type manager.
Chris@18 76 *
Chris@18 77 * @var \Drupal\Core\Entity\EntityTypeManagerInterface
Chris@18 78 */
Chris@18 79 protected $entityTypeManager;
Chris@18 80
Chris@18 81 /**
Chris@18 82 * The field manager.
Chris@18 83 *
Chris@18 84 * @var \Drupal\Core\Entity\EntityFieldManagerInterface
Chris@18 85 */
Chris@18 86 protected $fieldManager;
Chris@18 87
Chris@18 88 /**
Chris@18 89 * The entity type bundle information service.
Chris@18 90 *
Chris@18 91 * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
Chris@18 92 */
Chris@18 93 protected $entityTypeBundleInfo;
Chris@18 94
Chris@18 95 /**
Chris@18 96 * The JSON:API resource type repository service.
Chris@18 97 *
Chris@18 98 * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface
Chris@18 99 */
Chris@18 100 protected $resourceTypeRepository;
Chris@18 101
Chris@18 102 /**
Chris@18 103 * The module handler.
Chris@18 104 *
Chris@18 105 * @var \Drupal\Core\Extension\ModuleHandlerInterface
Chris@18 106 */
Chris@18 107 protected $moduleHandler;
Chris@18 108
Chris@18 109 /**
Chris@18 110 * Creates a FieldResolver instance.
Chris@18 111 *
Chris@18 112 * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
Chris@18 113 * The entity type manager.
Chris@18 114 * @param \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager
Chris@18 115 * The field manager.
Chris@18 116 * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
Chris@18 117 * The bundle info service.
Chris@18 118 * @param \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface $resource_type_repository
Chris@18 119 * The resource type repository.
Chris@18 120 * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
Chris@18 121 * The module handler.
Chris@18 122 */
Chris@18 123 public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, ResourceTypeRepositoryInterface $resource_type_repository, ModuleHandlerInterface $module_handler) {
Chris@18 124 $this->entityTypeManager = $entity_type_manager;
Chris@18 125 $this->fieldManager = $field_manager;
Chris@18 126 $this->entityTypeBundleInfo = $entity_type_bundle_info;
Chris@18 127 $this->resourceTypeRepository = $resource_type_repository;
Chris@18 128 $this->moduleHandler = $module_handler;
Chris@18 129 }
Chris@18 130
Chris@18 131 /**
Chris@18 132 * Validates and resolves an include path into its internal possibilities.
Chris@18 133 *
Chris@18 134 * Each resource type may define its own external names for its internal
Chris@18 135 * field names. As a result, a single external include path may target
Chris@18 136 * multiple internal paths.
Chris@18 137 *
Chris@18 138 * This can happen when an entity reference field has different allowed entity
Chris@18 139 * types *per bundle* (as is possible with comment entities) or when
Chris@18 140 * different resource types share an external field name but resolve to
Chris@18 141 * different internal fields names.
Chris@18 142 *
Chris@18 143 * Example 1:
Chris@18 144 * An installation may have three comment types for three different entity
Chris@18 145 * types, two of which have a file field and one of which does not. In that
Chris@18 146 * case, a path like @code field_comments.entity_id.media @endcode might be
Chris@18 147 * resolved to both @code field_comments.entity_id.field_audio @endcode
Chris@18 148 * and @code field_comments.entity_id.field_image @endcode.
Chris@18 149 *
Chris@18 150 * Example 2:
Chris@18 151 * A path of @code field_author_profile.account @endcode might
Chris@18 152 * resolve to @code field_author_profile.uid @endcode and @code
Chris@18 153 * field_author_profile.field_user @endcode if @code
Chris@18 154 * field_author_profile @endcode can relate to two different JSON:API resource
Chris@18 155 * types (like `node--profile` and `node--migrated_profile`) which have the
Chris@18 156 * external field name @code account @endcode aliased to different internal
Chris@18 157 * field names.
Chris@18 158 *
Chris@18 159 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
Chris@18 160 * The resource type for which the path should be validated.
Chris@18 161 * @param string[] $path_parts
Chris@18 162 * The include path as an array of strings. For example, the include query
Chris@18 163 * parameter string of @code field_tags.uid @endcode should be given
Chris@18 164 * as @code ['field_tags', 'uid'] @endcode.
Chris@18 165 * @param int $depth
Chris@18 166 * (internal) Used to track recursion depth in order to generate better
Chris@18 167 * exception messages.
Chris@18 168 *
Chris@18 169 * @return string[]
Chris@18 170 * The resolved internal include paths.
Chris@18 171 *
Chris@18 172 * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
Chris@18 173 * Thrown if the path contains invalid specifiers.
Chris@18 174 */
Chris@18 175 public static function resolveInternalIncludePath(ResourceType $resource_type, array $path_parts, $depth = 0) {
Chris@18 176 $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:include']);
Chris@18 177 if (empty($path_parts[0])) {
Chris@18 178 throw new CacheableBadRequestHttpException($cacheability, 'Empty include path.');
Chris@18 179 }
Chris@18 180 $public_field_name = $path_parts[0];
Chris@18 181 $internal_field_name = $resource_type->getInternalName($public_field_name);
Chris@18 182 $relatable_resource_types = $resource_type->getRelatableResourceTypesByField($public_field_name);
Chris@18 183 if (empty($relatable_resource_types)) {
Chris@18 184 $message = "`$public_field_name` is not a valid relationship field name.";
Chris@18 185 if (!empty(($possible = implode(', ', array_keys($resource_type->getRelatableResourceTypes()))))) {
Chris@18 186 $message .= " Possible values: $possible.";
Chris@18 187 }
Chris@18 188 throw new CacheableBadRequestHttpException($cacheability, $message);
Chris@18 189 }
Chris@18 190 $remaining_parts = array_slice($path_parts, 1);
Chris@18 191 if (empty($remaining_parts)) {
Chris@18 192 return [[$internal_field_name]];
Chris@18 193 }
Chris@18 194 $exceptions = [];
Chris@18 195 $resolved = [];
Chris@18 196 foreach ($relatable_resource_types as $relatable_resource_type) {
Chris@18 197 try {
Chris@18 198 // Each resource type may resolve the path differently and may return
Chris@18 199 // multiple possible resolutions.
Chris@18 200 $resolved = array_merge($resolved, static::resolveInternalIncludePath($relatable_resource_type, $remaining_parts, $depth + 1));
Chris@18 201 }
Chris@18 202 catch (CacheableBadRequestHttpException $e) {
Chris@18 203 $exceptions[] = $e;
Chris@18 204 }
Chris@18 205 }
Chris@18 206 if (!empty($exceptions) && count($exceptions) === count($relatable_resource_types)) {
Chris@18 207 $previous_messages = implode(' ', array_unique(array_map(function (CacheableBadRequestHttpException $e) {
Chris@18 208 return $e->getMessage();
Chris@18 209 }, $exceptions)));
Chris@18 210 // Only add the full include path on the first level of recursion so that
Chris@18 211 // the invalid path phrase isn't repeated at every level.
Chris@18 212 throw new CacheableBadRequestHttpException($cacheability, $depth === 0
Chris@18 213 ? sprintf("`%s` is not a valid include path. $previous_messages", implode('.', $path_parts))
Chris@18 214 : $previous_messages
Chris@18 215 );
Chris@18 216 }
Chris@18 217 // Remove duplicates by converting to strings and then using array_unique().
Chris@18 218 $resolved_as_strings = array_map(function ($possibility) {
Chris@18 219 return implode('.', $possibility);
Chris@18 220 }, $resolved);
Chris@18 221 $resolved_as_strings = array_unique($resolved_as_strings);
Chris@18 222
Chris@18 223 // The resolved internal paths do not include the current field name because
Chris@18 224 // resolution happens in a recursive process. Convert back from strings.
Chris@18 225 return array_map(function ($possibility) use ($internal_field_name) {
Chris@18 226 return array_merge([$internal_field_name], explode('.', $possibility));
Chris@18 227 }, $resolved_as_strings);
Chris@18 228 }
Chris@18 229
Chris@18 230 /**
Chris@18 231 * Resolves external field expressions into entity query compatible paths.
Chris@18 232 *
Chris@18 233 * It is often required to reference data which may exist across a
Chris@18 234 * relationship. For example, you may want to sort a list of articles by
Chris@18 235 * a field on the article author's representative entity. Or you may wish
Chris@18 236 * to filter a list of content by the name of referenced taxonomy terms.
Chris@18 237 *
Chris@18 238 * In an effort to simplify the referenced paths and align them with the
Chris@18 239 * structure of JSON:API responses and the structure of the hypothetical
Chris@18 240 * "reference document" (see link), it is possible to alias field names and
Chris@18 241 * elide the "entity" keyword from them (this word is used by the entity query
Chris@18 242 * system to traverse entity references).
Chris@18 243 *
Chris@18 244 * This method takes this external field expression and and attempts to
Chris@18 245 * resolve any aliases and/or abbreviations into a field expression that will
Chris@18 246 * be compatible with the entity query system.
Chris@18 247 *
Chris@18 248 * @link http://jsonapi.org/recommendations/#urls-reference-document
Chris@18 249 *
Chris@18 250 * Example:
Chris@18 251 * 'uid.field_first_name' -> 'uid.entity.field_first_name'.
Chris@18 252 * 'author.firstName' -> 'field_author.entity.field_first_name'
Chris@18 253 *
Chris@18 254 * @param string $entity_type_id
Chris@18 255 * The type of the entity for which to resolve the field name.
Chris@18 256 * @param string $bundle
Chris@18 257 * The bundle of the entity for which to resolve the field name.
Chris@18 258 * @param string $external_field_name
Chris@18 259 * The public field name to map to a Drupal field name.
Chris@18 260 *
Chris@18 261 * @return string
Chris@18 262 * The mapped field name.
Chris@18 263 *
Chris@18 264 * @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException
Chris@18 265 */
Chris@18 266 public function resolveInternalEntityQueryPath($entity_type_id, $bundle, $external_field_name) {
Chris@18 267 $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:filter', 'url.query_args:sort']);
Chris@18 268 $resource_type = $this->resourceTypeRepository->get($entity_type_id, $bundle);
Chris@18 269 if (empty($external_field_name)) {
Chris@18 270 throw new CacheableBadRequestHttpException($cacheability, 'No field name was provided for the filter.');
Chris@18 271 }
Chris@18 272
Chris@18 273 // Turns 'uid.categories.name' into
Chris@18 274 // 'uid.entity.field_category.entity.name'. This may be too simple, but it
Chris@18 275 // works for the time being.
Chris@18 276 $parts = explode('.', $external_field_name);
Chris@18 277 $unresolved_path_parts = $parts;
Chris@18 278 $reference_breadcrumbs = [];
Chris@18 279 /* @var \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types */
Chris@18 280 $resource_types = [$resource_type];
Chris@18 281 // This complex expression is needed to handle the string, "0", which would
Chris@18 282 // otherwise be evaluated as FALSE.
Chris@18 283 while (!is_null(($part = array_shift($parts)))) {
Chris@18 284 if (!$this->isMemberFilterable($part, $resource_types)) {
Chris@18 285 throw new CacheableBadRequestHttpException($cacheability, sprintf(
Chris@18 286 'Invalid nested filtering. The field `%s`, given in the path `%s`, does not exist.',
Chris@18 287 $part,
Chris@18 288 $external_field_name
Chris@18 289 ));
Chris@18 290 }
Chris@18 291
Chris@18 292 $field_name = $this->getInternalName($part, $resource_types);
Chris@18 293
Chris@18 294 // If none of the resource types are traversable, assume that the
Chris@18 295 // remaining path parts are targeting field deltas and/or field
Chris@18 296 // properties.
Chris@18 297 if (!$this->resourceTypesAreTraversable($resource_types)) {
Chris@18 298 $reference_breadcrumbs[] = $field_name;
Chris@18 299 return $this->constructInternalPath($reference_breadcrumbs, $parts);
Chris@18 300 }
Chris@18 301
Chris@18 302 // Different resource types have different field definitions.
Chris@18 303 $candidate_definitions = $this->getFieldItemDefinitions($resource_types, $field_name);
Chris@18 304 assert(!empty($candidate_definitions));
Chris@18 305
Chris@18 306 // We have a valid field, so add it to the validated trail of path parts.
Chris@18 307 $reference_breadcrumbs[] = $field_name;
Chris@18 308
Chris@18 309 // Remove resource types which do not have a candidate definition.
Chris@18 310 $resource_types = array_filter($resource_types, function (ResourceType $resource_type) use ($candidate_definitions) {
Chris@18 311 return isset($candidate_definitions[$resource_type->getTypeName()]);
Chris@18 312 });
Chris@18 313
Chris@18 314 // Check access to execute a query for each field per resource type since
Chris@18 315 // field definitions are bundle-specific.
Chris@18 316 foreach ($resource_types as $resource_type) {
Chris@18 317 $field_access = $this->getFieldAccess($resource_type, $field_name);
Chris@18 318 $cacheability->addCacheableDependency($field_access);
Chris@18 319 if (!$field_access->isAllowed()) {
Chris@18 320 $message = sprintf('The current user is not authorized to filter by the `%s` field, given in the path `%s`.', $field_name, implode('.', $reference_breadcrumbs));
Chris@18 321 if ($field_access instanceof AccessResultReasonInterface && ($reason = $field_access->getReason()) && !empty($reason)) {
Chris@18 322 $message .= ' ' . $reason;
Chris@18 323 }
Chris@18 324 throw new CacheableAccessDeniedHttpException($cacheability, $message);
Chris@18 325 }
Chris@18 326 }
Chris@18 327
Chris@18 328 // Get all of the referenceable resource types.
Chris@18 329 $resource_types = $this->getReferenceableResourceTypes($candidate_definitions);
Chris@18 330
Chris@18 331 $at_least_one_entity_reference_field = FALSE;
Chris@18 332 $candidate_property_names = array_unique(NestedArray::mergeDeepArray(array_map(function (FieldItemDataDefinitionInterface $definition) use (&$at_least_one_entity_reference_field) {
Chris@18 333 $property_definitions = $definition->getPropertyDefinitions();
Chris@18 334 return array_reduce(array_keys($property_definitions), function ($property_names, $property_name) use ($property_definitions, &$at_least_one_entity_reference_field) {
Chris@18 335 $property_definition = $property_definitions[$property_name];
Chris@18 336 $is_data_reference_definition = $property_definition instanceof DataReferenceTargetDefinition;
Chris@18 337 if (!$property_definition->isInternal()) {
Chris@18 338 // Entity reference fields are special: their reference property
Chris@18 339 // (usually `target_id`) is never exposed in the JSON:API
Chris@18 340 // representation. Hence it must also not be exposed in 400
Chris@18 341 // responses' error messages.
Chris@18 342 $property_names[] = $is_data_reference_definition ? 'id' : $property_name;
Chris@18 343 }
Chris@18 344 if ($is_data_reference_definition) {
Chris@18 345 $at_least_one_entity_reference_field = TRUE;
Chris@18 346 }
Chris@18 347 return $property_names;
Chris@18 348 }, []);
Chris@18 349 }, $candidate_definitions)));
Chris@18 350
Chris@18 351 // Determine if the specified field has one property or many in its
Chris@18 352 // JSON:API representation, or if it is an relationship (an entity
Chris@18 353 // reference field), in which case the `id` of the related resource must
Chris@18 354 // always be specified.
Chris@18 355 $property_specifier_needed = $at_least_one_entity_reference_field || count($candidate_property_names) > 1;
Chris@18 356
Chris@18 357 // If there are no remaining path parts, the process is finished unless
Chris@18 358 // the field has multiple properties, in which case one must be specified.
Chris@18 359 if (empty($parts)) {
Chris@18 360 if ($property_specifier_needed) {
Chris@18 361 $possible_specifiers = array_map(function ($specifier) use ($at_least_one_entity_reference_field) {
Chris@18 362 return $at_least_one_entity_reference_field && $specifier !== 'id' ? "meta.$specifier" : $specifier;
Chris@18 363 }, $candidate_property_names);
Chris@18 364 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)));
Chris@18 365 }
Chris@18 366 return $this->constructInternalPath($reference_breadcrumbs);
Chris@18 367 }
Chris@18 368
Chris@18 369 // If the next part is a delta, as in "body.0.value", then we add it to
Chris@18 370 // the breadcrumbs and remove it from the parts that still must be
Chris@18 371 // processed.
Chris@18 372 if (static::isDelta($parts[0])) {
Chris@18 373 $reference_breadcrumbs[] = array_shift($parts);
Chris@18 374 }
Chris@18 375
Chris@18 376 // If there are no remaining path parts, the process is finished.
Chris@18 377 if (empty($parts)) {
Chris@18 378 return $this->constructInternalPath($reference_breadcrumbs);
Chris@18 379 }
Chris@18 380
Chris@18 381 // JSON:API outputs entity reference field properties under a meta object
Chris@18 382 // on a relationship. If the filter specifies one of these properties, it
Chris@18 383 // must prefix the property name with `meta`. The only exception is if the
Chris@18 384 // next path part is the same as the name for the reference property
Chris@18 385 // (typically `entity`), this is permitted to disambiguate the case of a
Chris@18 386 // field name on the target entity which is the same a property name on
Chris@18 387 // the entity reference field.
Chris@18 388 if ($at_least_one_entity_reference_field && $parts[0] !== 'id') {
Chris@18 389 if ($parts[0] === 'meta') {
Chris@18 390 array_shift($parts);
Chris@18 391 }
Chris@18 392 elseif (in_array($parts[0], $candidate_property_names) && !static::isCandidateDefinitionReferenceProperty($parts[0], $candidate_definitions)) {
Chris@18 393 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));
Chris@18 394 }
Chris@18 395 }
Chris@18 396
Chris@18 397 // Determine if the next part is not a property of $field_name.
Chris@18 398 if (!static::isCandidateDefinitionProperty($parts[0], $candidate_definitions) && !empty(static::getAllDataReferencePropertyNames($candidate_definitions))) {
Chris@18 399 // The next path part is neither a delta nor a field property, so it
Chris@18 400 // must be a field on a targeted resource type. We need to guess the
Chris@18 401 // intermediate reference property since one was not provided.
Chris@18 402 //
Chris@18 403 // For example, the path `uid.name` for a `node--article` resource type
Chris@18 404 // will be resolved into `uid.entity.name`.
Chris@18 405 $reference_breadcrumbs[] = static::getDataReferencePropertyName($candidate_definitions, $parts, $unresolved_path_parts);
Chris@18 406 }
Chris@18 407 else {
Chris@18 408 // If the property is not a reference property, then all
Chris@18 409 // remaining parts must be further property specifiers.
Chris@18 410 if (!static::isCandidateDefinitionReferenceProperty($parts[0], $candidate_definitions)) {
Chris@18 411 // If a field property is specified on a field with only one property
Chris@18 412 // defined, throw an error because in the JSON:API output, it does not
Chris@18 413 // exist. This is because JSON:API elides single-value properties;
Chris@18 414 // respecting it would leak this Drupalism out.
Chris@18 415 if (count($candidate_property_names) === 1) {
Chris@18 416 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));
Chris@18 417 }
Chris@18 418 elseif (!in_array($parts[0], $candidate_property_names, TRUE)) {
Chris@18 419 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)));
Chris@18 420 }
Chris@18 421 return $this->constructInternalPath($reference_breadcrumbs, $parts);
Chris@18 422 }
Chris@18 423 // The property is a reference, so add it to the breadcrumbs and
Chris@18 424 // continue resolving fields.
Chris@18 425 $reference_breadcrumbs[] = array_shift($parts);
Chris@18 426 }
Chris@18 427 }
Chris@18 428
Chris@18 429 // Reconstruct the full path to the final reference field.
Chris@18 430 return $this->constructInternalPath($reference_breadcrumbs);
Chris@18 431 }
Chris@18 432
Chris@18 433 /**
Chris@18 434 * Expands the internal path with the "entity" keyword.
Chris@18 435 *
Chris@18 436 * @param string[] $references
Chris@18 437 * The resolved internal field names of all entity references.
Chris@18 438 * @param string[] $property_path
Chris@18 439 * (optional) A sub-property path for the last field in the path.
Chris@18 440 *
Chris@18 441 * @return string
Chris@18 442 * The expanded and imploded path.
Chris@18 443 */
Chris@18 444 protected function constructInternalPath(array $references, array $property_path = []) {
Chris@18 445 // Reconstruct the path parts that are referencing sub-properties.
Chris@18 446 $field_path = implode('.', $property_path);
Chris@18 447
Chris@18 448 // This rebuilds the path from the real, internal field names that have
Chris@18 449 // been traversed so far. It joins them with the "entity" keyword as
Chris@18 450 // required by the entity query system.
Chris@18 451 $entity_path = implode('.', $references);
Chris@18 452
Chris@18 453 // Reconstruct the full path to the final reference field.
Chris@18 454 return (empty($field_path)) ? $entity_path : $entity_path . '.' . $field_path;
Chris@18 455 }
Chris@18 456
Chris@18 457 /**
Chris@18 458 * Get all item definitions from a set of resources types by a field name.
Chris@18 459 *
Chris@18 460 * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
Chris@18 461 * The resource types on which the field might exist.
Chris@18 462 * @param string $field_name
Chris@18 463 * The field for which to retrieve field item definitions.
Chris@18 464 *
Chris@18 465 * @return \Drupal\Core\TypedData\ComplexDataDefinitionInterface[]
Chris@18 466 * The found field item definitions.
Chris@18 467 */
Chris@18 468 protected function getFieldItemDefinitions(array $resource_types, $field_name) {
Chris@18 469 return array_reduce($resource_types, function ($result, ResourceType $resource_type) use ($field_name) {
Chris@18 470 /* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
Chris@18 471 $entity_type = $resource_type->getEntityTypeId();
Chris@18 472 $bundle = $resource_type->getBundle();
Chris@18 473 $definitions = $this->fieldManager->getFieldDefinitions($entity_type, $bundle);
Chris@18 474 if (isset($definitions[$field_name])) {
Chris@18 475 $result[$resource_type->getTypeName()] = $definitions[$field_name]->getItemDefinition();
Chris@18 476 }
Chris@18 477 return $result;
Chris@18 478 }, []);
Chris@18 479 }
Chris@18 480
Chris@18 481 /**
Chris@18 482 * Resolves the UUID field name for a resource type.
Chris@18 483 *
Chris@18 484 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
Chris@18 485 * The resource type for which to get the UUID field name.
Chris@18 486 *
Chris@18 487 * @return string
Chris@18 488 * The resolved internal name.
Chris@18 489 */
Chris@18 490 protected function getIdFieldName(ResourceType $resource_type) {
Chris@18 491 $entity_type = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId());
Chris@18 492 return $entity_type->getKey('uuid');
Chris@18 493 }
Chris@18 494
Chris@18 495 /**
Chris@18 496 * Resolves the internal field name based on a collection of resource types.
Chris@18 497 *
Chris@18 498 * @param string $field_name
Chris@18 499 * The external field name.
Chris@18 500 * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
Chris@18 501 * The resource types from which to get an internal name.
Chris@18 502 *
Chris@18 503 * @return string
Chris@18 504 * The resolved internal name.
Chris@18 505 */
Chris@18 506 protected function getInternalName($field_name, array $resource_types) {
Chris@18 507 return array_reduce($resource_types, function ($carry, ResourceType $resource_type) use ($field_name) {
Chris@18 508 if ($carry != $field_name) {
Chris@18 509 // We already found the internal name.
Chris@18 510 return $carry;
Chris@18 511 }
Chris@18 512 return $field_name === 'id' ? $this->getIdFieldName($resource_type) : $resource_type->getInternalName($field_name);
Chris@18 513 }, $field_name);
Chris@18 514 }
Chris@18 515
Chris@18 516 /**
Chris@18 517 * Determines if the given field or member name is filterable.
Chris@18 518 *
Chris@18 519 * @param string $external_name
Chris@18 520 * The external field or member name.
Chris@18 521 * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
Chris@18 522 * The resource types to test.
Chris@18 523 *
Chris@18 524 * @return bool
Chris@18 525 * Whether the given field is present as a filterable member of the targeted
Chris@18 526 * resource objects.
Chris@18 527 */
Chris@18 528 protected function isMemberFilterable($external_name, array $resource_types) {
Chris@18 529 return array_reduce($resource_types, function ($carry, ResourceType $resource_type) use ($external_name) {
Chris@18 530 // @todo: remove the next line and uncomment the following one in https://www.drupal.org/project/jsonapi/issues/3017047.
Chris@18 531 return $carry ?: $external_name === 'id' || $resource_type->isFieldEnabled($resource_type->getInternalName($external_name));
Chris@18 532 /*return $carry ?: in_array($external_name, ['id', 'type']) || $resource_type->isFieldEnabled($resource_type->getInternalName($external_name));*/
Chris@18 533 }, FALSE);
Chris@18 534 }
Chris@18 535
Chris@18 536 /**
Chris@18 537 * Get the referenceable ResourceTypes for a set of field definitions.
Chris@18 538 *
Chris@18 539 * @param \Drupal\Core\Field\FieldDefinitionInterface[] $definitions
Chris@18 540 * The resource types on which the reference field might exist.
Chris@18 541 *
Chris@18 542 * @return \Drupal\jsonapi\ResourceType\ResourceType[]
Chris@18 543 * The referenceable target resource types.
Chris@18 544 */
Chris@18 545 protected function getReferenceableResourceTypes(array $definitions) {
Chris@18 546 return array_reduce($definitions, function ($result, $definition) {
Chris@18 547 $resource_types = array_filter(
Chris@18 548 $this->collectResourceTypesForReference($definition)
Chris@18 549 );
Chris@18 550 $type_names = array_map(function ($resource_type) {
Chris@18 551 /* @var \Drupal\jsonapi\ResourceType\ResourceType $resource_type */
Chris@18 552 return $resource_type->getTypeName();
Chris@18 553 }, $resource_types);
Chris@18 554 return array_merge($result, array_combine($type_names, $resource_types));
Chris@18 555 }, []);
Chris@18 556 }
Chris@18 557
Chris@18 558 /**
Chris@18 559 * Build a list of resource types depending on which bundles are referenced.
Chris@18 560 *
Chris@18 561 * @param \Drupal\Core\Field\TypedData\FieldItemDataDefinitionInterface $item_definition
Chris@18 562 * The reference definition.
Chris@18 563 *
Chris@18 564 * @return \Drupal\jsonapi\ResourceType\ResourceType[]
Chris@18 565 * The list of resource types.
Chris@18 566 */
Chris@18 567 protected function collectResourceTypesForReference(FieldItemDataDefinitionInterface $item_definition) {
Chris@18 568 $main_property_definition = $item_definition->getPropertyDefinition(
Chris@18 569 $item_definition->getMainPropertyName()
Chris@18 570 );
Chris@18 571
Chris@18 572 // Check if the field is a flavor of an Entity Reference field.
Chris@18 573 if (!$main_property_definition instanceof DataReferenceTargetDefinition) {
Chris@18 574 return [];
Chris@18 575 }
Chris@18 576 $entity_type_id = $item_definition->getSetting('target_type');
Chris@18 577 $handler_settings = $item_definition->getSetting('handler_settings');
Chris@18 578
Chris@18 579 $has_target_bundles = isset($handler_settings['target_bundles']) && !empty($handler_settings['target_bundles']);
Chris@18 580 $target_bundles = $has_target_bundles ?
Chris@18 581 $handler_settings['target_bundles']
Chris@18 582 : $this->getAllBundlesForEntityType($entity_type_id);
Chris@18 583
Chris@18 584 return array_map(function ($bundle) use ($entity_type_id) {
Chris@18 585 return $this->resourceTypeRepository->get($entity_type_id, $bundle);
Chris@18 586 }, $target_bundles);
Chris@18 587 }
Chris@18 588
Chris@18 589 /**
Chris@18 590 * Whether the given resources can be traversed to other resources.
Chris@18 591 *
Chris@18 592 * @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
Chris@18 593 * The resources types to evaluate.
Chris@18 594 *
Chris@18 595 * @return bool
Chris@18 596 * TRUE if any one of the given resource types is traversable.
Chris@18 597 *
Chris@18 598 * @todo This class shouldn't be aware of entity types and their definitions.
Chris@18 599 * Whether a resource can have relationships to other resources is information
Chris@18 600 * we ought to be able to discover on the ResourceType. However, we cannot
Chris@18 601 * reliably determine this information with existing APIs. Entities may be
Chris@18 602 * backed by various storages that are unable to perform queries across
Chris@18 603 * references and certain storages may not be able to store references at all.
Chris@18 604 */
Chris@18 605 protected function resourceTypesAreTraversable(array $resource_types) {
Chris@18 606 foreach ($resource_types as $resource_type) {
Chris@18 607 $entity_type_definition = $this->entityTypeManager->getDefinition($resource_type->getEntityTypeId());
Chris@18 608 if ($entity_type_definition->entityClassImplements(FieldableEntityInterface::class)) {
Chris@18 609 return TRUE;
Chris@18 610 }
Chris@18 611 }
Chris@18 612 return FALSE;
Chris@18 613 }
Chris@18 614
Chris@18 615 /**
Chris@18 616 * Gets all bundle IDs for a given entity type.
Chris@18 617 *
Chris@18 618 * @param string $entity_type_id
Chris@18 619 * The entity type for which to get bundles.
Chris@18 620 *
Chris@18 621 * @return string[]
Chris@18 622 * The bundle IDs.
Chris@18 623 */
Chris@18 624 protected function getAllBundlesForEntityType($entity_type_id) {
Chris@18 625 return array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id));
Chris@18 626 }
Chris@18 627
Chris@18 628 /**
Chris@18 629 * Gets all unique reference property names from the given field definitions.
Chris@18 630 *
Chris@18 631 * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
Chris@18 632 * A list of targeted field item definitions specified by the path.
Chris@18 633 *
Chris@18 634 * @return string[]
Chris@18 635 * The reference property names, if any.
Chris@18 636 */
Chris@18 637 protected static function getAllDataReferencePropertyNames(array $candidate_definitions) {
Chris@18 638 $reference_property_names = array_reduce($candidate_definitions, function (array $reference_property_names, ComplexDataDefinitionInterface $definition) {
Chris@18 639 $property_definitions = $definition->getPropertyDefinitions();
Chris@18 640 foreach ($property_definitions as $property_name => $property_definition) {
Chris@18 641 if ($property_definition instanceof DataReferenceDefinitionInterface) {
Chris@18 642 $target_definition = $property_definition->getTargetDefinition();
Chris@18 643 assert($target_definition instanceof EntityDataDefinitionInterface, 'Entity reference fields should only be able to reference entities.');
Chris@18 644 $reference_property_names[] = $property_name . ':' . $target_definition->getEntityTypeId();
Chris@18 645 }
Chris@18 646 }
Chris@18 647 return $reference_property_names;
Chris@18 648 }, []);
Chris@18 649 return array_unique($reference_property_names);
Chris@18 650 }
Chris@18 651
Chris@18 652 /**
Chris@18 653 * Determines the reference property name for the remaining unresolved parts.
Chris@18 654 *
Chris@18 655 * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
Chris@18 656 * A list of targeted field item definitions specified by the path.
Chris@18 657 * @param string[] $remaining_parts
Chris@18 658 * The remaining path parts.
Chris@18 659 * @param string[] $unresolved_path_parts
Chris@18 660 * The unresolved path parts.
Chris@18 661 *
Chris@18 662 * @return string
Chris@18 663 * The reference name.
Chris@18 664 */
Chris@18 665 protected static function getDataReferencePropertyName(array $candidate_definitions, array $remaining_parts, array $unresolved_path_parts) {
Chris@18 666 $unique_reference_names = static::getAllDataReferencePropertyNames($candidate_definitions);
Chris@18 667 if (count($unique_reference_names) > 1) {
Chris@18 668 $choices = array_map(function ($reference_name) use ($unresolved_path_parts, $remaining_parts) {
Chris@18 669 $prior_parts = array_slice($unresolved_path_parts, 0, count($unresolved_path_parts) - count($remaining_parts));
Chris@18 670 return implode('.', array_merge($prior_parts, [$reference_name], $remaining_parts));
Chris@18 671 }, $unique_reference_names);
Chris@18 672 // @todo Add test coverage for this in https://www.drupal.org/project/jsonapi/issues/2971281
Chris@18 673 $message = sprintf('Ambiguous path. Try one of the following: %s, in place of the given path: %s', implode(', ', $choices), implode('.', $unresolved_path_parts));
Chris@18 674 $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:filter', 'url.query_args:sort']);
Chris@18 675 throw new CacheableBadRequestHttpException($cacheability, $message);
Chris@18 676 }
Chris@18 677 return $unique_reference_names[0];
Chris@18 678 }
Chris@18 679
Chris@18 680 /**
Chris@18 681 * Determines if a path part targets a specific field delta.
Chris@18 682 *
Chris@18 683 * @param string $part
Chris@18 684 * The path part.
Chris@18 685 *
Chris@18 686 * @return bool
Chris@18 687 * TRUE if the part is an integer, FALSE otherwise.
Chris@18 688 */
Chris@18 689 protected static function isDelta($part) {
Chris@18 690 return (bool) preg_match('/^[0-9]+$/', $part);
Chris@18 691 }
Chris@18 692
Chris@18 693 /**
Chris@18 694 * Determines if a path part targets a field property, not a subsequent field.
Chris@18 695 *
Chris@18 696 * @param string $part
Chris@18 697 * The path part.
Chris@18 698 * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
Chris@18 699 * A list of targeted field item definitions which are specified by the
Chris@18 700 * path.
Chris@18 701 *
Chris@18 702 * @return bool
Chris@18 703 * TRUE if the part is a property of one of the candidate definitions, FALSE
Chris@18 704 * otherwise.
Chris@18 705 */
Chris@18 706 protected static function isCandidateDefinitionProperty($part, array $candidate_definitions) {
Chris@18 707 $part = static::getPathPartPropertyName($part);
Chris@18 708 foreach ($candidate_definitions as $definition) {
Chris@18 709 if ($definition->getPropertyDefinition($part)) {
Chris@18 710 return TRUE;
Chris@18 711 }
Chris@18 712 }
Chris@18 713 return FALSE;
Chris@18 714 }
Chris@18 715
Chris@18 716 /**
Chris@18 717 * Determines if a path part targets a reference property.
Chris@18 718 *
Chris@18 719 * @param string $part
Chris@18 720 * The path part.
Chris@18 721 * @param \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $candidate_definitions
Chris@18 722 * A list of targeted field item definitions which are specified by the
Chris@18 723 * path.
Chris@18 724 *
Chris@18 725 * @return bool
Chris@18 726 * TRUE if the part is a property of one of the candidate definitions, FALSE
Chris@18 727 * otherwise.
Chris@18 728 */
Chris@18 729 protected static function isCandidateDefinitionReferenceProperty($part, array $candidate_definitions) {
Chris@18 730 $part = static::getPathPartPropertyName($part);
Chris@18 731 foreach ($candidate_definitions as $definition) {
Chris@18 732 $property = $definition->getPropertyDefinition($part);
Chris@18 733 if ($property && $property instanceof DataReferenceDefinitionInterface) {
Chris@18 734 return TRUE;
Chris@18 735 }
Chris@18 736 }
Chris@18 737 return FALSE;
Chris@18 738 }
Chris@18 739
Chris@18 740 /**
Chris@18 741 * Gets the property name from an entity typed or untyped path part.
Chris@18 742 *
Chris@18 743 * A path part may contain an entity type specifier like `entity:node`. This
Chris@18 744 * extracts the actual property name. If an entity type is not specified, then
Chris@18 745 * the path part is simply returned. For example, both `foo` and `foo:bar`
Chris@18 746 * will return `foo`.
Chris@18 747 *
Chris@18 748 * @param string $part
Chris@18 749 * A path part.
Chris@18 750 *
Chris@18 751 * @return string
Chris@18 752 * The property name from a path part.
Chris@18 753 */
Chris@18 754 protected static function getPathPartPropertyName($part) {
Chris@18 755 return strpos($part, ':') !== FALSE ? explode(':', $part)[0] : $part;
Chris@18 756 }
Chris@18 757
Chris@18 758 /**
Chris@18 759 * Gets the field access result for the 'view' operation.
Chris@18 760 *
Chris@18 761 * @param \Drupal\jsonapi\ResourceType\ResourceType $resource_type
Chris@18 762 * The JSON:API resource type on which the field exists.
Chris@18 763 * @param string $internal_field_name
Chris@18 764 * The field name for which access should be checked.
Chris@18 765 *
Chris@18 766 * @return \Drupal\Core\Access\AccessResultInterface
Chris@18 767 * The 'view' access result.
Chris@18 768 */
Chris@18 769 protected function getFieldAccess(ResourceType $resource_type, $internal_field_name) {
Chris@18 770 $definitions = $this->fieldManager->getFieldDefinitions($resource_type->getEntityTypeId(), $resource_type->getBundle());
Chris@18 771 assert(isset($definitions[$internal_field_name]), 'The field name should have already been validated.');
Chris@18 772 $field_definition = $definitions[$internal_field_name];
Chris@18 773 $filter_access_results = $this->moduleHandler->invokeAll('jsonapi_entity_field_filter_access', [$field_definition, \Drupal::currentUser()]);
Chris@18 774 $filter_access_result = array_reduce($filter_access_results, function (AccessResultInterface $combined_result, AccessResultInterface $result) {
Chris@18 775 return $combined_result->orIf($result);
Chris@18 776 }, AccessResult::neutral());
Chris@18 777 if (!$filter_access_result->isNeutral()) {
Chris@18 778 return $filter_access_result;
Chris@18 779 }
Chris@18 780 $entity_access_control_handler = $this->entityTypeManager->getAccessControlHandler($resource_type->getEntityTypeId());
Chris@18 781 $field_access = $entity_access_control_handler->fieldAccess('view', $field_definition, NULL, NULL, TRUE);
Chris@18 782 return $filter_access_result->orIf($field_access);
Chris@18 783 }
Chris@18 784
Chris@18 785 }