first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,354 @@
<?php
/**
* @file
* Documentation related to JSON:API.
*/
use Drupal\Core\Access\AccessResult;
/**
* @defgroup jsonapi_architecture JSON:API Architecture
* @{
*
* @section overview Overview
* The JSON:API module is a Drupal-centric implementation of the JSON:API
* specification. By its own definition, the JSON:API specification "is a
* specification for how a client should request that resources be fetched or
* modified, and how a server should respond to those requests. [It] is designed
* to minimize both the number of requests and the amount of data transmitted
* between clients and servers. This efficiency is achieved without compromising
* readability, flexibility, or discoverability."
*
* While "Drupal-centric", the JSON:API module is committed to strict compliance
* with the specification. Wherever possible, the module attempts to implement
* the specification in a way which is compatible and familiar with the patterns
* and concepts inherent to Drupal. However, when "Drupalisms" cannot be
* reconciled with the specification, the module will always choose the
* implementation most faithful to the specification.
*
* @see http://jsonapi.org/
*
* @section resources Resources
* Every unit of data in the specification is a "resource". The specification
* defines how a client should interact with a server to fetch and manipulate
* these resources.
*
* The JSON:API module maps every entity type + bundle to a resource type.
* Since the specification does not have a concept of resource type inheritance
* or composition, the JSON:API module implements different bundles of the same
* entity type as *distinct* resource types.
*
* While it is theoretically possible to expose arbitrary data as resources, the
* JSON:API module only exposes resources from (config and content) entities.
* This eliminates the need for another abstraction layer in order implement
* certain features of the specification.
*
* @section relationships Relationships
* The specification defines semantics for the "relationships" between
* resources. Since the JSON:API module defines every entity type + bundle as a
* resource type and does not allow non-entity resources, it is able to use
* entity references to automatically define and represent the relationships
* between all resources.
*
* @section revisions Resource versioning
* The JSON:API module exposes entity revisions in a manner inspired by RFC5829:
* Link Relation Types for Simple Version Navigation between Web Resources.
*
* Revision support is not an official part of the JSON:API specification.
* However, a number of "profiles" are being developed (also not officially part
* in the spec, but already committed to JSON:API v1.1) to standardize any
* custom behaviors that the JSON:API module has developed (all of which are
* still specification-compliant).
*
* @see https://github.com/json-api/json-api/pull/1268
* @see https://github.com/json-api/json-api/pull/1311
* @see https://www.drupal.org/project/drupal/issues/2955020
*
* By implementing revision support as a profile, the JSON:API module should be
* maximally compatible with other systems.
*
* A "version" in the JSON:API module is any revision that was previously, or is
* currently, a default revision. Not all revisions are considered to be a
* "version". Revisions that are not marked as a "default" revision are
* considered "working copies" since they are not usually publicly available
* and are the revisions to which most new work is applied.
*
* When the Content Moderation module is installed, it is possible that the
* most recent default revision is *not* the latest revision.
*
* Requesting a resource version is done via a URL query parameter. It has the
* following form:
*
* @code
* version-identifier
* __|__
* / \
* ?resourceVersion=foo:bar
* \_/ \_/
* | |
* version-negotiator |
* version-argument
* @endcode
*
* A version identifier is a string with enough information to load a
* particular revision. The version negotiator component names the negotiation
* mechanism for loading a revision. Currently, this can be either `id` or
* `rel`. The `id` negotiator takes a version argument which is the desired
* revision ID. The `rel` negotiator takes a version argument which is either
* the string `latest-version` or the string `working-copy`.
*
* In the future, other negotiators may be developed, such as negotiators that
* are UUID-, timestamp-, or workspace-based.
*
* To illustrate how a particular entity revision is requested, imagine a node
* that has a "Published" revision and a subsequent "Draft" revision.
*
* Using JSON:API, one could request the "Published" node by requesting
* `/jsonapi/node/page/{{uuid}}?resourceVersion=rel:latest-version`.
*
* To preview an entity that is still a work-in-progress (i.e. the "Draft"
* revision) one could request
* `/jsonapi/node/page/{{uuid}}?resourceVersion=rel:working-copy`.
*
* To request a specific revision ID, one can request
* `/jsonapi/node/page/{{uuid}}?resourceVersion=id:{{revision_id}}`.
*
* It is not yet possible to request a collection of revisions. This is still
* under development in issue [#3009588].
*
* @see https://www.drupal.org/project/drupal/issues/3009588.
* @see https://tools.ietf.org/html/rfc5829
* @see https://www.drupal.org/docs/8/modules/jsonapi/revisions
*
* @section translations Resource translations
*
* Some multilingual features currently do not work well with JSON:API. See
* JSON:API modules' multilingual support documentation online for more
* information on the current status of multilingual support.
*
* @see https://www.drupal.org/docs/8/modules/jsonapi/translations
*
* @section api API
* The JSON:API module provides an HTTP API that adheres to the JSON:API
* specification.
*
* The JSON:API module provides *no PHP API to modify its behavior.* It is
* designed to have zero configuration.
*
* - Adding new resources/resource types is unsupported: all entities/entity
* types are exposed automatically. If you want to expose more data via the
* JSON:API module, the data must be defined as entity. See the "Resources"
* section.
* - Custom field type normalization is not supported because the JSON:API
* specification requires specific representations for resources (entities),
* attributes on resources (non-entity reference fields) and relationships
* between those resources (entity reference fields). A field contains
* properties, and properties are of a certain data type. All non-internal
* properties on a field are normalized.
* - The same data type normalizers as those used by core's Serialization and
* REST modules are also used by the JSON:API module.
* - All available authentication mechanisms are allowed.
*
* @section tests Test Coverage
* The JSON:API module comes with extensive unit and kernel tests. But most
* importantly for end users, it also has comprehensive integration tests. These
* integration tests are designed to:
*
* - ensure a great DX (Developer Experience)
* - detect regressions and normalization changes before shipping a release
* - guarantee 100% of Drupal core's entity types work as expected
*
* The integration tests test the same common cases and edge cases using
* \Drupal\Tests\jsonapi\Functional\ResourceTestBase, which is a base class
* subclassed for every entity type that Drupal core ships with. It is ensured
* that 100% of Drupal core's entity types are tested thanks to
* \Drupal\Tests\jsonapi\Functional\TestCoverageTest.
*
* Custom entity type developers can get the same assurances by subclassing it
* for their entity types.
*
* @section bc Backwards Compatibility
* PHP API: there is no PHP API except for three security-related hooks. This
* means that this module's implementation details are entirely free to
* change at any time.
*
* Note that *normalizers are internal implementation details.* While
* normalizers are services, they are *not* to be used directly. This is due to
* the design of the Symfony Serialization component, not because the JSON:API
* module wanted to publicly expose services.
*
* HTTP API: URLs and JSON response structures are considered part of this
* module's public API. However, inconsistencies with the JSON:API specification
* will be considered bugs. Fixes which bring the module into compliance with
* the specification are *not* guaranteed to be backwards-compatible. When
* compliance bugs are found, clients are expected to be made compatible with
* both the pre-fix and post-fix representations.
*
* What this means for developing consumers of the HTTP API is that *clients
* should be implemented from the specification first and foremost.* This should
* mitigate implicit dependencies on implementation details or inconsistencies
* with the specification that are specific to this module.
*
* To help develop compatible clients, every response indicates the version of
* the JSON:API specification used under its "jsonapi" key. Future releases
* *may* increment the minor version number if the module implements features of
* a later specification. Remember that the specification stipulates that future
* versions *will* remain backwards-compatible as only additions may be
* released.
*
* @see http://jsonapi.org/faq/#what-is-the-meaning-of-json-apis-version
*
* Tests: subclasses of base test classes may contain BC breaks between minor
* releases, to allow minor releases to A) comply better with the JSON:API spec,
* B) guarantee that all resource types (and therefore entity types) function as
* expected, C) update to future versions of the JSON:API spec.
*
* @}
*/
/**
* @addtogroup hooks
* @{
*/
/**
* Controls access when filtering by entity data via JSON:API.
*
* This module supports filtering by resource object attributes referenced by
* relationship fields. For example, a site may add a "Favorite Animal" field
* to user entities, which would permit the following filtered query:
* @code
* /jsonapi/node/article?filter[uid.field_favorite_animal]=llama
* @endcode
* This query would return articles authored by users whose favorite animal is a
* llama. However, the information about a user's favorite animal should not be
* available to users without the "access user profiles" permission. The same
* must hold true even if that user is referenced as an article's author.
* Therefore, access to filter by this data must be restricted so that access
* cannot be bypassed via a JSON:API filtered query.
*
* As a rule, clients should only be able to filter by data that they can
* view.
*
* Conventionally, `$entity->access('view')` is how entity access is checked.
* This call invokes the corresponding hooks. However, these access checks
* require an `$entity` object. This means that they cannot be called prior to
* executing a database query.
*
* In order to safely enable filtering across a relationship, modules
* responsible for entity access must do two things:
* - Implement this hook (or hook_jsonapi_ENTITY_TYPE_filter_access()) and
* return an array of AccessResults keyed by the named entity subsets below.
* - If the AccessResult::allowed() returned by the above hook does not provide
* enough granularity (for example, if access depends on a bundle field value
* of the entity being queried), then hook_query_TAG_alter() must be
* implemented using the 'entity_access' or 'ENTITY_TYPE_access' query tag.
* See node_query_node_access_alter() for an example.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type of the entity to be filtered upon.
* @param \Drupal\Core\Session\AccountInterface $account
* The account for which to check access.
*
* @return \Drupal\Core\Access\AccessResultInterface[]
* An array keyed by a constant which identifies a subset of entities. For
* each subset, the value is one of the following access results:
* - AccessResult::allowed() if all entities within the subset (potentially
* narrowed by hook_query_TAG_alter() implementations) are viewable.
* - AccessResult::forbidden() if any entity within the subset is not
* 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
* user for whom access is being checked.
* See the documentation of the above constants for more information about
* each subset.
*
* @see hook_jsonapi_ENTITY_TYPE_filter_access()
*/
function hook_jsonapi_entity_filter_access(\Drupal\Core\Entity\EntityTypeInterface $entity_type, \Drupal\Core\Session\AccountInterface $account) {
// For every entity type that has an admin permission, allow access to filter
// 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),
]);
}
}
/**
* Controls access to filtering by entity data via JSON:API.
*
* This is the entity-type-specific variant of
* hook_jsonapi_entity_filter_access(). For implementations with logic that is
* specific to a single entity type, it is recommended to implement this hook
* rather than the generic hook_jsonapi_entity_filter_access() hook, which is
* called for every entity type.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type of the entities to be filtered upon.
* @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.
*
* @see hook_jsonapi_entity_filter_access()
*/
function hook_jsonapi_ENTITY_TYPE_filter_access(\Drupal\Core\Entity\EntityTypeInterface $entity_type, \Drupal\Core\Session\AccountInterface $account) {
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'),
]);
}
/**
* Restricts filtering access to the given field.
*
* Some fields may contain sensitive information. In these cases, modules are
* supposed to implement hook_entity_field_access(). However, this hook receives
* an optional `$items` argument and often must return AccessResult::neutral()
* when `$items === NULL`. This is because access may or may not be allowed
* based on the field items or based on the entity on which the field is
* attached (if the user is the entity owner, for example).
*
* Since JSON:API must check field access prior to having a field item list
* instance available (access must be checked before a database query is made),
* it is not sufficiently secure to check field 'view' access alone.
*
* This hook exists so that modules which cannot return
* AccessResult::forbidden() from hook_entity_field_access() can still secure
* JSON:API requests where necessary.
*
* If a corresponding implementation of hook_entity_field_access() *can* be
* forbidden for one or more values of the `$items` argument, this hook *MUST*
* return AccessResult::forbidden().
*
* @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
* The field definition of the field to be filtered upon.
* @param \Drupal\Core\Session\AccountInterface $account
* The account for which to check access.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
function hook_jsonapi_entity_field_filter_access(\Drupal\Core\Field\FieldDefinitionInterface $field_definition, \Drupal\Core\Session\AccountInterface $account) {
if ($field_definition->getTargetEntityTypeId() === 'node' && $field_definition->getName() === 'field_sensitive_data') {
$has_sufficient_access = FALSE;
foreach (['administer nodes', 'view all sensitive field data'] as $permission) {
$has_sufficient_access = $has_sufficient_access ?: $account->hasPermission($permission);
}
return AccessResult::forbiddenIf(!$has_sufficient_access)->cachePerPermissions();
}
return AccessResult::neutral();
}
/**
* @} End of "addtogroup hooks".
*/

View File

@@ -0,0 +1,14 @@
name: JSON:API
type: module
description: Exposes entities as a JSON:API-specification-compliant web API.
package: Web services
# version: VERSION
configure: jsonapi.settings
dependencies:
- drupal:serialization
- drupal:file
# Information added by Drupal.org packaging script on 2024-07-04
version: '10.3.1'
project: 'drupal'
datestamp: 1720094222

View File

@@ -0,0 +1,84 @@
<?php
/**
* @file
* Module install file.
*/
use Drupal\Core\Url;
/**
* Implements hook_install().
*/
function jsonapi_install() {
$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) {
\Drupal::messenger()->addWarning(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',
]));
}
}
/**
* Implements hook_requirements().
*/
function jsonapi_requirements($phase) {
$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() {
return 9401;
}

View File

@@ -0,0 +1,5 @@
jsonapi.settings:
title: 'JSON:API'
parent: system.admin_config_services
description: "Configure whether to allow only read operations or all operations."
route_name: jsonapi.settings

View File

@@ -0,0 +1,4 @@
jsonapi.settings:
route_name: jsonapi.settings
base_route: jsonapi.settings
title: 'Settings'

View File

@@ -0,0 +1,319 @@
<?php
/**
* @file
* Module implementation file.
*/
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\jsonapi\Routing\Routes as JsonApiRoutes;
/**
* 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 JSONAPI_FILTER_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 JSONAPI_FILTER_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 JSONAPI_FILTER_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 JSONAPI_FILTER_AMONG_OWN = 'filter_among_own';
/**
* Implements hook_help().
*/
function jsonapi_help($route_name, RouteMatchInterface $route_match) {
switch ($route_name) {
case 'help.page.jsonapi':
$output = '<h2>' . t('About') . '</h2>';
$output .= '<p>' . t('The JSON:API module is a fully compliant implementation of the <a href=":spec">JSON:API Specification</a>. By following shared conventions, you can increase productivity, take advantage of generalized tooling, and focus on what matters: your application. Clients built around JSON:API are able to take advantage of features like efficient response caching, which can sometimes eliminate network requests entirely. For more information, see the <a href=":docs">online documentation for the JSON:API module</a>.', [
':spec' => 'https://jsonapi.org',
':docs' => 'https://www.drupal.org/docs/8/modules/json-api',
]) . '</p>';
$output .= '<dl>';
$output .= '<dt>' . t('General') . '</dt>';
$output .= '<dd>' . t('JSON:API is a particular implementation of REST that provides conventions for resource relationships, collections, filters, pagination, and sorting. These conventions help developers build clients faster and encourages reuse of code.') . '</dd>';
$output .= '<dd>' . t('The <a href=":jsonapi-docs">JSON:API</a> and <a href=":rest-docs">RESTful Web Services</a> modules serve similar purposes. <a href=":comparison">Read the comparison of the RESTFul Web Services and JSON:API modules</a> to determine the best choice for your site.', [
':jsonapi-docs' => 'https://www.drupal.org/docs/8/modules/json-api',
':rest-docs' => 'https://www.drupal.org/docs/8/core/modules/rest',
':comparison' => 'https://www.drupal.org/docs/8/modules/jsonapi/jsonapi-vs-cores-rest-module',
]) . '</dd>';
$output .= '<dd>' . 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',
]) . '</dd>';
$output .= '<dd>' . 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',
]) . '</dd>';
$output .= '</dl>';
return $output;
}
return NULL;
}
/**
* Implements hook_modules_installed().
*/
function jsonapi_modules_installed($modules) {
$potential_conflicts = [
'content_translation',
'config_translation',
'language',
];
if (!empty(array_intersect($modules, $potential_conflicts))) {
\Drupal::messenger()->addWarning(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',
]));
}
}
/**
* Implements hook_entity_bundle_create().
*/
function jsonapi_entity_bundle_create() {
JsonApiRoutes::rebuild();
}
/**
* Implements hook_entity_bundle_delete().
*/
function jsonapi_entity_bundle_delete() {
JsonApiRoutes::rebuild();
}
/**
* Implements hook_entity_create().
*/
function jsonapi_entity_create(EntityInterface $entity) {
if (in_array($entity->getEntityTypeId(), ['field_storage_config', 'field_config'])) {
// @todo Only do this when relationship fields are updated, not just any field.
JsonApiRoutes::rebuild();
}
}
/**
* Implements hook_entity_delete().
*/
function jsonapi_entity_delete(EntityInterface $entity) {
if (in_array($entity->getEntityTypeId(), ['field_storage_config', 'field_config'])) {
// @todo Only do this when relationship fields are updated, not just any field.
JsonApiRoutes::rebuild();
}
}
/**
* Implements hook_jsonapi_entity_filter_access().
*/
function jsonapi_jsonapi_entity_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
// All core entity types and most or all contrib entity types allow users
// with the entity type's administrative permission to view all of the
// entities, so enable similarly permissive filtering to those users as well.
// A contrib module may override this decision by returning
// AccessResult::forbidden() from its implementation of this hook.
if ($admin_permission = $entity_type->getAdminPermission()) {
return ([
JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, $admin_permission),
]);
}
}
/**
* Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'block_content'.
*/
function jsonapi_jsonapi_block_content_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
// @see \Drupal\block_content\BlockContentAccessControlHandler::checkAccess()
// \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(),
]);
}
/**
* Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'comment'.
*/
function jsonapi_jsonapi_comment_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
// @see \Drupal\comment\CommentAccessControlHandler::checkAccess()
// \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'),
]);
}
/**
* Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'entity_test'.
*/
function jsonapi_jsonapi_entity_test_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
// @see \Drupal\entity_test\EntityTestAccessControlHandler::checkAccess()
return ([
JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'view test entity'),
]);
}
/**
* Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'file'.
*/
function jsonapi_jsonapi_file_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
// @see \Drupal\file\FileAccessControlHandler::checkAccess()
// \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'),
]);
}
/**
* Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'media'.
*/
function jsonapi_jsonapi_media_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
// @see \Drupal\media\MediaAccessControlHandler::checkAccess()
return ([
JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'view media'),
]);
}
/**
* Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'node'.
*/
function jsonapi_jsonapi_node_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
// @see \Drupal\node\NodeAccessControlHandler::access()
if ($account->hasPermission('bypass node access')) {
return ([
JSONAPI_FILTER_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,
// 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,
]);
}
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(),
]);
}
/**
* Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'shortcut'.
*/
function jsonapi_jsonapi_shortcut_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
// @see \Drupal\shortcut\ShortcutAccessControlHandler::checkAccess()
// \Drupal\jsonapi\Access\TemporaryQueryGuard adds the condition for
// (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, ['access shortcuts', 'customize shortcut links'])),
]);
}
/**
* Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'taxonomy_term'.
*/
function jsonapi_jsonapi_taxonomy_term_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
// @see \Drupal\taxonomy\TermAccessControlHandler::checkAccess()
return ([
JSONAPI_FILTER_AMONG_ALL => AccessResult::allowedIfHasPermission($account, 'administer taxonomy'),
JSONAPI_FILTER_AMONG_PUBLISHED => AccessResult::allowedIfHasPermission($account, 'access content'),
]);
}
/**
* Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'user'.
*/
function jsonapi_jsonapi_user_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
// @see \Drupal\user\UserAccessControlHandler::checkAccess()
// \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'),
]);
}
/**
* Implements hook_jsonapi_ENTITY_TYPE_filter_access() for 'workspace'.
*/
function jsonapi_jsonapi_workspace_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
// @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'),
]);
}

View File

@@ -0,0 +1,10 @@
route_callbacks:
- '\Drupal\jsonapi\Routing\Routes::routes'
jsonapi.settings:
path: '/admin/config/services/jsonapi'
defaults:
_form: '\Drupal\jsonapi\Form\JsonApiSettingsForm'
_title: 'JSON:API'
requirements:
_permission: 'administer site configuration'

View File

@@ -0,0 +1,253 @@
parameters:
jsonapi.base_path: /jsonapi
services:
_defaults:
autoconfigure: true
jsonapi.serializer:
class: Drupal\jsonapi\Serializer\Serializer
calls:
- [setFallbackNormalizer, ['@serializer']]
arguments: [{ }, { }]
serializer.normalizer.http_exception.jsonapi:
class: Drupal\jsonapi\Normalizer\HttpExceptionNormalizer
arguments: ['@current_user']
tags:
- { name: jsonapi_normalizer }
serializer.normalizer.unprocessable_entity_exception.jsonapi:
class: Drupal\jsonapi\Normalizer\UnprocessableHttpEntityExceptionNormalizer
arguments: ['@current_user']
tags:
# This must have a higher priority than the 'serializer.normalizer.http_exception.jsonapi' to take effect.
- { name: jsonapi_normalizer, priority: 1 }
serializer.normalizer.entity_access_exception.jsonapi:
class: Drupal\jsonapi\Normalizer\EntityAccessDeniedHttpExceptionNormalizer
arguments: ['@current_user']
tags:
# This must have a higher priority than the 'serializer.normalizer.http_exception.jsonapi' to take effect.
- { name: jsonapi_normalizer, priority: 1 }
serializer.normalizer.field_item.jsonapi:
class: Drupal\jsonapi\Normalizer\FieldItemNormalizer
arguments: ['@entity_type.manager']
tags:
- { name: jsonapi_normalizer }
serializer.normalizer.field.jsonapi:
class: Drupal\jsonapi\Normalizer\FieldNormalizer
tags:
- { name: jsonapi_normalizer }
serializer.normalizer.resource_identifier.jsonapi:
class: Drupal\jsonapi\Normalizer\ResourceIdentifierNormalizer
arguments: ['@entity_field.manager']
tags:
- { name: jsonapi_normalizer }
serializer.normalizer.data.jsonapi:
class: Drupal\jsonapi\Normalizer\DataNormalizer
tags:
- { name: jsonapi_normalizer }
serializer.normalizer.resource_object.jsonapi:
class: Drupal\jsonapi\Normalizer\ResourceObjectNormalizer
arguments: ['@jsonapi.normalization_cacher']
tags:
- { name: jsonapi_normalizer }
jsonapi.normalization_cacher:
class: Drupal\jsonapi\EventSubscriber\ResourceObjectNormalizationCacher
calls:
- ['setVariationCache', ['@variation_cache.jsonapi_normalizations']]
- ['setRequestStack', ['@request_stack']]
serializer.normalizer.content_entity.jsonapi:
class: Drupal\jsonapi\Normalizer\ContentEntityDenormalizer
arguments: ['@entity_type.manager', '@entity_field.manager', '@plugin.manager.field.field_type']
tags:
- { name: jsonapi_normalizer }
serializer.normalizer.config_entity.jsonapi:
class: Drupal\jsonapi\Normalizer\ConfigEntityDenormalizer
arguments: ['@entity_type.manager', '@entity_field.manager', '@plugin.manager.field.field_type']
tags:
- { name: jsonapi_normalizer }
serializer.normalizer.jsonapi_document_toplevel.jsonapi:
class: Drupal\jsonapi\Normalizer\JsonApiDocumentTopLevelNormalizer
arguments: ['@entity_type.manager', '@jsonapi.resource_type.repository']
tags:
- { name: jsonapi_normalizer }
serializer.normalizer.link_collection.jsonapi:
class: Drupal\jsonapi\Normalizer\LinkCollectionNormalizer
arguments: ['@current_user']
tags:
- { name: jsonapi_normalizer }
serializer.normalizer.relationship.jsonapi:
class: Drupal\jsonapi\Normalizer\RelationshipNormalizer
tags:
- { name: jsonapi_normalizer }
serializer.encoder.jsonapi:
class: Drupal\jsonapi\Encoder\JsonEncoder
tags:
- { name: jsonapi_encoder, format: 'api_json' }
jsonapi.resource_type.repository:
class: Drupal\jsonapi\ResourceType\ResourceTypeRepository
arguments: ['@entity_type.manager', '@entity_type.bundle.info', '@entity_field.manager', '@cache.jsonapi_resource_types', '@event_dispatcher']
Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface: '@jsonapi.resource_type.repository'
jsonapi.route_enhancer:
class: Drupal\jsonapi\Routing\RouteEnhancer
tags:
- { name: route_enhancer }
jsonapi.field_resolver:
class: Drupal\jsonapi\Context\FieldResolver
arguments: ['@entity_type.manager', '@entity_field.manager', '@entity_type.bundle.info', '@jsonapi.resource_type.repository', '@module_handler', '@current_user']
Drupal\jsonapi\Context\FieldResolver: '@jsonapi.field_resolver'
jsonapi.include_resolver:
class: Drupal\jsonapi\IncludeResolver
arguments:
- '@entity_type.manager'
- '@jsonapi.entity_access_checker'
Drupal\jsonapi\IncludeResolver: '@jsonapi.include_resolver'
paramconverter.jsonapi.entity_uuid:
parent: paramconverter.entity
class: Drupal\jsonapi\ParamConverter\EntityUuidConverter
calls:
- [setLanguageManager, ['@language_manager']]
tags:
# Priority 10, to ensure it runs before @paramconverter.entity.
- { name: paramconverter, priority: 10 }
paramconverter.jsonapi.resource_type:
class: Drupal\jsonapi\ParamConverter\ResourceTypeConverter
arguments: ['@jsonapi.resource_type.repository']
tags:
- { name: paramconverter }
jsonapi.exception_subscriber:
class: Drupal\jsonapi\EventSubscriber\DefaultExceptionSubscriber
arguments: ['@jsonapi.serializer', '%serializer.formats%']
logger.channel.jsonapi:
parent: logger.channel_base
arguments: ['jsonapi']
# Cache.
cache.jsonapi_memory:
class: Drupal\Core\Cache\MemoryCache\MemoryCacheInterface
tags:
- { name: cache.bin.memory, default_backend: cache.backend.memory.memory }
factory: ['@cache_factory', 'get']
arguments: [jsonapi_memory]
# A chained cache with an in-memory cache as the first layer and a database-
# backed cache as the fallback is used. The first layer (memory) is necessary
# because ResourceType value objects are retrieved many times during a
# request. The second layer (by default a database) is necessary to avoid
# recomputing the ResourceType value objects on every request.
cache.jsonapi_resource_types:
class: Drupal\Core\Cache\BackendChain
calls:
- [appendBackend, ['@cache.jsonapi_memory']]
- [appendBackend, ['@cache.default']]
tags: [{ name: cache.bin.memory }]
cache.jsonapi_normalizations:
class: Drupal\Core\Cache\CacheBackendInterface
tags:
- { name: cache.bin }
factory: ['@cache_factory', 'get']
arguments: [jsonapi_normalizations]
variation_cache.jsonapi_normalizations:
class: Drupal\Core\Cache\VariationCacheInterface
factory: ['@variation_cache_factory', 'get']
arguments: [jsonapi_normalizations]
# Route filter.
jsonapi.route_filter.format_setter:
class: Drupal\jsonapi\Routing\EarlyFormatSetter
tags:
# Set to a high priority so it runs before content_type_header_matcher
# and other filters that might throw exceptions.
- { name: route_filter, priority: 100 }
# Access Control
jsonapi.entity_access_checker:
class: Drupal\jsonapi\Access\EntityAccessChecker
public: false
arguments: ['@jsonapi.resource_type.repository', '@router.no_access_checks', '@current_user', '@entity.repository']
calls:
# This is a temporary measure. JSON:API should not need to be aware of the Content Moderation module.
- [setLatestRevisionCheck, ['@?access_check.latest_revision']] # This is only injected when the service is available.
Drupal\jsonapi\Access\EntityAccessChecker: '@jsonapi.entity_access_checker'
access_check.jsonapi.relationship_route_access:
class: Drupal\jsonapi\Access\RelationshipRouteAccessCheck
arguments: ['@jsonapi.entity_access_checker']
tags:
- { name: access_check, applies_to: _jsonapi_relationship_route_access }
# Route filters.
method_filter.jsonapi:
public: false
class: Drupal\jsonapi\Routing\ReadOnlyModeMethodFilter
decorates: method_filter
arguments: ['@method_filter.jsonapi.inner', '@config.factory']
# Controller.
jsonapi.entity_resource:
class: Drupal\jsonapi\Controller\EntityResource
arguments:
- '@entity_type.manager'
- '@entity_field.manager'
- '@jsonapi.resource_type.repository'
- '@renderer'
- '@entity.repository'
- '@jsonapi.include_resolver'
- '@jsonapi.entity_access_checker'
- '@jsonapi.field_resolver'
- '@jsonapi.serializer'
- '@datetime.time'
- '@current_user'
Drupal\jsonapi\Controller\EntityResource: '@jsonapi.entity_resource'
jsonapi.file_upload:
class: Drupal\jsonapi\Controller\FileUpload
arguments:
- '@current_user'
- '@entity_field.manager'
- '@file.upload_handler'
- '@http_kernel'
- '@file.input_stream_file_writer'
- '@file_system'
Drupal\jsonapi\Controller\FileUpload: '@jsonapi.file_upload'
# Event subscribers.
jsonapi.custom_query_parameter_names_validator.subscriber:
class: Drupal\jsonapi\EventSubscriber\JsonApiRequestValidator
jsonapi.resource_response.subscriber:
class: Drupal\jsonapi\EventSubscriber\ResourceResponseSubscriber
arguments: ['@jsonapi.serializer']
jsonapi.resource_response_validator.subscriber:
class: Drupal\jsonapi\EventSubscriber\ResourceResponseValidator
arguments: ['@logger.channel.jsonapi', '@module_handler', '%app.root%']
calls:
- [setValidator, []]
jsonapi.maintenance_mode_subscriber:
class: Drupal\jsonapi\EventSubscriber\JsonapiMaintenanceModeSubscriber
arguments: ['@maintenance_mode', '@config.factory']
# Revision management.
jsonapi.version_negotiator:
class: Drupal\jsonapi\Revisions\VersionNegotiator
public: false
tags:
- { name: service_collector, tag: jsonapi_version_negotiator, call: addVersionNegotiator }
Drupal\jsonapi\Revisions\VersionNegotiator: '@jsonapi.version_negotiator'
jsonapi.version_negotiator.default:
arguments: ['@entity_type.manager']
public: false
abstract: true
jsonapi.version_negotiator.id:
class: Drupal\jsonapi\Revisions\VersionById
parent: jsonapi.version_negotiator.default
tags:
- { name: jsonapi_version_negotiator, negotiator_name: 'id' }
jsonapi.version_negotiator.rel:
class: Drupal\jsonapi\Revisions\VersionByRel
parent: jsonapi.version_negotiator.default
tags:
- { name: jsonapi_version_negotiator, negotiator_name: 'rel' }
jsonapi.resource_version.route_enhancer:
class: Drupal\jsonapi\Revisions\ResourceVersionRouteEnhancer
public: false
arguments:
- '@jsonapi.version_negotiator'
tags:
- { name: route_enhancer }

382
core/modules/jsonapi/schema.json Executable file
View File

@@ -0,0 +1,382 @@
{
"$schema": "http://json-schema.org/draft-06/schema#",
"title": "JSON:API Schema",
"description": "This is a schema for responses in the JSON:API format. For more, see http://jsonapi.org",
"oneOf": [
{
"$ref": "#/definitions/success"
},
{
"$ref": "#/definitions/failure"
},
{
"$ref": "#/definitions/info"
}
],
"definitions": {
"success": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"$ref": "#/definitions/data"
},
"included": {
"description": "To reduce the number of HTTP requests, servers **MAY** allow responses that include related resources along with the requested primary resources. Such responses are called \"compound documents\".",
"type": "array",
"items": {
"$ref": "#/definitions/resource"
},
"uniqueItems": true
},
"meta": {
"$ref": "#/definitions/meta"
},
"links": {
"description": "Link members related to the primary data.",
"allOf": [
{
"$ref": "#/definitions/links"
},
{
"$ref": "#/definitions/pagination"
}
]
},
"jsonapi": {
"$ref": "#/definitions/jsonapi"
}
},
"additionalProperties": false
},
"failure": {
"type": "object",
"required": [
"errors"
],
"properties": {
"errors": {
"type": "array",
"items": {
"$ref": "#/definitions/error"
},
"uniqueItems": true
},
"meta": {
"$ref": "#/definitions/meta"
},
"jsonapi": {
"$ref": "#/definitions/jsonapi"
},
"links": {
"$ref": "#/definitions/links"
}
},
"additionalProperties": false
},
"info": {
"type": "object",
"required": [
"meta"
],
"properties": {
"meta": {
"$ref": "#/definitions/meta"
},
"links": {
"$ref": "#/definitions/links"
},
"jsonapi": {
"$ref": "#/definitions/jsonapi"
}
},
"additionalProperties": false
},
"meta": {
"description": "Non-standard meta-information that can not be represented as an attribute or relationship.",
"type": "object",
"additionalProperties": true
},
"data": {
"description": "The document's \"primary data\" is a representation of the resource or collection of resources targeted by a request.",
"oneOf": [
{
"$ref": "#/definitions/resource"
},
{
"description": "An array of resource objects, an array of resource identifier objects, or an empty array ([]), for requests that target resource collections.",
"type": "array",
"items": {
"$ref": "#/definitions/resource"
},
"uniqueItems": true
},
{
"description": "null if the request is one that might correspond to a single resource, but doesn't currently.",
"type": "null"
}
]
},
"resource": {
"description": "\"Resource objects\" appear in a JSON:API document to represent resources.",
"type": "object",
"required": [
"type",
"id"
],
"properties": {
"type": {
"type": "string"
},
"id": {
"type": "string"
},
"attributes": {
"$ref": "#/definitions/attributes"
},
"relationships": {
"$ref": "#/definitions/relationships"
},
"links": {
"$ref": "#/definitions/links"
},
"meta": {
"$ref": "#/definitions/meta"
}
},
"additionalProperties": false
},
"relationshipLinks": {
"description": "A resource object **MAY** contain references to other resource objects (\"relationships\"). Relationships may be to-one or to-many. Relationships can be specified by including a member in a resource's links object.",
"type": "object",
"properties": {
"self": {
"description": "A `self` member, whose value is a URL for the relationship itself (a \"relationship URL\"). This URL allows the client to directly manipulate the relationship. For example, it would allow a client to remove an `author` from an `article` without deleting the people resource itself.",
"$ref": "#/definitions/link"
},
"related": {
"$ref": "#/definitions/link"
}
},
"additionalProperties": true
},
"links": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/link"
}
},
"link": {
"description": "A link **MUST** be represented as either: a string containing the link's URL or a link object.",
"oneOf": [
{
"description": "A string containing the link's URL.",
"type": "string",
"format": "uri-reference"
},
{
"type": "object",
"required": [
"href"
],
"properties": {
"href": {
"description": "A string containing the link's URL.",
"type": "string",
"format": "uri-reference"
},
"meta": {
"$ref": "#/definitions/meta"
}
}
}
]
},
"attributes": {
"description": "Members of the attributes object (\"attributes\") represent information about the resource object in which it's defined.",
"type": "object",
"patternProperties": {
"^(?!relationships$|links$|id$|type$)\\w[-\\w_]*$": {
"description": "Attributes may contain any valid JSON value."
}
},
"additionalProperties": false
},
"relationships": {
"description": "Members of the relationships object (\"relationships\") represent references from the resource object in which it's defined to other resource objects.",
"type": "object",
"patternProperties": {
"^(?!id$|type$)\\w[-\\w_]*$": {
"properties": {
"links": {
"$ref": "#/definitions/relationshipLinks"
},
"data": {
"description": "Member, whose value represents \"resource linkage\".",
"oneOf": [
{
"$ref": "#/definitions/relationshipToOne"
},
{
"$ref": "#/definitions/relationshipToMany"
}
]
},
"meta": {
"$ref": "#/definitions/meta"
}
},
"anyOf": [
{"required": ["data"]},
{"required": ["meta"]},
{"required": ["links"]}
],
"additionalProperties": false
}
},
"additionalProperties": false
},
"relationshipToOne": {
"description": "References to other resource objects in a to-one (\"relationship\"). Relationships can be specified by including a member in a resource's links object.",
"anyOf": [
{
"$ref": "#/definitions/empty"
},
{
"$ref": "#/definitions/linkage"
}
]
},
"relationshipToMany": {
"description": "An array of objects each containing \"type\" and \"id\" members for to-many relationships.",
"type": "array",
"items": {
"$ref": "#/definitions/linkage"
},
"uniqueItems": true
},
"empty": {
"description": "Describes an empty to-one relationship.",
"type": "null"
},
"linkage": {
"description": "The \"type\" and \"id\" to non-empty members.",
"type": "object",
"required": [
"type",
"id"
],
"properties": {
"type": {
"type": "string"
},
"id": {
"type": "string"
},
"meta": {
"$ref": "#/definitions/meta"
}
},
"additionalProperties": false
},
"pagination": {
"type": "object",
"properties": {
"first": {
"description": "The first page of data",
"oneOf": [
{ "$ref": "#/definitions/link" },
{ "type": "null" }
]
},
"last": {
"description": "The last page of data",
"oneOf": [
{ "$ref": "#/definitions/link" },
{ "type": "null" }
]
},
"prev": {
"description": "The previous page of data",
"oneOf": [
{ "$ref": "#/definitions/link" },
{ "type": "null" }
]
},
"next": {
"description": "The next page of data",
"oneOf": [
{ "$ref": "#/definitions/link" },
{ "type": "null" }
]
}
}
},
"jsonapi": {
"description": "An object describing the server's implementation",
"type": "object",
"properties": {
"version": {
"type": "string"
},
"meta": {
"$ref": "#/definitions/meta"
}
},
"additionalProperties": false
},
"error": {
"type": "object",
"properties": {
"id": {
"description": "A unique identifier for this particular occurrence of the problem.",
"type": "string"
},
"links": {
"$ref": "#/definitions/links"
},
"status": {
"description": "The HTTP status code applicable to this problem, expressed as a string value.",
"type": "string"
},
"code": {
"description": "An application-specific error code, expressed as a string value.",
"type": "string"
},
"title": {
"description": "A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.",
"type": "string"
},
"detail": {
"description": "A human-readable explanation specific to this occurrence of the problem.",
"type": "string"
},
"source": {
"type": "object",
"properties": {
"pointer": {
"description": "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].",
"type": "string"
},
"parameter": {
"description": "A string indicating which query parameter caused the error.",
"type": "string"
}
}
},
"meta": {
"$ref": "#/definitions/meta"
}
},
"additionalProperties": false
}
}
}

View File

@@ -0,0 +1,215 @@
<?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 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/drupal/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 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 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.
*/
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();
$access = $entity->access('view all revisions', $account, TRUE);
// 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;
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace Drupal\jsonapi\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultReasonInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\jsonapi\ResourceType\ResourceType;
use Drupal\jsonapi\Routing\Routes;
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/drupal/issues/3032787
* @see jsonapi.api.php
*/
final class RelationshipRouteAccessCheck implements AccessInterface {
/**
* The route requirement key for this access check.
*
* @var string
*/
const ROUTE_REQUIREMENT_KEY = '_jsonapi_relationship_route_access';
/**
* The JSON:API entity access checker.
*
* @var \Drupal\jsonapi\Access\EntityAccessChecker
*/
protected $entityAccessChecker;
/**
* RelationshipRouteAccessCheck 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\Routing\Route $route
* The route to check against.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
* @param \Drupal\Core\Session\AccountInterface $account
* The currently logged in account.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(Route $route, RouteMatchInterface $route_match, ?AccountInterface $account = NULL) {
[$relationship_field_name, $field_operation] = explode('.', $route->getRequirement(static::ROUTE_REQUIREMENT_KEY));
assert(in_array($field_operation, ['view', 'edit'], TRUE));
$entity_operation = $field_operation === 'view' ? 'view' : 'update';
if ($resource_type = $route_match->getParameter(Routes::RESOURCE_TYPE_KEY)) {
assert($resource_type instanceof ResourceType);
$entity = $route_match->getParameter('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);
}
return $access_result;
}
}
return AccessResult::neutral();
}
}

View File

@@ -0,0 +1,602 @@
<?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/drupal/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);
[$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')) {
$shortcut_set_storage = \Drupal::entityTypeManager()->getStorage('shortcut_set');
$specific_condition = new EntityCondition('shortcut_set', $shortcut_set_storage->getDisplayedToUser($current_user)->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) {
static::$moduleHandler->invokeAllWith(
$hook,
function (callable $hook, string $module) use (&$combined_access_results, $entity_type, $account) {
$module_access_results = $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 = $merged[$field_name] ?? [];
$merged[$field_name] = array_merge($previous, [$parts]);
}
}
return !empty($merged) ? array_map([static::class, __FUNCTION__], $merged) : $merged;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Drupal\jsonapi;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\Core\Cache\CacheableResponseTrait;
/**
* Extends ResourceResponse with cacheability.
*
* We want to have the same functionality for both responses that are cacheable
* and those that are not. This response class should be used in all instances
* where the response is expected to be cacheable.
*
* @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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class CacheableResourceResponse extends ResourceResponse implements CacheableResponseInterface {
use CacheableResponseTrait;
}

View File

@@ -0,0 +1,770 @@
<?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;
use Drupal\Core\Session\AccountInterface;
/**
* 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/drupal/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;
/**
* The current user account.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* 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.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user account.
*/
public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $field_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, ResourceTypeRepositoryInterface $resource_type_repository, ModuleHandlerInterface $module_handler, AccountInterface $current_user) {
$this->currentUser = $current_user;
$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.
* @param string $operator
* (optional) The operator of the condition for which the path should be
* resolved.
*
* @return string
* The mapped field name.
*
* @throws \Drupal\Core\Http\Exception\CacheableBadRequestHttpException
*/
public function resolveInternalEntityQueryPath(ResourceType $resource_type, $external_field_name, $operator = NULL) {
$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 exposed in the JSON:API representation
// with a prefix.
$property_names[] = $is_data_reference_definition ? 'id' : $property_name;
}
if ($is_data_reference_definition) {
$at_least_one_entity_reference_field = TRUE;
$property_names[] = "drupal_internal__$property_name";
}
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 the operator is asserting the presence or absence of a
// relationship entirely, it does not make sense to require a property
// specifier.
if ($property_specifier_needed && (!$at_least_one_entity_reference_field || !in_array($operator, ['IS NULL', 'IS NOT NULL'], TRUE))) {
$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('.', array_map(function ($part) {
return str_replace('drupal_internal__', '', $part);
}, $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/drupal/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/drupal/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) {
$property_definitions = $definition->getPropertyDefinitions();
foreach ($property_definitions as $property_name => $property_definition) {
$property_name = $property_definition instanceof DataReferenceTargetDefinition
? "drupal_internal__$property_name"
: $property_name;
if ($part === $property_name) {
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 str_contains($part, ':') ? 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, $this->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

View File

@@ -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\CacheableResourceResponse;
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\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/drupal/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 CacheableResourceResponse(new JsonApiDocumentTopLevel(new ResourceObjectData([]), new NullIncludedData(), $urls, $meta));
return $response->addCacheableDependency($cacheability);
}
}

View File

@@ -0,0 +1,336 @@
<?php
namespace Drupal\jsonapi\Controller;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Core\Access\AccessResultReasonInterface;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\Exception\FileExistsException;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Lock\LockAcquiringException;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\file\Upload\ContentDispositionFilenameParser;
use Drupal\file\Upload\FileUploadHandler;
use Drupal\file\Upload\FileUploadLocationTrait;
use Drupal\file\Upload\FileUploadResult;
use Drupal\file\Upload\InputStreamFileWriterInterface;
use Drupal\file\Upload\InputStreamUploadedFile;
use Drupal\file\Validation\FileValidatorSettingsTrait;
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\File\Exception\CannotWriteFileException;
use Symfony\Component\HttpFoundation\File\Exception\NoFileException;
use Symfony\Component\HttpFoundation\File\Exception\UploadException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class FileUpload {
use DeprecatedServicePropertyTrait;
use EntityValidationTrait;
use FileUploadLocationTrait;
use FileValidatorSettingsTrait;
/**
* {@inheritdoc}
*/
protected array $deprecatedProperties = [
'fileUploader' => 'jsonapi.file.uploader.field',
];
/**
* Constructs a new FileUpload object.
*
* @phpstan-ignore-next-line
*/
public function __construct(
protected AccountInterface $currentUser,
protected EntityFieldManagerInterface $fieldManager,
protected FileUploadHandler | TemporaryJsonapiFileFieldUploader $fileUploadHandler,
protected HttpKernelInterface $httpKernel,
protected ?InputStreamFileWriterInterface $inputStreamFileWriter = NULL,
protected ?FileSystemInterface $fileSystem = NULL,
) {
if (!$this->fileUploadHandler instanceof FileUploadHandler) {
@trigger_error('Calling ' . __METHOD__ . '() without the $fileUploadHandler argument being an instance of ' . FileUploadHandler::class . ' is deprecated in drupal:10.3.0 and it will be required in drupal:11.0.0. See https://www.drupal.org/node/3445266', E_USER_DEPRECATED);
$this->fileUploadHandler = \Drupal::service('file.upload.handler');
}
if (!$this->inputStreamFileWriter) {
@trigger_error('Calling ' . __METHOD__ . '() without the $inputStreamFileWriter argument is deprecated in drupal:10.3.0 and it will be required in drupal:11.0.0. See https://www.drupal.org/node/3445266', E_USER_DEPRECATED);
$this->inputStreamFileWriter = \Drupal::service('file.input_stream_file_writer');
}
if (!$this->fileSystem) {
@trigger_error('Calling ' . __METHOD__ . '() without the $fileSystem argument is deprecated in drupal:10.3.0 and it will be required in drupal:11.0.0. See https://www.drupal.org/node/3445266', E_USER_DEPRECATED);
$this->fileSystem = \Drupal::service('file_system');
}
}
/**
* 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, string $file_field_name, FieldableEntityInterface $entity): Response {
$result = $this->handleFileUploadForResource($request, $resource_type, $file_field_name, $entity);
$file = $result->getFile();
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(), $resource_type->getPublicName($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, string $file_field_name): ResourceResponse {
$result = $this->handleFileUploadForResource($request, $resource_type, $file_field_name);
$file = $result->getFile();
// @todo Remove line below in favor of commented line in https://www.drupal.org/project/drupal/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($resource_type->getPublicName($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, []);
}
/**
* 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|null $entity
* (optional) The entity for which the file is to be uploaded.
*
* @return \Drupal\file\Upload\FileUploadResult
* The file upload result.
*/
protected function handleFileUploadForResource(Request $request, ResourceType $resource_type, string $file_field_name, ?FieldableEntityInterface $entity = NULL): FileUploadResult {
$file_field_name = $resource_type->getInternalName($file_field_name);
$field_definition = $this->validateAndLoadFieldDefinition($resource_type->getEntityTypeId(), $resource_type->getBundle(), $file_field_name);
static::ensureFileUploadAccess($this->currentUser, $field_definition, $entity);
$filename = ContentDispositionFilenameParser::parseFilename($request);
$tempPath = $this->inputStreamFileWriter->writeStreamToFile();
$uploadedFile = new InputStreamUploadedFile($filename, $filename, $tempPath, @filesize($tempPath));
$settings = $field_definition->getSettings();
$validators = $this->getFileUploadValidators($settings);
if (!array_key_exists('FileExtension', $validators) && $settings['file_extensions'] === '') {
// An empty string means 'all file extensions' but the FileUploadHandler
// needs the FileExtension entry to be present and empty in order for this
// to be respected. An empty array means 'all file extensions'.
// @see \Drupal\file\Upload\FileUploadHandler::handleExtensionValidation
$validators['FileExtension'] = [];
}
$destination = $this->getUploadLocation($field_definition);
// 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');
}
try {
$result = $this->fileUploadHandler->handleFileUpload($uploadedFile, $validators, $destination, FileExists::Rename, FALSE);
}
catch (LockAcquiringException $e) {
throw new HttpException(503, $e->getMessage(), NULL, ['Retry-After' => 1]);
}
catch (UploadException $e) {
throw new HttpException(500, 'Input file data could not be read', $e);
}
catch (CannotWriteFileException $e) {
throw new HttpException(500, 'Temporary file data could not be written', $e);
}
catch (NoFileException $e) {
throw new HttpException(500, 'Temporary file could not be opened', $e);
}
catch (FileExistsException $e) {
throw new HttpException(500, $e->getMessage(), $e);
}
catch (FileException $e) {
throw new HttpException(500, 'Temporary file could not be moved to file location');
}
if ($result->hasViolations()) {
$message = "Unprocessable Entity: file validation failed.\n";
$message .= implode("\n", array_map(function (ConstraintViolationInterface $violation) {
return PlainTextOutput::renderFromHtml($violation->getMessage());
}, (array) $result->getViolations()->getIterator()));
throw new UnprocessableEntityHttpException($message);
}
return $result;
}
/**
* 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
* entity the file is attached to 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() &&
// Base fields do not have target bundles.
(is_null($field_definition->getTargetBundle()) || $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);
}
/**
* 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
? static::checkFileUploadAccess($account, $field_definition, $entity)
: static::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;
}
}

View File

@@ -0,0 +1,470 @@
<?php
namespace Drupal\jsonapi\Controller;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\File\Event\FileUploadSanitizeNameEvent;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Lock\LockBackendInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Utility\Token;
use Drupal\file\Entity\File;
use Drupal\file\FileInterface;
use Drupal\file\Plugin\Field\FieldType\FileFieldItemList;
use Drupal\file\Upload\ContentDispositionFilenameParser;
use Drupal\file\Upload\FileUploadLocationTrait;
use Drupal\file\Upload\InputStreamFileWriterInterface;
use Drupal\file\Validation\FileValidatorInterface;
use Drupal\file\Validation\FileValidatorSettingsTrait;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\Exception\CannotWriteFileException;
use Symfony\Component\HttpFoundation\File\Exception\NoFileException;
use Symfony\Component\HttpFoundation\File\Exception\UploadException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* 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.
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. There is no
* replacement.
*
* @see https://www.drupal.org/node/3445266
*/
class TemporaryJsonapiFileFieldUploader {
use FileValidatorSettingsTrait;
use FileUploadLocationTrait {
getUploadLocation as getUploadDestination;
}
/**
* The regex used to extract the filename from the content disposition header.
*
* @var string
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
* \Drupal\file\Upload\ContentDispositionFilenameParser::REQUEST_HEADER_FILENAME_REGEX
* instead.
*
* @see https://www.drupal.org/node/3380380
*/
const REQUEST_HEADER_FILENAME_REGEX = '@\bfilename(?<star>\*?)=\"(?<filename>.+)\"@';
/**
* The amount of bytes to read in each iteration when streaming file data.
*
* @var int
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
* \Drupal\file\Upload\InputStreamFileWriterInterface::DEFAULT_BYTES_TO_READ
* instead.
*
* @see https://www.drupal.org/node/3380607
*/
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\Mime\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;
/**
* The event dispatcher.
*
* @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* The file validator.
*
* @var \Drupal\file\Validation\FileValidatorInterface
*/
protected FileValidatorInterface $fileValidator;
/**
* The input stream file writer.
*/
protected InputStreamFileWriterInterface $inputStreamFileWriter;
/**
* 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\Mime\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.
* @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface|null $event_dispatcher
* (optional) The event dispatcher.
* @param \Drupal\file\Validation\FileValidatorInterface|null $file_validator
* The file validator.
* @param \Drupal\file\Upload\InputStreamFileWriterInterface|null $input_stream_file_writer
* The stream file uploader.
*/
public function __construct(LoggerInterface $logger, FileSystemInterface $file_system, $mime_type_guesser, Token $token, LockBackendInterface $lock, ConfigFactoryInterface $config_factory, ?EventDispatcherInterface $event_dispatcher = NULL, ?FileValidatorInterface $file_validator = NULL, ?InputStreamFileWriterInterface $input_stream_file_writer = NULL) {
@\trigger_error(__CLASS__ . ' is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. There is no replacement. See https://www.drupal.org/node/3445266', E_USER_DEPRECATED);
$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');
if (!$event_dispatcher) {
$event_dispatcher = \Drupal::service('event_dispatcher');
}
$this->eventDispatcher = $event_dispatcher;
if (!$file_validator) {
@trigger_error('Calling ' . __METHOD__ . '() without the $file_validator argument is deprecated in drupal:10.2.0 and is required in drupal:11.0.0. See https://www.drupal.org/node/3363700', E_USER_DEPRECATED);
$file_validator = \Drupal::service('file.validator');
}
$this->fileValidator = $file_validator;
if (!$input_stream_file_writer) {
@trigger_error('Calling ' . __METHOD__ . '() without the $input_stream_file_writer argument is deprecated in drupal:10.3.0 and is required in drupal:11.0.0. See https://www.drupal.org/node/3380607', E_USER_DEPRECATED);
$input_stream_file_writer = \Drupal::service('file.input_stream_file_writer');
}
$this->inputStreamFileWriter = $input_stream_file_writer;
}
/**
* 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));
$settings = $field_definition->getSettings();
$destination = $this->getUploadDestination($field_definition);
// 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->getFileUploadValidators($field_definition->getSettings());
$prepared_filename = $this->prepareFilename($filename, $validators);
// Create the file.
$file_uri = "{$destination}/{$prepared_filename}";
if ($destination === $settings['uri_scheme'] . '://') {
$file_uri = "{$destination}{$prepared_filename}";
}
$temp_file_path = $this->streamUploadData();
$file_uri = $this->fileSystem->getDestinationFilename($file_uri, FileExists::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.', $file_uri), NULL, ['Retry-After' => 1]);
}
// Begin building file entity.
$file = File::create([]);
$file->setOwnerId($owner->id());
$file->setFilename($prepared_filename);
$file->setMimeType($this->mimeTypeGuesser->guessMimeType($prepared_filename));
$file->setFileUri($temp_file_path);
// 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 against field-level validators first while the file is
// still a temporary file. Validation is split up in 2 steps to be the same
// as in \Drupal\file\Upload\FileUploadHandler::handleFileUpload().
// For backwards compatibility this part is copied from ::validate() to
// leave that method behavior unchanged.
// @todo Improve this with a file uploader service in
// https://www.drupal.org/project/drupal/issues/2940383
$violations = $this->fileValidator->validate($file, $validators);
if (count($violations) > 0) {
return $violations;
}
$file->setFileUri($file_uri);
// Update the filename with any changes as a result of security or renaming
// due to an existing file.
// @todo Remove this duplication by replacing with FileUploadHandler. See
// https://www.drupal.org/project/drupal/issues/3401734
$file->setFilename($this->fileSystem->basename($file->getFileUri()));
// Move the file to the correct location after validation. Use
// FileExists::Error as the file location has already been
// determined above in FileSystem::getDestinationFilename().
try {
$this->fileSystem->move($temp_file_path, $file_uri, FileExists::Error);
}
catch (FileException $e) {
throw new HttpException(500, 'Temporary file could not be moved to file location');
}
// Second step of the validation on the file object itself now.
$violations = $file->validate();
// Remove violations of inaccessible fields as they cannot stem from our
// changes.
$violations->filterByFieldAccess();
if ($violations->count() > 0) {
return $violations;
}
$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.
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
* \Drupal\file\Upload\ContentDispositionFilenameParser::parseFilename()
* instead.
*
* @see https://www.drupal.org/node/3380380
*/
public function validateAndParseContentDispositionHeader(Request $request) {
@trigger_error('Calling ' . __METHOD__ . '() is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use \Drupal\file\Upload\ContentDispositionFilenameParser::parseFilename() instead. See https://www.drupal.org/node/3380380', E_USER_DEPRECATED);
return ContentDispositionFilenameParser::parseFilename($request);
}
/**
* 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
* entity the file is attached to 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() &&
// Base fields do not have target bundles.
(is_null($field_definition->getTargetBundle()) || $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() {
// Catch and throw the exceptions that JSON API module expects.
try {
$temp_file_path = $this->inputStreamFileWriter->writeStreamToFile();
}
catch (UploadException $e) {
$this->logger->error('Input data could not be read');
throw new HttpException(500, 'Input file data could not be read', $e);
}
catch (CannotWriteFileException $e) {
$this->logger->error('Temporary file data for could not be written');
throw new HttpException(500, 'Temporary file data could not be written', $e);
}
catch (NoFileException $e) {
$this->logger->error('Temporary file could not be opened for file upload');
throw new HttpException(500, 'Temporary file could not be opened', $e);
}
return $temp_file_path;
}
/**
* Validates the file.
*
* @todo this method is unused in this class because file validation needs to
* be split up in 2 steps in ::handleFileUploadForField(). Add a deprecation
* notice as soon as a central core file upload service can be used in this
* class. See https://www.drupal.org/project/drupal/issues/2940383
*
* @param \Drupal\file\FileInterface $file
* The file entity to validate.
* @param array $validators
* An array of upload validators to pass to FileValidator.
*
* @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.
$violations->addAll($this->fileValidator->validate($file, $validators));
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) {
// The actual extension validation occurs in
// \Drupal\jsonapi\Controller\TemporaryJsonapiFileFieldUploader::validate().
$extensions = $validators['FileExtension']['extensions'] ?? '';
$event = new FileUploadSanitizeNameEvent($filename, $extensions);
$this->eventDispatcher->dispatch($event);
return $event->getFilename();
}
/**
* 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.
*
* @deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use
* \Drupal\file\Upload\FileUploadLocationTrait::getUploadLocation() instead.
*
* @see https://www.drupal.org/node/3406099
*/
protected function getUploadLocation(array $settings) {
@\trigger_error(__METHOD__ . ' is deprecated in drupal:10.3.0 and is removed from drupal:11.0.0. Use \Drupal\file\Upload\FileUploadLocationTrait::getUploadLocation() instead. See https://www.drupal.org/node/3406099', E_USER_DEPRECATED);
$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;
}
/**
* 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);
}
}

View File

@@ -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/drupal/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 = $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 = $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));
}
}
}

View File

@@ -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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class JsonEncoder extends SerializationJsonEncoder {
/**
* The formats that this Encoder supports.
*
* @var string
*/
protected static $format = ['api_json'];
}

View File

@@ -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/drupal/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;
}
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Drupal\jsonapi\EventSubscriber;
use Drupal\jsonapi\CacheableResourceResponse;
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\ExceptionEvent;
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/drupal/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(ExceptionEvent $event) {
if (!$this->isJsonApiExceptionEvent($event)) {
return;
}
if (($exception = $event->getThrowable()) && !$exception instanceof HttpException) {
$exception = new HttpException(500, $exception->getMessage(), $exception);
$event->setThrowable($exception);
}
$this->setEventResponse($event, $exception->getStatusCode());
}
/**
* {@inheritdoc}
*/
protected function setEventResponse(ExceptionEvent $event, $status) {
/** @var \Symfony\Component\HttpKernel\Exception\HttpException $exception */
$exception = $event->getThrowable();
$document = new JsonApiDocumentTopLevel(new ErrorCollection([$exception]), new NullIncludedData(), new LinkCollection([]));
if ($event->getRequest()->isMethodCacheable()) {
$response = new CacheableResourceResponse($document, $exception->getStatusCode(), $exception->getHeaders());
$response->addCacheableDependency($exception);
}
else {
$response = new ResourceResponse($document, $exception->getStatusCode(), $exception->getHeaders());
}
$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\ExceptionEvent $exception_event
* The exception event.
*
* @return bool
* TRUE if it needs to be formatted using JSON:API. FALSE otherwise.
*/
protected function isJsonApiExceptionEvent(ExceptionEvent $exception_event) {
$request = $exception_event->getRequest();
$parameters = $request->attributes->all();
return $request->getRequestFormat() === 'api_json' || (bool) Routes::getResourceTypeNameFromParameters($parameters);
}
}

View File

@@ -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\RequestEvent;
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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class JsonApiRequestValidator implements EventSubscriberInterface {
/**
* Validates JSON:API requests.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The event to process.
*/
public function onRequest(RequestEvent $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(): array {
$events[KernelEvents::REQUEST][] = ['onRequest'];
return $events;
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Drupal\jsonapi\EventSubscriber;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Site\MaintenanceModeEvents;
use Drupal\Core\Site\MaintenanceModeInterface;
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 Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Maintenance mode subscriber for JSON:API 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.
*/
class JsonapiMaintenanceModeSubscriber implements EventSubscriberInterface {
/**
* The maintenance mode.
*
* @var \Drupal\Core\Site\MaintenanceMode
*/
protected $maintenanceMode;
/**
* The configuration factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $config;
/**
* Constructs a new JsonapiMaintenanceModeSubscriber.
*
* @param \Drupal\Core\Site\MaintenanceModeInterface $maintenance_mode
* The maintenance mode.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
*/
public function __construct(MaintenanceModeInterface $maintenance_mode, ConfigFactoryInterface $config_factory) {
$this->maintenanceMode = $maintenance_mode;
$this->config = $config_factory;
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
$events = [];
$events[MaintenanceModeEvents::MAINTENANCE_MODE_REQUEST][] = [
'onMaintenanceModeRequest',
-800,
];
return $events;
}
/**
* Returns response when site is in maintenance mode and user is not exempt.
*
* @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
* The event to process.
*/
public function onMaintenanceModeRequest(RequestEvent $event) {
$request = $event->getRequest();
if ($request->getRequestFormat() !== 'api_json') {
return;
}
// Retry-After will be random within a range defined in jsonapi settings.
// The goals are to keep it short and to reduce the thundering herd problem.
$header_settings = $this->config->get('jsonapi.settings')->get('maintenance_header_retry_seconds');
$retry_after_time = rand($header_settings['min'], $header_settings['max']);
$http_exception = new HttpException(503, $this->maintenanceMode->getSiteMaintenanceMessage());
$document = new JsonApiDocumentTopLevel(new ErrorCollection([$http_exception]), new NullIncludedData(), new LinkCollection([]));
$response = new ResourceResponse($document, $http_exception->getStatusCode(), [
'Content-Type' => 'application/vnd.api+json',
'Retry-After' => $retry_after_time,
]);
// Calling RequestEvent::setResponse() also stops propagation of event.
$event->setResponse($response);
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace Drupal\jsonapi\EventSubscriber;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\VariationCacheInterface;
use Drupal\jsonapi\JsonApiResource\ResourceObject;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Caches entity normalizations after the response has been sent.
*
* @internal
* @see \Drupal\jsonapi\Normalizer\ResourceObjectNormalizer::getNormalization()
*/
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 variation cache.
*
* @var \Drupal\Core\Cache\VariationCacheInterface
*/
protected $variationCache;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* The things to cache after the response has been sent.
*
* @var array
*/
protected $toCache = [];
/**
* Sets the variation cache.
*
* @param \Drupal\Core\Cache\VariationCacheInterface $variation_cache
* The variation cache.
*/
public function setVariationCache(VariationCacheInterface $variation_cache) {
$this->variationCache = $variation_cache;
}
/**
* Sets the request stack.
*
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack.
*/
public function setRequestStack(RequestStack $request_stack) {
$this->requestStack = $request_stack;
}
/**
* 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) {
// @todo Investigate whether to cache POST and PATCH requests.
// @todo Follow up on https://www.drupal.org/project/drupal/issues/3381898.
if (!$this->requestStack->getCurrentRequest()->isMethodCacheable()) {
return FALSE;
}
$cached = $this->variationCache->get($this->generateCacheKeys($object), new CacheableMetadata());
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\TerminateEvent $event
* The Event to process.
*/
public function onTerminate(TerminateEvent $event) {
foreach ($this->toCache as $value) {
[$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.
*/
protected function set(ResourceObject $object, array $normalization_parts) {
// @todo Investigate whether to cache POST and PATCH requests.
// @todo Follow up on https://www.drupal.org/project/drupal/issues/3381898.
if (!$this->requestStack->getCurrentRequest()->isMethodCacheable()) {
return;
}
// Merge the entity's cacheability metadata with that of the normalization
// parts, so that VariationCache can take care of cache redirects for us.
$cacheability = CacheableMetadata::createFromObject($object)
->merge(static::mergeCacheableDependencies($normalization_parts[static::RESOURCE_CACHE_SUBSET_BASE]))
->merge(static::mergeCacheableDependencies($normalization_parts[static::RESOURCE_CACHE_SUBSET_FIELDS]));
$this->variationCache->set($this->generateCacheKeys($object), $normalization_parts, $cacheability, new CacheableMetadata());
}
/**
* Generates the cache keys for a normalization.
*
* @param \Drupal\jsonapi\JsonApiResource\ResourceObject $object
* The resource object for which to generate the cache keys.
*
* @return string[]
* The cache keys to pass to the variation cache.
*
* @see \Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber::$dynamicPageCacheRedirectRenderArray
*/
protected static function generateCacheKeys(ResourceObject $object) {
return [$object->getResourceType()->getTypeName(), $object->getId(), $object->getLanguage()->getId()];
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
$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;
}
}

View File

@@ -0,0 +1,188 @@
<?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\ResponseEvent;
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/drupal/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(): array {
// 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\ResponseEvent $event
* The event to process.
*/
public function onResponse(ResponseEvent $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.
if ($response instanceof CacheableResponseInterface) {
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->has('fields')) {
$context['sparse_fieldset'] = array_map(function ($item) {
return explode(',', $item);
}, $request->query->all('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;
}
}

View File

@@ -0,0 +1,171 @@
<?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\ResponseEvent;
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/drupal/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(): array {
$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\ResponseEvent $event
* The event to process.
*/
public function onResponse(ResponseEvent $event) {
$response = $event->getResponse();
if (!str_contains($response->headers->get('Content-Type', ''), 'application/vnd.api+json')) {
return;
}
// Wraps validation in an assert to prevent execution in production.
assert($this->validateResponse($response, $event->getRequest()), 'A JSON:API response failed validation (see the logs for details). Report this in the Drupal issue queue at https://www.drupal.org/project/issues/drupal');
}
/**
* 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;
}
}

View File

@@ -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/drupal/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/drupal/issues/2997594.
$this->resourceIdentifier = $entity ? ResourceIdentifier::fromEntity($entity) : NULL;
}
/**
* Returns the error.
*
* @return array
* The error.
*/
public function getError() {
return $this->error;
}
}

View File

@@ -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/drupal/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;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Drupal\jsonapi\Form;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\ConfigTarget;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\RedundantEditableConfigNamesTrait;
/**
* Configure JSON:API settings for this site.
*
* @internal
*/
class JsonApiSettingsForm extends ConfigFormBase {
use RedundantEditableConfigNamesTrait;
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'jsonapi_settings';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$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.'),
],
'#config_target' => new ConfigTarget(
'jsonapi.settings',
'read_only',
// Convert the bool config value to an expected string.
fn($value) => $value ? 'r' : 'rw',
// Convert the submitted value to a boolean before storing it in config.
fn($value) => $value === 'r',
),
'#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);
}
}

View File

@@ -0,0 +1,280 @@
<?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\EntityReferenceItemInterface;
use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
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/drupal/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);
$public_field_name = $resource_object->getResourceType()->getPublicName($field_name);
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, $public_field_name);
$includes = IncludedData::merge($includes, new IncludedData([$exception]));
continue;
}
elseif (!$resource_object instanceof ResourceObject) {
continue;
}
// 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;
}
if (is_subclass_of($field_list->getItemDefinition()->getClass(), EntityReferenceItemInterface::class)) {
foreach ($field_list as $field_item) {
if (!($field_item->getDataDefinition()->getPropertyDefinition('entity') instanceof DataReferenceDefinitionInterface)) {
continue;
}
if (!($field_item->entity instanceof EntityInterface)) {
continue;
}
// Support entity reference fields that don't have the referenced
// target type stored in settings.
$references[$field_item->entity->getEntityTypeId()][] = $field_item->get($field_item::mainPropertyName())->getValue();
}
}
else {
@trigger_error(
sprintf('Entity reference field items not implementing %s is deprecated in drupal:10.2.0 and will be required in drupal:11.0.0. See https://www.drupal.org/node/3279140', EntityReferenceItemInterface::class),
E_USER_DEPRECATED
);
$target_type = $field_list->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type');
if (!empty($target_type)) {
foreach ($field_list as $field_item) {
$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
* A 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
* A 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 = $merged[$field_name] ?? [];
$merged[$field_name] = array_merge($previous, [$parts]);
}
return !empty($merged) ? array_map([static::class, __FUNCTION__], $merged) : $merged;
}
}

View File

@@ -0,0 +1,181 @@
<?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/drupal/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
*/
#[\ReturnTypeWillChange]
public function getIterator() {
return new \ArrayIterator($this->data);
}
/**
* Returns the number of entities.
*
* @return int
* The number of parameters
*/
#[\ReturnTypeWillChange]
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 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));
}
}

View File

@@ -0,0 +1,55 @@
<?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/drupal/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
*/
#[\ReturnTypeWillChange]
public function getIterator() {
return new \ArrayIterator($this->errors);
}
}

View File

@@ -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/drupal/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);
}
}

View File

@@ -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/drupal/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;
}
}

View File

@@ -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/drupal/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]);
}
}

View File

@@ -0,0 +1,173 @@
<?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/drupal/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 type.
*
* @var string
*/
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, string $link_relation_type, array $target_attributes = []) {
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 $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) {
// 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(), $a->rel);
$b_string = sprintf('<%s>;rel="%s"', $b->getHref(), $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());
}
}

View File

@@ -0,0 +1,201 @@
<?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/drupal/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}
*/
#[\ReturnTypeWillChange]
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) && !str_contains($key, ':');
}
}

View File

@@ -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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class NullIncludedData extends IncludedData {
/**
* NullData constructor.
*/
public function __construct() {
parent::__construct([]);
}
}

View File

@@ -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/drupal/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);
}
}

View File

@@ -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/drupal/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 its 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;
}
}

View File

@@ -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/drupal/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);
}
}

View File

@@ -0,0 +1,464 @@
<?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/drupal/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/drupal/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);
$main_property_name = $item->getDataDefinition()->getMainPropertyName();
$meta = array_diff_key($properties, array_flip([$property_name, $main_property_name]));
if (!is_null($arity)) {
$meta[static::ARITY_KEY] = $arity;
}
$meta["drupal_internal__$main_property_name"] = $properties[$main_property_name];
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 \Drupal\jsonapi\JsonApiResource\ResourceIdentifier $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'));
}
}
}

View File

@@ -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/drupal/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();
}

View File

@@ -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/drupal/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;
}
}

View File

@@ -0,0 +1,385 @@
<?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\Language\Language;
use Drupal\Core\Language\LanguageInterface;
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/drupal/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;
/**
* The resource language.
*
* @var \Drupal\Core\Language\LanguageInterface
*/
protected $language;
/**
* 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.
* @param \Drupal\Core\Language\LanguageInterface|null $language
* (optional) The resource language.
*/
public function __construct(CacheableDependencyInterface $cacheability, ResourceType $resource_type, $id, $revision_id, array $fields, LinkCollection $links, ?LanguageInterface $language = NULL) {
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);
// If the specified language empty it falls back the same way as in the entity system
// @see \Drupal\Core\Entity\EntityBase::language()
$this->language = $language ?: new Language(['id' => LanguageInterface::LANGCODE_NOT_SPECIFIED]);
}
/**
* 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([])),
$entity->language()
);
}
/**
* 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 language.
*
* @return \Drupal\Core\Language\LanguageInterface
* The resource language.
*/
public function getLanguage(): LanguageInterface {
return $this->language;
}
/**
* 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;
}
}

View File

@@ -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/drupal/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;
}
}

View File

@@ -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/drupal/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);
}

View File

@@ -0,0 +1,146 @@
<?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/drupal/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}\-_ ]";
/**
* Regular expression to check the validity of a member name.
*/
const MEMBER_NAME_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';
/**
* 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) {
return preg_match(static::MEMBER_NAME_REGEXP, $member_name) === 1;
}
/**
* The reserved (official) query parameters.
*/
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 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 compliance 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;
}
}

View File

@@ -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/drupal/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());
}
}

View File

@@ -0,0 +1,48 @@
<?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/drupal/issues/3032787
* @see jsonapi.api.php
*/
final class ConfigEntityDenormalizer extends EntityDenormalizerBase {
/**
* {@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;
}
/**
* {@inheritdoc}
*/
public function hasCacheableSupportsMethod(): bool {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use getSupportedTypes() instead. See https://www.drupal.org/node/3359695', E_USER_DEPRECATED);
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
ConfigEntityInterface::class => TRUE,
];
}
}

View File

@@ -0,0 +1,104 @@
<?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/drupal/issues/3032787
* @see jsonapi.api.php
*/
final class ContentEntityDenormalizer extends EntityDenormalizerBase {
/**
* 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;
}
/**
* {@inheritdoc}
*/
public function hasCacheableSupportsMethod(): bool {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use getSupportedTypes() instead. See https://www.drupal.org/node/3359695', E_USER_DEPRECATED);
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
ContentEntityInterface::class => TRUE,
];
}
}

View File

@@ -0,0 +1,46 @@
<?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}
*/
public function normalize($object, $format = NULL, array $context = []): array|string|int|float|bool|\ArrayObject|NULL {
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);
}
/**
* {@inheritdoc}
*/
public function hasCacheableSupportsMethod(): bool {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use getSupportedTypes() instead. See https://www.drupal.org/node/3359695', E_USER_DEPRECATED);
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
Data::class => TRUE,
];
}
}

View File

@@ -0,0 +1,72 @@
<?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/drupal/issues/3032787
* @see jsonapi.api.php
*
* @see http://jsonapi.org/format/#error-objects
*/
class EntityAccessDeniedHttpExceptionNormalizer extends HttpExceptionNormalizer {
/**
* {@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 = $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;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
EntityAccessDeniedHttpException::class => TRUE,
];
}
}

View File

@@ -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/drupal/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, ?string $format = NULL, array $context = []): bool {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function normalize($object, $format = NULL, array $context = []): array|string|int|float|bool|\ArrayObject|NULL {
throw new \LogicException('This method should never be called.');
}
/**
* {@inheritdoc}
*/
public function denormalize($data, $class, $format = NULL, array $context = []): mixed {
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);
}

View File

@@ -0,0 +1,122 @@
<?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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class EntityReferenceFieldNormalizer extends FieldNormalizer {
/**
* {@inheritdoc}
*/
public function normalize($field, $format = NULL, array $context = []): array|string|int|float|bool|\ArrayObject|NULL {
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;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
EntityReferenceFieldItemListInterface::class => TRUE,
];
}
}

View File

@@ -0,0 +1,254 @@
<?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\DataDefinitionInterface;
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\Exception\UnexpectedValueException;
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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class FieldItemNormalizer extends NormalizerBase implements DenormalizerInterface {
use SerializedColumnNormalizerTrait;
/**
* 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 = []): array|string|int|float|bool|\ArrayObject|NULL {
assert($field_item instanceof FieldItemInterface);
/** @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.
$flatten = count($field_properties) === 1 && $field_item::mainPropertyName() !== NULL;
$values = static::rasterizeValueRecursive($flatten ? 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 = []): mixed {
$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)) {
$writable_properties = array_keys(array_filter($property_definitions, function (DataDefinitionInterface $data_definition) : bool {
return !$data_definition->isReadOnly();
}));
$invalid_property_names = [];
foreach ($data as $property_name => $property_value) {
if (!isset($property_definitions[$property_name])) {
$alt = static::getAlternatives($property_name, $writable_properties);
$invalid_property_names[$property_name] = reset($alt);
}
}
if (!empty($invalid_property_names)) {
$suggestions = array_values(array_filter($invalid_property_names));
// Only use the "Did you mean"-style error message if there is a
// suggestion for every invalid property name.
if (count($suggestions) === count($invalid_property_names)) {
$format = count($invalid_property_names) === 1
? "The property '%s' does not exist on the '%s' field of type '%s'. Did you mean '%s'?"
: "The properties '%s' do not exist on the '%s' field of type '%s'. Did you mean '%s'?";
throw new UnexpectedValueException(sprintf(
$format,
implode("', '", array_keys($invalid_property_names)),
$item_definition->getFieldDefinition()->getName(),
$item_definition->getFieldDefinition()->getType(),
implode("', '", $suggestions)
));
}
else {
$format = count($invalid_property_names) === 1
? "The property '%s' does not exist on the '%s' field of type '%s'. Writable properties are: '%s'."
: "The properties '%s' do not exist on the '%s' field of type '%s'. Writable properties are: '%s'.";
throw new UnexpectedValueException(sprintf(
$format,
implode("', '", array_keys($invalid_property_names)),
$item_definition->getFieldDefinition()->getName(),
$item_definition->getFieldDefinition()->getType(),
implode("', '", $writable_properties)
));
}
}
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;
}
/**
* Provides alternatives for a given array and key.
*
* @param string $search_key
* The search key to get alternatives for.
* @param array $keys
* The search space to search for alternatives in.
*
* @return string[]
* An array of strings with suitable alternatives.
*
* @see \Drupal\Component\DependencyInjection\Container::getAlternatives()
*/
private static function getAlternatives(string $search_key, array $keys) : array {
// $search_key is user input and could be longer than the 255 string length
// limit of levenshtein().
if (strlen($search_key) > 255) {
return [];
}
$alternatives = [];
foreach ($keys as $key) {
$lev = levenshtein($search_key, $key);
if ($lev <= strlen($search_key) / 3 || str_contains($key, $search_key)) {
$alternatives[] = $key;
}
}
return $alternatives;
}
/**
* 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;
}
/**
* {@inheritdoc}
*/
public function hasCacheableSupportsMethod(): bool {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use getSupportedTypes() instead. See https://www.drupal.org/node/3359695', E_USER_DEPRECATED);
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
FieldItemInterface::class => TRUE,
];
}
}

View File

@@ -0,0 +1,110 @@
<?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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class FieldNormalizer extends NormalizerBase implements DenormalizerInterface {
/**
* {@inheritdoc}
*/
public function normalize($field, $format = NULL, array $context = []): array|string|int|float|bool|\ArrayObject|NULL {
/** @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 = []): mixed {
$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\FieldItemNormalizer[]
* 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;
}
/**
* {@inheritdoc}
*/
public function hasCacheableSupportsMethod(): bool {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use getSupportedTypes() instead. See https://www.drupal.org/node/3359695', E_USER_DEPRECATED);
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
FieldItemListInterface::class => TRUE,
];
}
}

View File

@@ -0,0 +1,187 @@
<?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/drupal/issues/3032787
* @see jsonapi.api.php
*
* @see http://jsonapi.org/format/#error-objects
*/
class HttpExceptionNormalizer extends NormalizerBase {
/**
* 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 = []): array|string|int|float|bool|\ArrayObject|NULL {
$cacheability = new CacheableMetadata();
$cacheability->addCacheableDependency($object);
$cacheability->addCacheTags(['config:system.logging']);
if (\Drupal::config('system.logging')->get('error_level') === ERROR_REPORTING_DISPLAY_VERBOSE) {
$cacheability->setCacheMaxAge(0);
}
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();
}
$is_verbose_reporting = \Drupal::config('system.logging')->get('error_level') === ERROR_REPORTING_DISPLAY_VERBOSE;
$site_report_access = $this->currentUser->hasPermission('access site reports');
if ($site_report_access && $is_verbose_reporting) {
// 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/drupal/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];
}
/**
* {@inheritdoc}
*/
public function hasCacheableSupportsMethod(): bool {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use getSupportedTypes() instead. See https://www.drupal.org/node/3359695', E_USER_DEPRECATED);
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
HttpException::class => TRUE,
];
}
}

View File

@@ -0,0 +1,348 @@
<?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/drupal/issues/3032787
* @see jsonapi.api.php
*
* @see \Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel
*/
class JsonApiDocumentTopLevelNormalizer extends NormalizerBase implements DenormalizerInterface, NormalizerInterface {
/**
* 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 = []): mixed {
$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[] = array_filter($reference_item, function ($key) {
return !str_starts_with($key, 'drupal_internal__');
}, ARRAY_FILTER_USE_KEY);
}
return array_filter($canonical_ids);
}, $relationships);
// Add the relationship ids.
$normalized = array_merge($normalized, $relationships);
}
// Override deserialization target class with the one in the ResourceType.
$class = $context['resource_type']->getDeserializationTargetClass();
return $this
->serializer
->denormalize($normalized, $class, $format, $context);
}
/**
* {@inheritdoc}
*/
public function normalize($object, $format = NULL, array $context = []): array|string|int|float|bool|\ArrayObject|NULL {
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/drupal/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/drupal/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);
}
/**
* {@inheritdoc}
*/
public function hasCacheableSupportsMethod(): bool {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use getSupportedTypes() instead. See https://www.drupal.org/node/3359695', E_USER_DEPRECATED);
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
JsonApiDocumentTopLevel::class => TRUE,
];
}
}

View File

@@ -0,0 +1,166 @@
<?php
namespace Drupal\jsonapi\Normalizer;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Session\AccountInterface;
use Drupal\jsonapi\JsonApiResource\LinkCollection;
use Drupal\jsonapi\JsonApiResource\Link;
use Drupal\jsonapi\Normalizer\Value\CacheableNormalization;
use Drupal\jsonapi\Normalizer\Value\CacheableOmission;
/**
* 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. See this class's hashByHref() method for
* details.
*
* 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/drupal/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';
/**
* 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;
/**
* The current user making the request.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* LinkCollectionNormalizer 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 = []): array|string|int|float|bool|\ArrayObject|NULL {
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] : []);
// Checking access on links is not about access to the link itself;
// it is about whether the current user has access to the route that is
// *targeted* by the link. This is done on a "best effort" basis. That
// is, some links target routes that depend on a request to determine if
// they're accessible or not. Some other links might target routes to
// which the current user will clearly not have access, in that case
// this code proactively removes those links from the response.
$access = $link->getUri()->access($this->currentUser, TRUE);
$cacheability = CacheableMetadata::createFromObject($link)->addCacheableDependency($access);
$normalized[$link_key] = $access->isAllowed()
? new CacheableNormalization($cacheability, $normalization)
: new CacheableOmission($cacheability);
}
}
return CacheableNormalization::aggregate($normalized);
}
/**
* Hashes a link using its href and its target attributes, if any.
*
* This method generates an unpredictable, but deterministic, 7 character
* alphanumeric hash for a given link.
*
* The hash is unpredictable because a random hash salt will be used for every
* request. The hash is deterministic because, within a single request, links
* with the same href and target attributes (i.o.w. duplicates) will generate
* equivalent hash values.
*
* @param \Drupal\jsonapi\JsonApiResource\Link $link
* A link to be hashed.
*
* @return string
* A 7 character alphanumeric hash.
*/
protected function hashByHref(Link $link) {
// Generate a salt unique to each instance of this class.
if (!$this->hashSalt) {
$this->hashSalt = Crypt::randomBytesBase64();
}
// Create a dictionary of link parameters.
$link_parameters = [
'href' => $link->getHref(),
] + $link->getTargetAttributes();
// Serialize the dictionary into a string.
foreach ($link_parameters as $name => $value) {
$serialized_parameters[] = sprintf('%s="%s"', $name, implode(' ', (array) $value));
}
// Hash the string.
$b64_hash = Crypt::hashBase64($this->hashSalt . implode('; ', $serialized_parameters));
// Remove any dashes and underscores from the base64 hash and then return
// the first 7 characters.
return substr(str_replace(['-', '_'], '', $b64_hash), 0, 7);
}
/**
* {@inheritdoc}
*/
public function hasCacheableSupportsMethod(): bool {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use getSupportedTypes() instead. See https://www.drupal.org/node/3359695', E_USER_DEPRECATED);
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
LinkCollection::class => TRUE,
];
}
}

View File

@@ -0,0 +1,72 @@
<?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/drupal/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 normalization without a format. 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;
}
}

View File

@@ -0,0 +1,45 @@
<?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}
*/
public function normalize($object, $format = NULL, array $context = []): array|string|int|float|bool|\ArrayObject|NULL {
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(),
]);
}
/**
* {@inheritdoc}
*/
public function hasCacheableSupportsMethod(): bool {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use getSupportedTypes() instead. See https://www.drupal.org/node/3359695', E_USER_DEPRECATED);
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
Relationship::class => TRUE,
];
}
}

View File

@@ -0,0 +1,155 @@
<?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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class ResourceIdentifierNormalizer extends NormalizerBase implements DenormalizerInterface {
/**
* 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 = []): array|string|int|float|bool|\ArrayObject|NULL {
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 = []): mixed {
// 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']];
$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 ($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 match the destination resource types (%s).',
$value['type'],
implode(', ', $target_resource_type_names)
));
}
return new ResourceIdentifier($value['type'], $value['id'], $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;
}
/**
* {@inheritdoc}
*/
public function hasCacheableSupportsMethod(): bool {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use getSupportedTypes() instead. See https://www.drupal.org/node/3359695', E_USER_DEPRECATED);
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
ResourceIdentifier::class => TRUE,
];
}
}

View File

@@ -0,0 +1,218 @@
<?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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class ResourceObjectNormalizer extends NormalizerBase {
/**
* 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, string $type, ?string $format = NULL, array $context = []): bool {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function normalize($object, $format = NULL, array $context = []): array|string|int|float|bool|\ArrayObject|NULL {
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'] = $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'] ?? NULL, 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 (is_a($context['resource_object']->getResourceType()->getDeserializationTargetClass(), 'Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay', TRUE) && $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);
}
}
/**
* {@inheritdoc}
*/
public function hasCacheableSupportsMethod(): bool {
@trigger_error(__METHOD__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use getSupportedTypes() instead. See https://www.drupal.org/node/3359695', E_USER_DEPRECATED);
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
ResourceObject::class => TRUE,
];
}
}

View File

@@ -0,0 +1,83 @@
<?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/drupal/issues/3032787
* @see jsonapi.api.php
*
* @see http://jsonapi.org/format/#error-objects
*/
class UnprocessableHttpEntityExceptionNormalizer extends HttpExceptionNormalizer {
/**
* {@inheritdoc}
*/
protected function buildErrorObjects(HttpException $exception) {
/** @var \Drupal\jsonapi\Exception\UnprocessableHttpEntityException $exception */
$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;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
UnprocessableHttpEntityException::class => TRUE,
];
}
}

View File

@@ -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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class CacheableNormalization extends TemporaryArrayObjectThrowingExceptions 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_iterable($value) && !static::hasNoNestedInstances($value) || $value instanceof static) {
return FALSE;
}
}
return TRUE;
}
}

View File

@@ -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/drupal/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.');
}
}

View File

@@ -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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class HttpExceptionNormalizerValue extends CacheableNormalization {}

View File

@@ -0,0 +1,275 @@
<?php
namespace Drupal\jsonapi\Normalizer\Value;
/**
* An \ArrayObject that throws an exception when used as an ArrayObject.
*
* @internal This class implements all methods for class \ArrayObject and throws
* an \Exception when one of those methods is called.
*/
class TemporaryArrayObjectThrowingExceptions extends \ArrayObject {
/**
* Append a value to the ArrayObject.
*
* @param mixed $value
* The value to append to the ArrayObject.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
public function append($value): void {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Sort the ArrayObject.
*
* @param int $flags
* The flags to sort the ArrayObject by.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
#[\ReturnTypeWillChange]
public function asort($flags = SORT_REGULAR): bool {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Count the ArrayObject.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
public function count(): int {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Exchange the current array with another array or object.
*
* @param array|object $array
* The array to replace for the current array.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
public function exchangeArray($array): array {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Exports the \ArrayObject to an array.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
public function getArrayCopy(): array {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Gets the behavior flags of the \ArrayObject.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
public function getFlags(): int {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Create a new iterator from an ArrayObject instance.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
public function getIterator(): \Iterator {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Gets the class name of the array iterator that is used by \ArrayObject::getIterator().
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
public function getIteratorClass(): string {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Sort the entries by key.
*
* @param int $flags
* The flags to sort the ArrayObject by.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
#[\ReturnTypeWillChange]
public function ksort($flags = SORT_REGULAR): bool {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Sort an array using a case insensitive "natural order" algorithm.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
#[\ReturnTypeWillChange]
public function natcasesort(): bool {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Sort entries using a "natural order" algorithm.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
#[\ReturnTypeWillChange]
public function natsort(): bool {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Returns whether the requested index exists.
*
* @param mixed $key
* The index being checked.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
public function offsetExists($key): bool {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Returns the value at the specified index.
*
* @param mixed $key
* The index with the value.
*
* @return mixed
* The value at the specified index or null.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
#[\ReturnTypeWillChange]
public function offsetGet($key) {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Sets the value at the specified index to new value.
*
* @param mixed $key
* The index being set.
* @param mixed $value
* The new value for the key.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
public function offsetSet($key, $value): void {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Unsets the value at the specified index.
*
* @param mixed $key
* The index being unset.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
public function offsetUnset($key): void {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Sets the behavior flags for the \ArrayObject.
*
* @param int $flags
* Set the flags that change the behavior of the \ArrayObject.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
public function setFlags($flags): void {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Sets the iterator classname for the \ArrayObject.
*
* @param string $iteratorClass
* The classname of the array iterator to use when iterating over this
* object.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
public function setIteratorClass($iteratorClass): void {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Sort the entries with a user-defined comparison function.
*
* @param callable $callback
* The comparison function must return an integer less than, equal to, or
* greater than zero if the first argument is considered to be respectively
* less than, equal to, or greater than the second.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
#[\ReturnTypeWillChange]
public function uasort($callback): bool {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
/**
* Sort the entries by keys using a user-defined comparison function.
*
* @param callable $callback
* The comparison function must return an integer less than, equal to, or
* greater than zero if the first argument is considered to be respectively
* less than, equal to, or greater than the second.
*
* @throws \Exception
* This class does not support this action but it must implement it, because
* it is extending \ArrayObject.
*/
#[\ReturnTypeWillChange]
public function uksort($callback): bool {
throw new \Exception('This ' . __CLASS__ . ' does not support this action but it must implement it, because it is extending \ArrayObject.');
}
}

View File

@@ -0,0 +1,91 @@
<?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 Drupal\Core\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/drupal/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);
$uuid_key = $this->entityTypeManager->getDefinition($entity_type_id)
->getKey('uuid');
if ($storage = $this->entityTypeManager->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 = $this->entityRepository->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']) && str_starts_with($definition['type'], 'entity')
);
}
}

View File

@@ -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/drupal/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);
}
}

View File

@@ -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/drupal/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);
}
}
}
}

View File

@@ -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/drupal/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;
}
}

View File

@@ -0,0 +1,298 @@
<?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/drupal/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.
$group = match ($condition_group->conjunction()) {
'AND' => $query->andConditionGroup(),
'OR' => $query->orConditionGroup(),
};
// 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];
$operator = $filter_item[static::CONDITION_KEY][EntityCondition::OPERATOR_KEY];
$filter_item[static::CONDITION_KEY][EntityCondition::PATH_KEY] = $field_resolver->resolveInternalEntityQueryPath($resource_type, $unresolved, $operator);
}
}
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']);
}
}

View File

@@ -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/drupal/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]);
}
}

View File

@@ -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/drupal/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 array
*/
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;
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Drupal\jsonapi;
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/drupal/issues/3032787
* @see jsonapi.api.php
*
* @see \Drupal\rest\ModifiedResourceResponse
*/
class ResourceResponse extends Response {
/**
* 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;
}
}

View File

@@ -0,0 +1,446 @@
<?php
namespace Drupal\jsonapi\ResourceType;
/**
* 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/drupal/issues/3032787
* @see jsonapi.api.php
*
* @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
*/
class ResourceType {
/**
* A string which is used as path separator in resource type names.
*
* @see \Drupal\jsonapi\ResourceType\ResourceType::getPath()
*/
const TYPE_NAME_URI_PATH_SEPARATOR = '--';
/**
* 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 unnecessarily 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.
* @param null|string $type_name
* The resource type name.
*/
public function __construct($entity_type_id, $bundle, $deserialization_target_class, $internal = FALSE, $is_locatable = TRUE, $is_mutable = TRUE, $is_versionable = FALSE, array $fields = [], $type_name = NULL) {
$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 = $type_name;
if ($type_name === NULL) {
$this->typeName = $this->bundle === '?'
? 'unknown'
: $this->entityTypeId . self::TYPE_NAME_URI_PATH_SEPARATOR . $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 && $field->isFieldEnabled();
})), '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->isFieldEnabled()
? $field->getRelatableResourceTypes()
: [];
}
/**
* Get the resource path.
*
* @return string
* The path to access this resource type. The function
* replaces "--" with "/" in the URI path.
* Example: "node--article" -> "node/article".
*
* @see \Drupal\jsonapi\ResourceType\ResourceType::TYPE_NAME_URI_PATH_SEPARATOR
* @see jsonapi.base_path
*/
public function getPath() {
return '/' . implode('/', explode(self::TYPE_NAME_URI_PATH_SEPARATOR, $this->typeName));
}
}

View File

@@ -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/drupal/issues/3032787
* @see jsonapi.api.php
*
* @see \Drupal\jsonapi\ResourceType\ResourceTypeRepository
*/
class ResourceTypeAttribute extends ResourceTypeField {}

View File

@@ -0,0 +1,154 @@
<?php
namespace Drupal\jsonapi\ResourceType;
use Drupal\Component\Assertion\Inspector;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\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 null|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($entity_type->id() . ResourceType::TYPE_NAME_URI_PATH_SEPARATOR . $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;
}
/**
* Sets the name of the resource type to be built.
*
* @param string $resource_type_name
* The resource type name. Also used to build the resource path.
*
* @see \Drupal\jsonapi\ResourceType\ResourceType::getPath()
*/
public function setResourceTypeName(string $resource_type_name): void {
$this->resourceTypeName = $resource_type_name;
}
/**
* 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;
}
}
}
}

View File

@@ -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';
}

View File

@@ -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/drupal/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;
}
}

View File

@@ -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/drupal/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;
}
}

View File

@@ -0,0 +1,557 @@
<?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\Installer\InstallerKernel;
use Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItemInterface;
use Drupal\Core\Logger\LoggerChannelTrait;
use Drupal\Core\TypedData\DataReferenceTargetDefinition;
use Symfony\Contracts\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/drupal/issues/3032787
* @see jsonapi.api.php
*
* @see \Drupal\jsonapi\ResourceType\ResourceType
*/
class ResourceTypeRepository implements ResourceTypeRepositoryInterface {
use LoggerChannelTrait;
/**
* 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\Contracts\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\Contracts\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) {
return $cached->data;
}
$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 $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) {
$type_name = NULL;
$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($event, ResourceTypeBuildEvents::BUILD);
$internalize_resource_type = $event->resourceTypeShouldBeDisabled();
$fields = $event->getFields();
$type_name = $event->getResourceTypeName();
}
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,
$type_name
);
}
/**
* {@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.');
}
$map_id = sprintf('jsonapi.resource_type.%s.%s', $entity_type_id, $bundle);
$cached = $this->cache->get($map_id);
if ($cached) {
return $cached->data;
}
$resource_type = static::lookupResourceType($this->all(), $entity_type_id, $bundle);
$this->cache->set($map_id, $resource_type, Cache::PERMANENT, $this->cacheTags);
return $resource_type;
}
/**
* {@inheritdoc}
*/
public function getByTypeName($type_name) {
$resource_types = $this->all();
return $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. 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 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) {
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');
$relatable_resource_types = [];
$item_class = $item_definition->getClass();
if (is_subclass_of($item_class, EntityReferenceItemInterface::class)) {
$target_type_bundles = $item_class::getReferenceableBundles($field_definition);
}
else {
@trigger_error(
sprintf('Entity reference field items not implementing %s is deprecated in drupal:10.2.0 and will be required in drupal:11.0.0. See https://www.drupal.org/node/3279140', EntityReferenceItemInterface::class),
E_USER_DEPRECATED
);
$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);
$target_type_bundles = [$entity_type_id => $target_bundles];
}
foreach ($target_type_bundles as $entity_type_id => $target_bundles) {
foreach ($target_bundles as $target_bundle) {
if ($resource_type = static::lookupResourceType($resource_types, $entity_type_id, $target_bundle)) {
$relatable_resource_types[] = $resource_type;
continue;
}
// Do not warn during site installation since system integrity
// is not guaranteed during this period and may cause confusing and
// unnecessary warnings.
if (!InstallerKernel::installationAttempted()) {
$this->getLogger('jsonapi')->warning(
'The "@name" at "@target_entity_type_id:@target_bundle" references the "@entity_type_id:@bundle" entity type that does not exist.',
[
'@name' => $field_definition->getName(),
'@target_entity_type_id' => $field_definition->getTargetEntityTypeId(),
'@target_bundle' => $field_definition->getTargetBundle(),
'@entity_type_id' => $entity_type_id,
'@bundle' => $target_bundle,
],
);
}
}
}
return $relatable_resource_types;
}
/**
* 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) {
// Ensure all keys are strings because numeric values are allowed as bundle
// names and "array_keys()" casts "42" to 42.
return array_map('strval', array_keys($this->entityTypeBundleInfo->getBundleInfo($entity_type_id)));
}
/**
* Lookup a resource type by entity type ID and bundle name.
*
* @param \Drupal\jsonapi\ResourceType\ResourceType[] $resource_types
* The list of resource types to do a lookup.
* @param string $entity_type_id
* The entity type of a seekable resource type.
* @param string $bundle
* The entity bundle of a seekable resource type.
*
* @return \Drupal\jsonapi\ResourceType\ResourceType|null
* The resource type or NULL if one cannot be found.
*/
protected static function lookupResourceType(array $resource_types, $entity_type_id, $bundle) {
if (isset($resource_types[$entity_type_id . ResourceType::TYPE_NAME_URI_PATH_SEPARATOR . $bundle])) {
return $resource_types[$entity_type_id . ResourceType::TYPE_NAME_URI_PATH_SEPARATOR . $bundle];
}
foreach ($resource_types as $resource_type) {
if ($resource_type->getEntityTypeId() === $entity_type_id && $resource_type->getBundle() === $bundle) {
return $resource_type;
}
}
return NULL;
}
}

View File

@@ -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/drupal/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|null
* 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);
}

View File

@@ -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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class InvalidVersionIdentifierException extends \InvalidArgumentException {}

View File

@@ -0,0 +1,107 @@
<?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/drupal/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) {
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
$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;
}
}

View File

@@ -0,0 +1,186 @@
<?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 Drupal\Core\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/drupal/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) {
$cacheability = (new CacheableMetadata())->addCacheContexts(['url.path', static::CACHE_CONTEXT]);
throw new CacheableHttpException($cacheability, 501, 'Resource versioning is not yet supported for this resource type.');
}
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);
}
// '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;
}
}

View File

@@ -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/drupal/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;
}
}

View File

@@ -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/drupal/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);
}
}
}

View File

@@ -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/drupal/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(str_starts_with(get_class($version_negotiator), 'Drupal\\jsonapi\\'), '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 {
[$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);
}
}

View File

@@ -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/drupal/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);
}

View File

@@ -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/drupal/issues/3032787
* @see jsonapi.api.php
*/
class VersionNotFoundException extends \InvalidArgumentException {
/**
* {@inheritdoc}
*/
public function __construct($message = '', $code = 0, ?\Exception $previous = NULL) {
parent::__construct(!is_null($message) ? $message : 'The identified version could not be found.', $code, $previous);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,79 @@
<?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[] = $route->getMethods();
}
$all_supported_methods = array_merge(...$all_supported_methods);
$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()));
}
}

View File

@@ -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/drupal/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;
}
}

View File

@@ -0,0 +1,481 @@
<?php
namespace Drupal\jsonapi\Routing;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\jsonapi\Access\RelationshipRouteAccessCheck;
use Drupal\jsonapi\Controller\EntryPoint;
use Drupal\jsonapi\ParamConverter\ResourceTypeConverter;
use Drupal\jsonapi\ResourceType\ResourceType;
use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
use Drupal\Core\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/drupal/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(
!str_ends_with($jsonapi_base_path, '/'),
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])
&& str_starts_with($defaults[RouteObjectInterface::CONTROLLER_NAME], static::CONTROLLER_SERVICE_NAME);
}
/**
* 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('_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;
$field_operation = $method === 'GET' ? 'view' : 'edit';
$method_specific_relationship_route->setRequirement(RelationshipRouteAccessCheck::ROUTE_REQUIREMENT_KEY, "$relationship_field_name.$field_operation");
$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(RelationshipRouteAccessCheck::ROUTE_REQUIREMENT_KEY, "$relationship_field_name.view");
$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 $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();
}
}

View File

@@ -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/drupal/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/drupal/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 (!str_starts_with(get_class($normalizer), 'Drupal\jsonapi\Normalizer')) {
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 = []): array|string|int|float|bool|\ArrayObject|NULL {
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 = []): mixed {
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, ?string $format = NULL, array $context = []): bool {
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, string $type, ?string $format = NULL, array $context = []): bool {
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);
}
}

View File

@@ -0,0 +1,2 @@
langcode: en
read_only: true

Some files were not shown because too many files have changed in this diff Show More