3 namespace Drupal\rest\Plugin\rest\resource;
5 use Drupal\Component\Plugin\DependentPluginInterface;
6 use Drupal\Component\Plugin\PluginManagerInterface;
7 use Drupal\Core\Access\AccessResultReasonInterface;
8 use Drupal\Core\Cache\CacheableResponseInterface;
9 use Drupal\Core\Config\Entity\ConfigEntityType;
10 use Drupal\Core\Entity\EntityTypeManagerInterface;
11 use Drupal\Core\Entity\FieldableEntityInterface;
12 use Drupal\Core\Config\ConfigFactoryInterface;
13 use Drupal\Core\Entity\EntityInterface;
14 use Drupal\Core\Entity\EntityStorageException;
15 use Drupal\Core\Field\FieldItemListInterface;
16 use Drupal\Core\Http\Exception\CacheableAccessDeniedHttpException;
17 use Drupal\rest\Plugin\ResourceBase;
18 use Drupal\rest\ResourceResponse;
19 use Psr\Log\LoggerInterface;
20 use Symfony\Component\DependencyInjection\ContainerInterface;
21 use Drupal\rest\ModifiedResourceResponse;
22 use Symfony\Component\HttpFoundation\Response;
23 use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
24 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
25 use Symfony\Component\HttpKernel\Exception\HttpException;
28 * Represents entities as resources.
30 * @see \Drupal\rest\Plugin\Deriver\EntityDeriver
34 * label = @Translation("Entity"),
35 * serialization_class = "Drupal\Core\Entity\Entity",
36 * deriver = "Drupal\rest\Plugin\Deriver\EntityDeriver",
38 * "canonical" = "/entity/{entity_type}/{entity}",
39 * "create" = "/entity/{entity_type}"
43 class EntityResource extends ResourceBase implements DependentPluginInterface {
45 use EntityResourceValidationTrait;
46 use EntityResourceAccessTrait;
49 * The entity type targeted by this resource.
51 * @var \Drupal\Core\Entity\EntityTypeInterface
53 protected $entityType;
58 * @var \Drupal\Core\Config\ConfigFactoryInterface
60 protected $configFactory;
63 * The link relation type manager used to create HTTP header links.
65 * @var \Drupal\Component\Plugin\PluginManagerInterface
67 protected $linkRelationTypeManager;
70 * Constructs a Drupal\rest\Plugin\rest\resource\EntityResource object.
72 * @param array $configuration
73 * A configuration array containing information about the plugin instance.
74 * @param string $plugin_id
75 * The plugin_id for the plugin instance.
76 * @param mixed $plugin_definition
77 * The plugin implementation definition.
78 * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
79 * The entity type manager
80 * @param array $serializer_formats
81 * The available serialization formats.
82 * @param \Psr\Log\LoggerInterface $logger
84 * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
86 * @param \Drupal\Component\Plugin\PluginManagerInterface $link_relation_type_manager
87 * The link relation type manager.
89 public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, $serializer_formats, LoggerInterface $logger, ConfigFactoryInterface $config_factory, PluginManagerInterface $link_relation_type_manager) {
90 parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger);
91 $this->entityType = $entity_type_manager->getDefinition($plugin_definition['entity_type']);
92 $this->configFactory = $config_factory;
93 $this->linkRelationTypeManager = $link_relation_type_manager;
99 public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
104 $container->get('entity_type.manager'),
105 $container->getParameter('serializer.formats'),
106 $container->get('logger.factory')->get('rest'),
107 $container->get('config.factory'),
108 $container->get('plugin.manager.link_relation_type')
113 * Responds to entity GET requests.
115 * @param \Drupal\Core\Entity\EntityInterface $entity
118 * @return \Drupal\rest\ResourceResponse
119 * The response containing the entity with its accessible fields.
121 * @throws \Symfony\Component\HttpKernel\Exception\HttpException
123 public function get(EntityInterface $entity) {
124 $entity_access = $entity->access('view', NULL, TRUE);
125 if (!$entity_access->isAllowed()) {
126 throw new CacheableAccessDeniedHttpException($entity_access, $entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'view'));
129 $response = new ResourceResponse($entity, 200);
130 $response->addCacheableDependency($entity);
131 $response->addCacheableDependency($entity_access);
133 if ($entity instanceof FieldableEntityInterface) {
134 foreach ($entity as $field_name => $field) {
135 /** @var \Drupal\Core\Field\FieldItemListInterface $field */
136 $field_access = $field->access('view', NULL, TRUE);
137 $response->addCacheableDependency($field_access);
139 if (!$field_access->isAllowed()) {
140 $entity->set($field_name, NULL);
145 $this->addLinkHeaders($entity, $response);
151 * Responds to entity POST requests and saves the new entity.
153 * @param \Drupal\Core\Entity\EntityInterface $entity
156 * @return \Drupal\rest\ModifiedResourceResponse
157 * The HTTP response object.
159 * @throws \Symfony\Component\HttpKernel\Exception\HttpException
161 public function post(EntityInterface $entity = NULL) {
162 if ($entity == NULL) {
163 throw new BadRequestHttpException('No entity content received.');
166 $entity_access = $entity->access('create', NULL, TRUE);
167 if (!$entity_access->isAllowed()) {
168 throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'create'));
170 $definition = $this->getPluginDefinition();
171 // Verify that the deserialized entity is of the type that we expect to
172 // prevent security issues.
173 if ($entity->getEntityTypeId() != $definition['entity_type']) {
174 throw new BadRequestHttpException('Invalid entity type');
176 // POSTed entities must not have an ID set, because we always want to create
177 // new entities here.
178 if (!$entity->isNew()) {
179 throw new BadRequestHttpException('Only new entities can be created');
182 $this->checkEditFieldAccess($entity);
184 // Validate the received data before saving.
185 $this->validate($entity);
188 $this->logger->notice('Created entity %type with ID %id.', ['%type' => $entity->getEntityTypeId(), '%id' => $entity->id()]);
190 // 201 Created responses return the newly created entity in the response
191 // body. These responses are not cacheable, so we add no cacheability
194 if (in_array('canonical', $entity->uriRelationships(), TRUE)) {
195 $url = $entity->urlInfo('canonical', ['absolute' => TRUE])->toString(TRUE);
196 $headers['Location'] = $url->getGeneratedUrl();
198 return new ModifiedResourceResponse($entity, 201, $headers);
200 catch (EntityStorageException $e) {
201 throw new HttpException(500, 'Internal Server Error', $e);
206 * Responds to entity PATCH requests.
208 * @param \Drupal\Core\Entity\EntityInterface $original_entity
209 * The original entity object.
210 * @param \Drupal\Core\Entity\EntityInterface $entity
213 * @return \Drupal\rest\ModifiedResourceResponse
214 * The HTTP response object.
216 * @throws \Symfony\Component\HttpKernel\Exception\HttpException
218 public function patch(EntityInterface $original_entity, EntityInterface $entity = NULL) {
219 if ($entity == NULL) {
220 throw new BadRequestHttpException('No entity content received.');
222 $definition = $this->getPluginDefinition();
223 if ($entity->getEntityTypeId() != $definition['entity_type']) {
224 throw new BadRequestHttpException('Invalid entity type');
226 $entity_access = $original_entity->access('update', NULL, TRUE);
227 if (!$entity_access->isAllowed()) {
228 throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'update'));
231 // Overwrite the received fields.
232 // @todo Remove $changed_fields in https://www.drupal.org/project/drupal/issues/2862574.
233 $changed_fields = [];
234 foreach ($entity->_restSubmittedFields as $field_name) {
235 $field = $entity->get($field_name);
236 // It is not possible to set the language to NULL as it is automatically
237 // re-initialized. As it must not be empty, skip it if it is.
238 // @todo Remove in https://www.drupal.org/project/drupal/issues/2933408.
239 if ($entity->getEntityType()->hasKey('langcode') && $field_name === $entity->getEntityType()->getKey('langcode') && $field->isEmpty()) {
242 if ($this->checkPatchFieldAccess($original_entity->get($field_name), $field)) {
243 $changed_fields[] = $field_name;
244 $original_entity->set($field_name, $field->getValue());
248 // If no fields are changed, we can send a response immediately!
249 if (empty($changed_fields)) {
250 return new ModifiedResourceResponse($original_entity, 200);
253 // Validate the received data before saving.
254 $this->validate($original_entity, $changed_fields);
256 $original_entity->save();
257 $this->logger->notice('Updated entity %type with ID %id.', ['%type' => $original_entity->getEntityTypeId(), '%id' => $original_entity->id()]);
259 // Return the updated entity in the response body.
260 return new ModifiedResourceResponse($original_entity, 200);
262 catch (EntityStorageException $e) {
263 throw new HttpException(500, 'Internal Server Error', $e);
268 * Checks whether the given field should be PATCHed.
270 * @param \Drupal\Core\Field\FieldItemListInterface $original_field
271 * The original (stored) value for the field.
272 * @param \Drupal\Core\Field\FieldItemListInterface $received_field
273 * The received value for the field.
276 * Whether the field should be PATCHed or not.
278 * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
279 * Thrown when the user sending the request is not allowed to update the
280 * field. Only thrown when the user could not abuse this information to
281 * determine the stored value.
285 protected function checkPatchFieldAccess(FieldItemListInterface $original_field, FieldItemListInterface $received_field) {
286 // The user might not have access to edit the field, but still needs to
287 // submit the current field value as part of the PATCH request. For
288 // example, the entity keys required by denormalizers. Therefore, if the
289 // received value equals the stored value, return FALSE without throwing an
290 // exception. But only for fields that the user has access to view, because
291 // the user has no legitimate way of knowing the current value of fields
292 // that they are not allowed to view, and we must not make the presence or
293 // absence of a 403 response a way to find that out.
294 if ($original_field->access('view') && $original_field->equals($received_field)) {
298 // If the user is allowed to edit the field, it is always safe to set the
299 // received value. We may be setting an unchanged value, but that is ok.
300 $field_edit_access = $original_field->access('edit', NULL, TRUE);
301 if ($field_edit_access->isAllowed()) {
305 // It's helpful and safe to let the user know when they are not allowed to
307 $field_name = $received_field->getName();
308 $error_message = "Access denied on updating field '$field_name'.";
309 if ($field_edit_access instanceof AccessResultReasonInterface) {
310 $reason = $field_edit_access->getReason();
312 $error_message .= ' ' . $reason;
315 throw new AccessDeniedHttpException($error_message);
319 * Responds to entity DELETE requests.
321 * @param \Drupal\Core\Entity\EntityInterface $entity
324 * @return \Drupal\rest\ModifiedResourceResponse
325 * The HTTP response object.
327 * @throws \Symfony\Component\HttpKernel\Exception\HttpException
329 public function delete(EntityInterface $entity) {
330 $entity_access = $entity->access('delete', NULL, TRUE);
331 if (!$entity_access->isAllowed()) {
332 throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'delete'));
336 $this->logger->notice('Deleted entity %type with ID %id.', ['%type' => $entity->getEntityTypeId(), '%id' => $entity->id()]);
338 // DELETE responses have an empty body.
339 return new ModifiedResourceResponse(NULL, 204);
341 catch (EntityStorageException $e) {
342 throw new HttpException(500, 'Internal Server Error', $e);
347 * Generates a fallback access denied message, when no specific reason is set.
349 * @param \Drupal\Core\Entity\EntityInterface $entity
351 * @param string $operation
352 * The disallowed entity operation.
355 * The proper message to display in the AccessDeniedHttpException.
357 protected function generateFallbackAccessDeniedMessage(EntityInterface $entity, $operation) {
358 $message = "You are not authorized to {$operation} this {$entity->getEntityTypeId()} entity";
360 if ($entity->bundle() !== $entity->getEntityTypeId()) {
361 $message .= " of bundle {$entity->bundle()}";
363 return "{$message}.";
369 public function permissions() {
370 // @see https://www.drupal.org/node/2664780
371 if ($this->configFactory->get('rest.settings')->get('bc_entity_resource_permissions')) {
372 // The default Drupal 8.0.x and 8.1.x behavior.
373 return parent::permissions();
376 // The default Drupal 8.2.x behavior.
384 protected function getBaseRoute($canonical_path, $method) {
385 $route = parent::getBaseRoute($canonical_path, $method);
386 $definition = $this->getPluginDefinition();
388 $parameters = $route->getOption('parameters') ?: [];
389 $parameters[$definition['entity_type']]['type'] = 'entity:' . $definition['entity_type'];
390 $route->setOption('parameters', $parameters);
398 public function availableMethods() {
399 $methods = parent::availableMethods();
400 if ($this->isConfigEntityResource()) {
401 // Currently only GET is supported for Config Entities.
402 // @todo Remove when supported https://www.drupal.org/node/2300677
403 $unsupported_methods = ['POST', 'PUT', 'DELETE', 'PATCH'];
404 $methods = array_diff($methods, $unsupported_methods);
410 * Checks if this resource is for a Config Entity.
413 * TRUE if the entity is a Config Entity, FALSE otherwise.
415 protected function isConfigEntityResource() {
416 return $this->entityType instanceof ConfigEntityType;
422 public function calculateDependencies() {
423 if (isset($this->entityType)) {
424 return ['module' => [$this->entityType->getProvider()]];
429 * Adds link headers to a response.
431 * @param \Drupal\Core\Entity\EntityInterface $entity
433 * @param \Symfony\Component\HttpFoundation\Response $response
436 * @see https://tools.ietf.org/html/rfc5988#section-5
438 protected function addLinkHeaders(EntityInterface $entity, Response $response) {
439 foreach ($entity->uriRelationships() as $relation_name) {
440 if ($this->linkRelationTypeManager->hasDefinition($relation_name)) {
441 /** @var \Drupal\Core\Http\LinkRelationTypeInterface $link_relation_type */
442 $link_relation_type = $this->linkRelationTypeManager->createInstance($relation_name);
444 $generator_url = $entity->toUrl($relation_name)
447 if ($response instanceof CacheableResponseInterface) {
448 $response->addCacheableDependency($generator_url);
450 $uri = $generator_url->getGeneratedUrl();
452 $relationship = $link_relation_type->isRegistered()
453 ? $link_relation_type->getRegisteredName()
454 : $link_relation_type->getExtensionUri();
456 $link_header = '<' . $uri . '>; rel="' . $relationship . '"';
457 $response->headers->set('Link', $link_header, FALSE);