diff options
Diffstat (limited to 'core/modules/jsonapi')
14 files changed, 642 insertions, 112 deletions
diff --git a/core/modules/jsonapi/jsonapi.api.php b/core/modules/jsonapi/jsonapi.api.php index 5b2f2002d25c..ca8c5ae993fb 100644 --- a/core/modules/jsonapi/jsonapi.api.php +++ b/core/modules/jsonapi/jsonapi.api.php @@ -9,6 +9,7 @@ use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Access\AccessResult; +use Drupal\jsonapi\JsonApiFilter; /** * @defgroup jsonapi_architecture JSON:API Architecture @@ -263,10 +264,10 @@ use Drupal\Core\Access\AccessResult; * viewable. * - AccessResult::neutral() if the implementation has no opinion. * The supported subsets for which an access result may be returned are: - * - JSONAPI_FILTER_AMONG_ALL: all entities of the given type. - * - JSONAPI_FILTER_AMONG_PUBLISHED: all published entities of the given type. - * - JSONAPI_FILTER_AMONG_ENABLED: all enabled entities of the given type. - * - JSONAPI_FILTER_AMONG_OWN: all entities of the given type owned by the + * - JsonApiFilter::AMONG_ALL: all entities of the given type. + * - JsonApiFilter::AMONG_PUBLISHED: all published entities of the given type. + * - JsonApiFilter::AMONG_ENABLED: all enabled entities of the given type. + * - JsonApiFilter::AMONG_OWN: all entities of the given type owned by the * user for whom access is being checked. * See the documentation of the above constants for more information about * each subset. @@ -278,7 +279,7 @@ function hook_jsonapi_entity_filter_access(EntityTypeInterface $entity_type, Acc // by all entities of that type to users with that permission. if ($admin_permission = $entity_type->getAdminPermission()) { return ([ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, $admin_permission), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, $admin_permission), ]); } } @@ -305,9 +306,9 @@ function hook_jsonapi_entity_filter_access(EntityTypeInterface $entity_type, Acc */ function hook_jsonapi_ENTITY_TYPE_filter_access(EntityTypeInterface $entity_type, AccountInterface $account): array { return ([ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer llamas'), - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'view all published llamas'), - JSONAPI_FILTER_AMONG_OWN => AccessResult::allowedIfHasPermissions($account, ['view own published llamas', 'view own unpublished llamas'], 'AND'), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer llamas'), + JsonApiFilter::AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'view all published llamas'), + JsonApiFilter::AMONG_OWN => AccessResult::allowedIfHasPermissions($account, ['view own published llamas', 'view own unpublished llamas'], 'AND'), ]); } diff --git a/core/modules/jsonapi/jsonapi.install b/core/modules/jsonapi/jsonapi.install index c5a60c424fbe..47efd893ae0e 100644 --- a/core/modules/jsonapi/jsonapi.install +++ b/core/modules/jsonapi/jsonapi.install @@ -5,8 +5,6 @@ * Module install file. */ -use Drupal\Core\Url; - /** * Implements hook_install(). */ @@ -28,55 +26,6 @@ function jsonapi_install(): void { } /** - * Implements hook_requirements(). - */ -function jsonapi_requirements($phase): array { - $requirements = []; - if ($phase === 'runtime') { - $module_handler = \Drupal::moduleHandler(); - $potential_conflicts = [ - 'content_translation', - 'config_translation', - 'language', - ]; - $should_warn = array_reduce($potential_conflicts, function ($should_warn, $module_name) use ($module_handler) { - return $should_warn ?: $module_handler->moduleExists($module_name); - }, FALSE); - if ($should_warn) { - $requirements['jsonapi_multilingual_support'] = [ - 'title' => t('JSON:API multilingual support'), - 'value' => t('Limited'), - 'severity' => REQUIREMENT_INFO, - 'description' => t('Some multilingual features currently do not work well with JSON:API. See the <a href=":jsonapi-docs">JSON:API multilingual support documentation</a> for more information on the current status of multilingual support.', [ - ':jsonapi-docs' => 'https://www.drupal.org/docs/8/modules/jsonapi/translations', - ]), - ]; - } - $requirements['jsonapi_revision_support'] = [ - 'title' => t('JSON:API revision support'), - 'value' => t('Limited'), - 'severity' => REQUIREMENT_INFO, - 'description' => t('Revision support is currently read-only and only for the "Content" and "Media" entity types in JSON:API. See the <a href=":jsonapi-docs">JSON:API revision support documentation</a> for more information on the current status of revision support.', [ - ':jsonapi-docs' => 'https://www.drupal.org/docs/8/modules/jsonapi/revisions', - ]), - ]; - $requirements['jsonapi_read_only_mode'] = [ - 'title' => t('JSON:API allowed operations'), - 'value' => t('Read-only'), - 'severity' => REQUIREMENT_INFO, - ]; - if (!\Drupal::configFactory()->get('jsonapi.settings')->get('read_only')) { - $requirements['jsonapi_read_only_mode']['value'] = t('All (create, read, update, delete)'); - $requirements['jsonapi_read_only_mode']['description'] = t('It is recommended to <a href=":configure-url">configure</a> JSON:API to only accept 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', - ':configure-url' => Url::fromRoute('jsonapi.settings')->toString(), - ]); - } - } - return $requirements; -} - -/** * Implements hook_update_last_removed(). */ function jsonapi_update_last_removed(): int { diff --git a/core/modules/jsonapi/jsonapi.module b/core/modules/jsonapi/jsonapi.module index 69414af650b9..c512575305a4 100644 --- a/core/modules/jsonapi/jsonapi.module +++ b/core/modules/jsonapi/jsonapi.module @@ -11,6 +11,10 @@ * regardless of whether they are published or enabled, and regardless of * their owner. * + * @deprecated in drupal:11.3.0 and is removed from drupal:13.0.0. Use + * \Drupal\jsonapi\JsonApiFilter::AMONG_ALL instead. + * + * @see https://www.drupal.org/node/3495601 * @see hook_jsonapi_entity_filter_access() * @see hook_jsonapi_ENTITY_TYPE_filter_access() */ @@ -25,6 +29,10 @@ const JSONAPI_FILTER_AMONG_ALL = 'filter_among_all'; * This is used when an entity type has a "published" entity key and there's a * query condition for the value of that equaling 1. * + * @deprecated in drupal:11.3.0 and is removed from drupal:13.0.0. Use + * \Drupal\jsonapi\JsonApiFilter::AMONG_PUBLISHED instead. + * + * @see https://www.drupal.org/node/3495601 * @see hook_jsonapi_entity_filter_access() * @see hook_jsonapi_ENTITY_TYPE_filter_access() */ @@ -42,6 +50,10 @@ const JSONAPI_FILTER_AMONG_PUBLISHED = 'filter_among_published'; * For the User entity type, which does not have a "status" entity key, the * "status" field is used. * + * @deprecated in drupal:11.3.0 and is removed from drupal:13.0.0. Use + * \Drupal\jsonapi\JsonApiFilter::AMONG_ENABLED instead. + * + * @see https://www.drupal.org/node/3495601 * @see hook_jsonapi_entity_filter_access() * @see hook_jsonapi_ENTITY_TYPE_filter_access() */ @@ -64,6 +76,10 @@ const JSONAPI_FILTER_AMONG_ENABLED = 'filter_among_enabled'; * - The entity type has an "owner" entity key. * - There's a filter/query condition for the value equal to the user's ID. * + * @deprecated in drupal:11.3.0 and is removed from drupal:13.0.0. Use + * \Drupal\jsonapi\JsonApiFilter::AMONG_OWN instead. + * + * @see https://www.drupal.org/node/3495601 * @see hook_jsonapi_entity_filter_access() * @see hook_jsonapi_ENTITY_TYPE_filter_access() */ diff --git a/core/modules/jsonapi/src/Access/TemporaryQueryGuard.php b/core/modules/jsonapi/src/Access/TemporaryQueryGuard.php index d59dca4ec03a..2888fbbec77c 100644 --- a/core/modules/jsonapi/src/Access/TemporaryQueryGuard.php +++ b/core/modules/jsonapi/src/Access/TemporaryQueryGuard.php @@ -12,6 +12,7 @@ use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\TypedData\DataReferenceDefinitionInterface; +use Drupal\jsonapi\JsonApiFilter; use Drupal\jsonapi\Query\EntityCondition; use Drupal\jsonapi\Query\EntityConditionGroup; use Drupal\jsonapi\Query\Filter; @@ -323,12 +324,12 @@ class TemporaryQueryGuard { } /** - * Gets an access condition for the allowed JSONAPI_FILTER_AMONG_* subsets. + * Gets an access condition for the allowed JsonApiFilter::AMONG_* subsets. * - * If access is allowed for the JSONAPI_FILTER_AMONG_ALL subset, then no + * If access is allowed for the JsonApiFilter::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 + * JsonApiFilter::AMONG_PUBLISHED, JsonApiFilter::AMONG_ENABLED, or + * JsonApiFilter::AMONG_OWN, then a condition group is returned for the union * of allowed subsets. If no subsets are allowed, then static::alwaysFalse() * is returned. * @@ -344,12 +345,12 @@ class TemporaryQueryGuard { * 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. + // Get the combined access results for each JsonApiFilter::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()) { + $cacheability->addCacheableDependency($access_results[JsonApiFilter::AMONG_ALL]); + if ($access_results[JsonApiFilter::AMONG_ALL]->isAllowed()) { return NULL; } @@ -363,7 +364,7 @@ class TemporaryQueryGuard { // The "published" subset. $published_field_name = $entity_type->getKey('published'); if ($published_field_name) { - $access_result = $access_results[JSONAPI_FILTER_AMONG_PUBLISHED]; + $access_result = $access_results[JsonApiFilter::AMONG_PUBLISHED]; $cacheability->addCacheableDependency($access_result); if ($access_result->isAllowed()) { $conditions[] = new EntityCondition($published_field_name, 1); @@ -375,7 +376,7 @@ class TemporaryQueryGuard { // @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]; + $access_result = $access_results[JsonApiFilter::AMONG_ENABLED]; $cacheability->addCacheableDependency($access_result); if ($access_result->isAllowed()) { $conditions[] = new EntityCondition($status_field_name, 1); @@ -387,7 +388,7 @@ class TemporaryQueryGuard { // @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]; + $access_result = $access_results[JsonApiFilter::AMONG_OWN]; $cacheability->addCacheableDependency($access_result); if ($access_result->isAllowed()) { $cacheability->addCacheContexts(['user']); @@ -415,7 +416,7 @@ class TemporaryQueryGuard { } /** - * Gets the combined access result for each JSONAPI_FILTER_AMONG_* subset. + * Gets the combined access result for each JsonApiFilter::AMONG_* subset. * * This invokes hook_jsonapi_entity_filter_access() and * hook_jsonapi_ENTITY_TYPE_filter_access() and combines the results from all @@ -433,10 +434,10 @@ class TemporaryQueryGuard { 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(), + JsonApiFilter::AMONG_ALL => AccessResult::neutral(), + JsonApiFilter::AMONG_PUBLISHED => AccessResult::neutral(), + JsonApiFilter::AMONG_ENABLED => AccessResult::neutral(), + JsonApiFilter::AMONG_OWN => AccessResult::neutral(), ]; // Invoke hook_jsonapi_entity_filter_access() and diff --git a/core/modules/jsonapi/src/Controller/EntityResource.php b/core/modules/jsonapi/src/Controller/EntityResource.php index 6416d6cb27ac..53822cdf1f1f 100644 --- a/core/modules/jsonapi/src/Controller/EntityResource.php +++ b/core/modules/jsonapi/src/Controller/EntityResource.php @@ -672,9 +672,10 @@ class EntityResource { return $this->getRelationship($resource_type, $entity, $related, $request, $status); } - $main_property_name = $field_definition->getItemDefinition()->getMainPropertyName(); foreach ($new_resource_identifiers as $new_resource_identifier) { - $new_field_value = [$main_property_name => $this->getEntityFromResourceIdentifier($new_resource_identifier)->id()]; + // We assume all entity reference fields have an 'entity' computed + // property that can be used to assign the needed values. + $new_field_value = ['entity' => $this->getEntityFromResourceIdentifier($new_resource_identifier)]; // Remove `arity` from the received extra properties, otherwise this // will fail field validation. $new_field_value += array_diff_key($new_resource_identifier->getMeta(), array_flip([ResourceIdentifier::ARITY_KEY])); @@ -760,9 +761,10 @@ class EntityResource { * The field definition of the entity field to be updated. */ protected function doPatchMultipleRelationship(EntityInterface $entity, array $resource_identifiers, FieldDefinitionInterface $field_definition) { - $main_property_name = $field_definition->getItemDefinition()->getMainPropertyName(); - $entity->{$field_definition->getName()} = array_map(function (ResourceIdentifier $resource_identifier) use ($main_property_name) { - $field_properties = [$main_property_name => $this->getEntityFromResourceIdentifier($resource_identifier)->id()]; + $entity->{$field_definition->getName()} = array_map(function (ResourceIdentifier $resource_identifier) { + // We assume all entity reference fields have an 'entity' computed + // property that can be used to assign the needed values. + $field_properties = ['entity' => $this->getEntityFromResourceIdentifier($resource_identifier)]; // Remove `arity` from the received extra properties, otherwise this // will fail field validation. $field_properties += array_diff_key($resource_identifier->getMeta(), array_flip([ResourceIdentifier::ARITY_KEY])); diff --git a/core/modules/jsonapi/src/Hook/JsonapiHooks.php b/core/modules/jsonapi/src/Hook/JsonapiHooks.php index 7db75a3297be..d5f6d2540fcd 100644 --- a/core/modules/jsonapi/src/Hook/JsonapiHooks.php +++ b/core/modules/jsonapi/src/Hook/JsonapiHooks.php @@ -7,6 +7,7 @@ use Drupal\Core\Session\AccountInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\jsonapi\JsonApiFilter; use Drupal\jsonapi\Routing\Routes; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Hook\Attribute\Hook; @@ -107,7 +108,7 @@ class JsonapiHooks { // AccessResult::forbidden() from its implementation of this hook. if ($admin_permission = $entity_type->getAdminPermission()) { return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, $admin_permission), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, $admin_permission), ]; } return []; @@ -122,8 +123,8 @@ class JsonapiHooks { // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for // (isReusable()), so this does not have to. return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'access block library'), - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowed(), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'access block library'), + JsonApiFilter::AMONG_PUBLISHED => AccessResult::allowed(), ]; } @@ -136,8 +137,8 @@ class JsonapiHooks { // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for // (access to the commented entity), so this does not have to. return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer comments'), - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'access comments'), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer comments'), + JsonApiFilter::AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'access comments'), ]; } @@ -148,7 +149,7 @@ class JsonapiHooks { public function jsonapiEntityTestFilterAccess(EntityTypeInterface $entity_type, AccountInterface $account): array { // @see \Drupal\entity_test\EntityTestAccessControlHandler::checkAccess() return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'view test entity'), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'view test entity'), ]; } @@ -161,7 +162,7 @@ class JsonapiHooks { // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for // (public OR owner), so this does not have to. return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'access content'), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'access content'), ]; } @@ -172,7 +173,7 @@ class JsonapiHooks { public function jsonapiMediaFilterAccess(EntityTypeInterface $entity_type, AccountInterface $account): array { // @see \Drupal\media\MediaAccessControlHandler::checkAccess() return [ - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'view media'), + JsonApiFilter::AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'view media'), ]; } @@ -184,30 +185,30 @@ class JsonapiHooks { // @see \Drupal\node\NodeAccessControlHandler::access() if ($account->hasPermission('bypass node access')) { return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowed()->cachePerPermissions(), + JsonApiFilter::AMONG_ALL => AccessResult::allowed()->cachePerPermissions(), ]; } if (!$account->hasPermission('access content')) { $forbidden = AccessResult::forbidden("The 'access content' permission is required.")->cachePerPermissions(); return [ - JSONAPI_FILTER_AMONG_ALL => $forbidden, - JSONAPI_FILTER_AMONG_OWN => $forbidden, - JSONAPI_FILTER_AMONG_PUBLISHED => $forbidden, + JsonApiFilter::AMONG_ALL => $forbidden, + JsonApiFilter::AMONG_OWN => $forbidden, + JsonApiFilter::AMONG_PUBLISHED => $forbidden, // For legacy reasons, the Node entity type has a "status" key, so // forbid this subset as well, even though it has no semantic meaning. - JSONAPI_FILTER_AMONG_ENABLED => $forbidden, + JsonApiFilter::AMONG_ENABLED => $forbidden, ]; } return [ - // @see \Drupal\node\NodeAccessControlHandler::checkAccess() - JSONAPI_FILTER_AMONG_OWN => AccessResult::allowedIfHasPermission($account, 'view own unpublished content'), - // @see \Drupal\node\NodeGrantDatabaseStorage::access() - // Note that: - // - This is just for the default grant. Other node access conditions - // are added via the 'node_access' query tag. - // - Permissions were checked earlier in this function, so we must - // vary the cache by them. - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowed()->cachePerPermissions(), + // @see \Drupal\node\NodeAccessControlHandler::checkAccess() + JsonApiFilter::AMONG_OWN => AccessResult::allowedIfHasPermission($account, 'view own unpublished content'), + // @see \Drupal\node\NodeGrantDatabaseStorage::access() + // Note that: + // - This is just for the default grant. Other node access conditions + // are added via the 'node_access' query tag. + // - Permissions were checked earlier in this function, so we must + // vary the cache by them. + JsonApiFilter::AMONG_PUBLISHED => AccessResult::allowed()->cachePerPermissions(), ]; } @@ -218,10 +219,10 @@ class JsonapiHooks { public function jsonapiShortcutFilterAccess(EntityTypeInterface $entity_type, AccountInterface $account): array { // @see \Drupal\shortcut\ShortcutAccessControlHandler::checkAccess() // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for - // (shortcut_set = $shortcut_set_storage->getDisplayedToUser($current_user)), + // "shortcut_set = $shortcut_set_storage->getDisplayedToUser($current_user)" // so this does not have to. return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer shortcuts')->orIf(AccessResult::allowedIfHasPermissions($account, [ + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer shortcuts')->orIf(AccessResult::allowedIfHasPermissions($account, [ 'access shortcuts', 'customize shortcut links', ])), @@ -235,8 +236,8 @@ class JsonapiHooks { public function jsonapiTaxonomyTermFilterAccess(EntityTypeInterface $entity_type, AccountInterface $account): array { // @see \Drupal\taxonomy\TermAccessControlHandler::checkAccess() return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer taxonomy'), - JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'access content'), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer taxonomy'), + JsonApiFilter::AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'access content'), ]; } @@ -249,8 +250,8 @@ class JsonapiHooks { // \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for // (!isAnonymous()), so this does not have to. return [ - JSONAPI_FILTER_AMONG_OWN => AccessResult::allowed(), - JSONAPI_FILTER_AMONG_ENABLED => AccessResult::allowedIfHasPermission($account, 'access user profiles'), + JsonApiFilter::AMONG_OWN => AccessResult::allowed(), + JsonApiFilter::AMONG_ENABLED => AccessResult::allowedIfHasPermission($account, 'access user profiles'), ]; } @@ -261,8 +262,8 @@ class JsonapiHooks { public function jsonapiWorkspaceFilterAccess(EntityTypeInterface $entity_type, AccountInterface $account): array { // @see \Drupal\workspaces\WorkspaceAccessControlHandler::checkAccess() return [ - JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'view any workspace'), - JSONAPI_FILTER_AMONG_OWN => AccessResult::allowedIfHasPermission($account, 'view own workspace'), + JsonApiFilter::AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'view any workspace'), + JsonApiFilter::AMONG_OWN => AccessResult::allowedIfHasPermission($account, 'view own workspace'), ]; } diff --git a/core/modules/jsonapi/src/Hook/JsonapiRequirements.php b/core/modules/jsonapi/src/Hook/JsonapiRequirements.php new file mode 100644 index 000000000000..4903389fddfd --- /dev/null +++ b/core/modules/jsonapi/src/Hook/JsonapiRequirements.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\jsonapi\Hook; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Requirement\RequirementSeverity; +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Url; + +/** + * Requirements for the JSON:API module. + */ +class JsonapiRequirements { + + use StringTranslationTrait; + + public function __construct( + protected readonly ConfigFactoryInterface $configFactory, + protected readonly ModuleHandlerInterface $moduleHandler, + ) {} + + /** + * Implements hook_runtime_requirements(). + */ + #[Hook('runtime_requirements')] + public function runtime(): array { + $requirements = []; + $potential_conflicts = [ + 'content_translation', + 'config_translation', + 'language', + ]; + $should_warn = array_reduce($potential_conflicts, function ($should_warn, $module_name) { + return $should_warn ?: $this->moduleHandler->moduleExists($module_name); + }, FALSE); + if ($should_warn) { + $requirements['jsonapi_multilingual_support'] = [ + 'title' => $this->t('JSON:API multilingual support'), + 'value' => $this->t('Limited'), + 'severity' => RequirementSeverity::Info, + 'description' => $this->t('Some multilingual features currently do not work well with JSON:API. See the <a href=":jsonapi-docs">JSON:API multilingual support documentation</a> for more information on the current status of multilingual support.', [ + ':jsonapi-docs' => 'https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module/translations', + ]), + ]; + } + $requirements['jsonapi_revision_support'] = [ + 'title' => $this->t('JSON:API revision support'), + 'value' => $this->t('Limited'), + 'severity' => RequirementSeverity::Info, + 'description' => $this->t('Revision support is currently read-only and only for the "Content" and "Media" entity types in JSON:API. See the <a href=":jsonapi-docs">JSON:API revision support documentation</a> for more information on the current status of revision support.', [ + ':jsonapi-docs' => 'https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module/revisions', + ]), + ]; + $requirements['jsonapi_read_only_mode'] = [ + 'title' => $this->t('JSON:API allowed operations'), + 'value' => $this->t('Read-only'), + 'severity' => RequirementSeverity::Info, + ]; + if (!$this->configFactory->get('jsonapi.settings')->get('read_only')) { + $requirements['jsonapi_read_only_mode']['value'] = $this->t('All (create, read, update, delete)'); + $requirements['jsonapi_read_only_mode']['description'] = $this->t('It is recommended to <a href=":configure-url">configure</a> JSON:API to only accept 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/core-modules-and-themes/core-modules/jsonapi-module/security-considerations', + ':configure-url' => Url::fromRoute('jsonapi.settings')->toString(), + ]); + } + return $requirements; + } + +} diff --git a/core/modules/jsonapi/src/JsonApiFilter.php b/core/modules/jsonapi/src/JsonApiFilter.php new file mode 100644 index 000000000000..c9ac90be7af7 --- /dev/null +++ b/core/modules/jsonapi/src/JsonApiFilter.php @@ -0,0 +1,77 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\jsonapi; + +/** + * JsonApi filter options. + */ +final class JsonApiFilter { + + /** + * Array key for denoting type-based filtering access. + * + * Array key for denoting access to filter among all entities of a given type, + * regardless of whether they are published or enabled, and regardless of + * their owner. + * + * @see hook_jsonapi_entity_filter_access() + * @see hook_jsonapi_ENTITY_TYPE_filter_access() + */ + const AMONG_ALL = 'filter_among_all'; + + /** + * Array key for denoting type-based published-only filtering access. + * + * Array key for denoting access to filter among all published entities of a + * given type, regardless of their owner. + * + * This is used when an entity type has a "published" entity key and there's a + * query condition for the value of that equaling 1. + * + * @see hook_jsonapi_entity_filter_access() + * @see hook_jsonapi_ENTITY_TYPE_filter_access() + */ + const AMONG_PUBLISHED = 'filter_among_published'; + + /** + * Array key for denoting type-based enabled-only filtering access. + * + * Array key for denoting access to filter among all enabled entities of a + * given type, regardless of their owner. + * + * This is used when an entity type has a "status" entity key and there's a + * query condition for the value of that equaling 1. + * + * For the User entity type, which does not have a "status" entity key, the + * "status" field is used. + * + * @see hook_jsonapi_entity_filter_access() + * @see hook_jsonapi_ENTITY_TYPE_filter_access() + */ + const AMONG_ENABLED = 'filter_among_enabled'; + + /** + * Array key for denoting type-based owned-only filtering access. + * + * Array key for denoting access to filter among all entities of a given type, + * regardless of whether they are published or enabled, so long as they are + * owned by the user for whom access is being checked. + * + * When filtering among User entities, this is used when access is being + * checked for an authenticated user and there's a query condition + * limiting the result set to just that user's entity object. + * + * When filtering among entities of another type, this is used when all of the + * following conditions are met: + * - Access is being checked for an authenticated user. + * - The entity type has an "owner" entity key. + * - There's a filter/query condition for the value equal to the user's ID. + * + * @see hook_jsonapi_entity_filter_access() + * @see hook_jsonapi_ENTITY_TYPE_filter_access() + */ + const AMONG_OWN = 'filter_among_own'; + +} diff --git a/core/modules/jsonapi/src/Normalizer/Value/TemporaryArrayObjectThrowingExceptions.php b/core/modules/jsonapi/src/Normalizer/Value/TemporaryArrayObjectThrowingExceptions.php index d6e766416b0b..0f3c74f1ef53 100644 --- a/core/modules/jsonapi/src/Normalizer/Value/TemporaryArrayObjectThrowingExceptions.php +++ b/core/modules/jsonapi/src/Normalizer/Value/TemporaryArrayObjectThrowingExceptions.php @@ -97,7 +97,7 @@ class TemporaryArrayObjectThrowingExceptions extends \ArrayObject { } /** - * Gets the class name of the array iterator that is used by \ArrayObject::getIterator(). + * Gets the class name of the iterator used by \ArrayObject::getIterator(). * * @throws \Exception * This class does not support this action but it must implement it, because diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/config/schema/jsonapi_test_field_type.schema.yml b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/config/schema/jsonapi_test_field_type.schema.yml new file mode 100644 index 000000000000..9d082c7649c3 --- /dev/null +++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/config/schema/jsonapi_test_field_type.schema.yml @@ -0,0 +1,11 @@ +field.storage_settings.jsonapi_test_field_type_entity_reference_uuid: + type: field.storage_settings.entity_reference + label: 'Entity reference field storage settings' + +field.field_settings.jsonapi_test_field_type_entity_reference_uuid: + type: field.field_settings.entity_reference + label: 'Entity reference field settings' + +field.value.jsonapi_test_field_type_entity_reference_uuid: + type: field.value.entity_reference + label: 'Default value' diff --git a/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Plugin/Field/FieldType/EntityReferenceUuidItem.php b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Plugin/Field/FieldType/EntityReferenceUuidItem.php new file mode 100644 index 000000000000..f9aa3e77bbe9 --- /dev/null +++ b/core/modules/jsonapi/tests/modules/jsonapi_test_field_type/src/Plugin/Field/FieldType/EntityReferenceUuidItem.php @@ -0,0 +1,241 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\jsonapi_test_field_type\Plugin\Field\FieldType; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\TypedData\EntityDataDefinition; +use Drupal\Core\Field\Attribute\FieldType; +use Drupal\Core\Field\EntityReferenceFieldItemList; +use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\TypedData\DataReferenceDefinition; +use Drupal\Core\TypedData\DataReferenceTargetDefinition; + +/** + * Defines the 'entity_reference_uuid' entity field type. + * + * Supported settings (below the definition's 'settings' key) are: + * - target_type: The entity type to reference. Required. + * + * @property string $target_uuid + */ +#[FieldType( + id: 'jsonapi_test_field_type_entity_reference_uuid', + label: new TranslatableMarkup('Entity reference UUID'), + description: new TranslatableMarkup('An entity field containing an entity reference by UUID.'), + category: 'reference', + default_widget: 'entity_reference_autocomplete', + default_formatter: 'entity_reference_label', + list_class: EntityReferenceFieldItemList::class, +)] +class EntityReferenceUuidItem extends EntityReferenceItem { + + /** + * {@inheritdoc} + */ + public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) { + $settings = $field_definition->getSettings(); + $target_type_info = \Drupal::entityTypeManager()->getDefinition($settings['target_type']); + + $properties = parent::propertyDefinitions($field_definition); + + $target_uuid_definition = DataReferenceTargetDefinition::create('string') + ->setLabel(new TranslatableMarkup('@label UUID', ['@label' => $target_type_info->getLabel()])); + + $target_uuid_definition->setRequired(TRUE); + $properties['target_uuid'] = $target_uuid_definition; + + $properties['entity'] = DataReferenceDefinition::create('entity') + ->setLabel($target_type_info->getLabel()) + ->setDescription(new TranslatableMarkup('The referenced entity by UUID')) + // The entity object is computed out of the entity ID. + ->setComputed(TRUE) + ->setReadOnly(FALSE) + ->setTargetDefinition(EntityDataDefinition::create($settings['target_type'])) + // We can add a constraint for the target entity type. The list of + // referenceable bundles is a field setting, so the corresponding + // constraint is added dynamically in ::getConstraints(). + ->addConstraint('EntityType', $settings['target_type']); + + return $properties; + } + + /** + * {@inheritdoc} + */ + public static function mainPropertyName() { + return 'target_uuid'; + } + + /** + * {@inheritdoc} + */ + public static function schema(FieldStorageDefinitionInterface $field_definition) { + $columns = [ + 'target_uuid' => [ + 'description' => 'The UUID of the target entity.', + 'type' => 'varchar_ascii', + 'length' => 128, + ], + ]; + + return [ + 'columns' => $columns, + 'indexes' => [ + 'target_uuid' => ['target_uuid'], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function setValue($values, $notify = TRUE): void { + if (isset($values) && !is_array($values)) { + // If either a scalar or an object was passed as the value for the item, + // assign it to the 'entity' or 'target_uuid' depending on values type. + if (is_object($values)) { + $this->set('entity', $values, $notify); + } + else { + $this->set('target_uuid', $values, $notify); + } + } + else { + parent::setValue($values, FALSE); + // Support setting the field item with only one property, but make sure + // values stay in sync if only property is passed. + // NULL is a valid value, so we use array_key_exists(). + if (is_array($values) && array_key_exists('target_uuid', $values) && !isset($values['entity'])) { + $this->onChange('target_uuid', FALSE); + } + elseif (is_array($values) && !array_key_exists('target_uuid', $values) && isset($values['entity'])) { + $this->onChange('entity', FALSE); + } + elseif (is_array($values) && array_key_exists('target_uuid', $values) && isset($values['entity'])) { + // If both properties are passed, verify the passed values match. The + // only exception we allow is when we have a new entity: in this case + // its actual id and target_uuid will be different, due to the new + // entity marker. + $entity_uuid = $this->get('entity')->get('uuid'); + // If the entity has been saved and we're trying to set both the + // target_uuid and the entity values with a non-null target UUID, then + // the value for target_uuid should match the UUID of the entity value. + if (!$this->entity->isNew() && $values['target_uuid'] !== NULL && ($entity_uuid !== $values['target_uuid'])) { + throw new \InvalidArgumentException('The target UUID and entity passed to the entity reference item do not match.'); + } + } + // Notify the parent if necessary. + if ($notify && $this->parent) { + $this->parent->onChange($this->getName()); + } + } + + } + + /** + * {@inheritdoc} + */ + public function onChange($property_name, $notify = TRUE): void { + // Make sure that the target UUID and the target property stay in sync. + if ($property_name === 'entity') { + $property = $this->get('entity'); + if ($target_uuid = $property->isTargetNew() ? NULL : $property->getValue()->uuid()) { + $this->writePropertyValue('target_uuid', $target_uuid); + } + } + elseif ($property_name === 'target_uuid') { + $property = $this->get('entity'); + $entity_type = $property->getDataDefinition()->getConstraint('EntityType'); + $entities = \Drupal::entityTypeManager()->getStorage($entity_type)->loadByProperties(['uuid' => $this->get('target_uuid')->getValue()]); + if ($entity = array_shift($entities)) { + assert($entity instanceof EntityInterface); + $this->writePropertyValue('target_uuid', $entity->uuid()); + $this->writePropertyValue('entity', $entity); + } + } + parent::onChange($property_name, $notify); + } + + /** + * {@inheritdoc} + */ + public function isEmpty() { + // Avoid loading the entity by first checking the 'target_uuid'. + if ($this->target_uuid !== NULL) { + return FALSE; + } + if ($this->entity && $this->entity instanceof EntityInterface) { + return FALSE; + } + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function preSave(): void { + if ($this->hasNewEntity()) { + // Save the entity if it has not already been saved by some other code. + if ($this->entity->isNew()) { + $this->entity->save(); + } + // Make sure the parent knows we are updating this property so it can + // react properly. + $this->target_uuid = $this->entity->uuid(); + } + if (!$this->isEmpty() && $this->target_uuid === NULL) { + $this->target_uuid = $this->entity->uuid(); + } + } + + /** + * {@inheritdoc} + */ + public static function generateSampleValue(FieldDefinitionInterface $field_definition): array { + $manager = \Drupal::service('plugin.manager.entity_reference_selection'); + + // Instead of calling $manager->getSelectionHandler($field_definition) + // replicate the behavior to be able to override the sorting settings. + $options = [ + 'target_type' => $field_definition->getFieldStorageDefinition()->getSetting('target_type'), + 'handler' => $field_definition->getSetting('handler'), + 'handler_settings' => $field_definition->getSetting('handler_settings') ?: [], + 'entity' => NULL, + ]; + + $entity_type = \Drupal::entityTypeManager()->getDefinition($options['target_type']); + $options['handler_settings']['sort'] = [ + 'field' => $entity_type->getKey('uuid'), + 'direction' => 'DESC', + ]; + $selection_handler = $manager->getInstance($options); + + // Select a random number of references between the last 50 referenceable + // entities created. + if ($referenceable = $selection_handler->getReferenceableEntities(NULL, 'CONTAINS', 50)) { + $group = array_rand($referenceable); + return ['target_uuid' => array_rand($referenceable[$group])]; + } + return []; + } + + /** + * Determines whether the item holds an unsaved entity. + * + * This is notably used for "autocreate" widgets, and more generally to + * support referencing freshly created entities (they will get saved + * automatically as the hosting entity gets saved). + * + * @return bool + * TRUE if the item holds an unsaved entity. + */ + public function hasNewEntity() { + return !$this->isEmpty() && $this->target_uuid === NULL && $this->entity->isNew(); + } + +} diff --git a/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php b/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php index 22f8f7f57d1b..7539670e1555 100644 --- a/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php +++ b/core/modules/jsonapi/tests/src/Functional/FileUploadTest.php @@ -265,7 +265,7 @@ class FileUploadTest extends ResourceTestBase { ->set('field_rest_file_test', ['target_id' => $existing_file->id()]) ->save(); - $uri = Url::fromUri('base:' . '/jsonapi/entity_test/entity_test/' . $this->entity->uuid() . '/field_rest_file_test'); + $uri = Url::fromUri('base:/jsonapi/entity_test/entity_test/' . $this->entity->uuid() . '/field_rest_file_test'); // DX: 405 when read-only mode is enabled. $response = $this->fileRequest($uri, $this->testFileData); diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiRelationshipTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiRelationshipTest.php new file mode 100644 index 000000000000..110e1a6840b3 --- /dev/null +++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRelationshipTest.php @@ -0,0 +1,157 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\jsonapi\Functional; + +use Drupal\Core\Url; +use Drupal\entity_test\EntityTestHelper; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use GuzzleHttp\RequestOptions; + +/** + * JSON:API resource tests. + * + * @group jsonapi + * + * @internal + */ +class JsonApiRelationshipTest extends JsonApiFunctionalTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'basic_auth', + 'entity_test', + 'jsonapi_test_field_type', + ]; + + /** + * The entity type ID. + */ + protected string $entityTypeId = 'entity_test'; + + /** + * The entity bundle. + */ + protected string $bundle = 'entity_test'; + + /** + * The field name. + */ + protected string $fieldName = 'field_child'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + EntityTestHelper::createBundle($this->bundle, 'Parent', $this->entityTypeId); + + FieldStorageConfig::create([ + 'field_name' => $this->fieldName, + 'type' => 'jsonapi_test_field_type_entity_reference_uuid', + 'entity_type' => $this->entityTypeId, + 'cardinality' => 1, + 'settings' => [ + 'target_type' => $this->entityTypeId, + ], + ])->save(); + FieldConfig::create([ + 'field_name' => $this->fieldName, + 'entity_type' => $this->entityTypeId, + 'bundle' => $this->bundle, + 'label' => $this->randomString(), + 'settings' => [ + 'handler' => 'default', + 'handler_settings' => [], + ], + ])->save(); + + \Drupal::service('router.builder')->rebuild(); + } + + /** + * Test relationships without target_id as main property. + * + * @see https://www.drupal.org/project/drupal/issues/3476224 + */ + public function testPatchHandleUUIDPropertyReferenceFieldIssue3127883(): void { + $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); + $user = $this->drupalCreateUser([ + 'administer entity_test content', + 'view test entity', + ]); + + // Create parent and child entities. + $storage = $this->container->get('entity_type.manager') + ->getStorage($this->entityTypeId); + $parentEntity = $storage + ->create([ + 'type' => $this->bundle, + ]); + $parentEntity->save(); + $childUuid = $this->container->get('uuid')->generate(); + $childEntity = $storage + ->create([ + 'type' => $this->bundle, + 'uuid' => $childUuid, + ]); + $childEntity->save(); + $uuid = $childEntity->uuid(); + $this->assertEquals($childUuid, $uuid); + + // 1. Successful PATCH to the related endpoint. + $url = Url::fromUri(sprintf('internal:/jsonapi/%s/%s/%s/relationships/%s', $this->entityTypeId, $this->bundle, $parentEntity->uuid(), $this->fieldName)); + $request_options = [ + RequestOptions::HEADERS => [ + 'Content-Type' => 'application/vnd.api+json', + 'Accept' => 'application/vnd.api+json', + ], + RequestOptions::AUTH => [$user->getAccountName(), $user->pass_raw], + RequestOptions::JSON => [ + 'data' => [ + 'id' => $childUuid, + 'type' => sprintf('%s--%s', $this->entityTypeId, $this->bundle), + ], + ], + ]; + $response = $this->request('PATCH', $url, $request_options); + $this->assertSame(204, $response->getStatusCode(), (string) $response->getBody()); + $parentEntity = $storage->loadUnchanged($parentEntity->id()); + $this->assertEquals($childEntity->uuid(), $parentEntity->get($this->fieldName)->target_uuid); + + // Reset the relationship. + $parentEntity->set($this->fieldName, NULL) + ->save(); + $parentEntity = $storage->loadUnchanged($parentEntity->id()); + $this->assertTrue($parentEntity->get($this->fieldName)->isEmpty()); + + // 2. Successful PATCH to individual endpoint. + $url = Url::fromUri(sprintf('internal:/jsonapi/%s/%s/%s', $this->entityTypeId, $this->bundle, $parentEntity->uuid())); + $request_options[RequestOptions::JSON] = [ + 'data' => [ + 'id' => $parentEntity->uuid(), + 'type' => sprintf('%s--%s', $this->entityTypeId, $this->bundle), + 'relationships' => [ + $this->fieldName => [ + 'data' => [ + [ + 'id' => $childUuid, + 'type' => sprintf('%s--%s', $this->entityTypeId, $this->bundle), + ], + ], + ], + ], + ], + ]; + $response = $this->request('PATCH', $url, $request_options); + $this->assertSame(200, $response->getStatusCode(), (string) $response->getBody()); + $parentEntity = $storage->loadUnchanged($parentEntity->id()); + $this->assertEquals($childEntity->uuid(), $parentEntity->get($this->fieldName)->target_uuid); + } + +} diff --git a/core/modules/jsonapi/tests/src/Functional/WorkspaceTest.php b/core/modules/jsonapi/tests/src/Functional/WorkspaceTest.php index 97eec557d22d..48cbc20067a9 100644 --- a/core/modules/jsonapi/tests/src/Functional/WorkspaceTest.php +++ b/core/modules/jsonapi/tests/src/Functional/WorkspaceTest.php @@ -15,6 +15,7 @@ use Drupal\workspaces\Entity\Workspace; * JSON:API integration test for the "Workspace" content entity type. * * @group jsonapi + * @group workspaces */ class WorkspaceTest extends ResourceTestBase { @@ -142,7 +143,7 @@ class WorkspaceTest extends ResourceTestBase { 'changed' => (new \DateTime())->setTimestamp($this->entity->getChangedTime())->setTimezone(new \DateTimeZone('UTC'))->format(\DateTime::RFC3339), 'label' => 'Campaign', 'drupal_internal__id' => 'campaign', - 'drupal_internal__revision_id' => 2, + 'drupal_internal__revision_id' => 1, ], 'relationships' => [ 'parent' => [ |