EntityResource.php 16.5 KB
Newer Older
1 2 3 4
<?php

namespace Drupal\rest\Plugin\rest\resource;

5
use Drupal\Component\Plugin\DependentPluginInterface;
6 7
use Drupal\Component\Plugin\PluginManagerInterface;
use Drupal\Core\Cache\CacheableResponseInterface;
8 9 10
use Drupal\Core\Config\Entity\ConfigEntityType;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
11
use Drupal\Core\Config\ConfigFactoryInterface;
12
use Drupal\Core\Entity\EntityInterface;
13
use Drupal\Core\Entity\EntityStorageException;
14 15
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\TypedData\PrimitiveInterface;
16
use Drupal\rest\Plugin\ResourceBase;
17
use Drupal\rest\ResourceResponse;
18 19
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
20
use Drupal\rest\ModifiedResourceResponse;
21
use Symfony\Component\HttpFoundation\Response;
22
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
23
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
24 25 26 27 28
use Symfony\Component\HttpKernel\Exception\HttpException;

/**
 * Represents entities as resources.
 *
29 30
 * @see \Drupal\rest\Plugin\Deriver\EntityDeriver
 *
31
 * @RestResource(
32 33
 *   id = "entity",
 *   label = @Translation("Entity"),
34
 *   serialization_class = "Drupal\Core\Entity\Entity",
35
 *   deriver = "Drupal\rest\Plugin\Deriver\EntityDeriver",
36 37
 *   uri_paths = {
 *     "canonical" = "/entity/{entity_type}/{entity}",
38
 *     "https://www.drupal.org/link-relations/create" = "/entity/{entity_type}"
39
 *   }
40 41
 * )
 */
42
class EntityResource extends ResourceBase implements DependentPluginInterface {
43

44 45 46
  use EntityResourceValidationTrait;
  use EntityResourceAccessTrait;

47
  /**
48
   * The entity type targeted by this resource.
49
   *
50
   * @var \Drupal\Core\Entity\EntityTypeInterface
51
   */
52
  protected $entityType;
53

54 55 56 57 58 59 60
  /**
   * The config factory.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

61 62 63 64 65 66 67
  /**
   * The link relation type manager used to create HTTP header links.
   *
   * @var \Drupal\Component\Plugin\PluginManagerInterface
   */
  protected $linkRelationTypeManager;

68 69 70 71 72 73 74 75 76
  /**
   * Constructs a Drupal\rest\Plugin\rest\resource\EntityResource object.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The plugin_id for the plugin instance.
   * @param mixed $plugin_definition
   *   The plugin implementation definition.
77 78
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager
79 80 81 82
   * @param array $serializer_formats
   *   The available serialization formats.
   * @param \Psr\Log\LoggerInterface $logger
   *   A logger instance.
83
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
84
   *   The config factory.
85 86
   * @param \Drupal\Component\Plugin\PluginManagerInterface $link_relation_type_manager
   *   The link relation type manager.
87
   */
88
  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) {
89
    parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger);
90
    $this->entityType = $entity_type_manager->getDefinition($plugin_definition['entity_type']);
91
    $this->configFactory = $config_factory;
92
    $this->linkRelationTypeManager = $link_relation_type_manager;
93 94 95 96 97 98 99 100 101 102
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
103
      $container->get('entity_type.manager'),
104
      $container->getParameter('serializer.formats'),
105
      $container->get('logger.factory')->get('rest'),
106 107
      $container->get('config.factory'),
      $container->get('plugin.manager.link_relation_type')
108 109 110
    );
  }

111 112 113
  /**
   * Responds to entity GET requests.
   *
114 115
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity object.
116
   *
117
   * @return \Drupal\rest\ResourceResponse
118
   *   The response containing the entity with its accessible fields.
119 120 121
   *
   * @throws \Symfony\Component\HttpKernel\Exception\HttpException
   */
122
  public function get(EntityInterface $entity) {
123 124
    $entity_access = $entity->access('view', NULL, TRUE);
    if (!$entity_access->isAllowed()) {
125
      throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'view'));
126
    }
127 128 129 130 131

    $response = new ResourceResponse($entity, 200);
    $response->addCacheableDependency($entity);
    $response->addCacheableDependency($entity_access);

132 133 134 135 136 137 138 139 140
    if ($entity instanceof FieldableEntityInterface) {
      foreach ($entity as $field_name => $field) {
        /** @var \Drupal\Core\Field\FieldItemListInterface $field */
        $field_access = $field->access('view', NULL, TRUE);
        $response->addCacheableDependency($field_access);

        if (!$field_access->isAllowed()) {
          $entity->set($field_name, NULL);
        }
141
      }
142
    }
143

144 145
    $this->addLinkHeaders($entity, $response);

146
    return $response;
147 148
  }

149 150 151 152 153 154
  /**
   * Responds to entity POST requests and saves the new entity.
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity.
   *
155
   * @return \Drupal\rest\ModifiedResourceResponse
156 157 158 159
   *   The HTTP response object.
   *
   * @throws \Symfony\Component\HttpKernel\Exception\HttpException
   */
160
  public function post(EntityInterface $entity = NULL) {
161
    if ($entity == NULL) {
162
      throw new BadRequestHttpException('No entity content received.');
163 164
    }

165 166 167
    $entity_access = $entity->access('create', NULL, TRUE);
    if (!$entity_access->isAllowed()) {
      throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'create'));
168
    }
169
    $definition = $this->getPluginDefinition();
170 171
    // Verify that the deserialized entity is of the type that we expect to
    // prevent security issues.
172
    if ($entity->getEntityTypeId() != $definition['entity_type']) {
173
      throw new BadRequestHttpException('Invalid entity type');
174 175 176 177
    }
    // POSTed entities must not have an ID set, because we always want to create
    // new entities here.
    if (!$entity->isNew()) {
178
      throw new BadRequestHttpException('Only new entities can be created');
179
    }
180

181
    $this->checkEditFieldAccess($entity);
182 183 184

    // Validate the received data before saving.
    $this->validate($entity);
185 186
    try {
      $entity->save();
187
      $this->logger->notice('Created entity %type with ID %id.', ['%type' => $entity->getEntityTypeId(), '%id' => $entity->id()]);
188

189
      // 201 Created responses return the newly created entity in the response
190 191
      // body. These responses are not cacheable, so we add no cacheability
      // metadata here.
192 193 194 195 196 197
      $headers = [];
      if (in_array('canonical', $entity->uriRelationships(), TRUE)) {
        $url = $entity->urlInfo('canonical', ['absolute' => TRUE])->toString(TRUE);
        $headers['Location'] = $url->getGeneratedUrl();
      }
      return new ModifiedResourceResponse($entity, 201, $headers);
198 199
    }
    catch (EntityStorageException $e) {
200
      throw new HttpException(500, 'Internal Server Error', $e);
201 202 203
    }
  }

204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238
  /**
   * Gets the values from the field item list casted to the correct type.
   *
   * Values are casted to the correct type so we can determine whether or not
   * something has changed. REST formats such as JSON support typed data but
   * Drupal's database API will return values as strings. Currently, only
   * primitive data types know how to cast their values to the correct type.
   *
   * @param \Drupal\Core\Field\FieldItemListInterface $field_item_list
   *   The field item list to retrieve its data from.
   *
   * @return mixed[][]
   *   The values from the field item list casted to the correct type. The array
   *   of values returned is a multidimensional array keyed by delta and the
   *   property name.
   */
  protected function getCastedValueFromFieldItemList(FieldItemListInterface $field_item_list) {
    $value = $field_item_list->getValue();

    foreach ($value as $delta => $field_item_value) {
      /** @var \Drupal\Core\Field\FieldItemInterface $field_item */
      $field_item = $field_item_list->get($delta);
      $properties = $field_item->getProperties(TRUE);
      // Foreach field value we check whether we know the underlying property.
      // If we exists we try to cast the value.
      foreach ($field_item_value as $property_name => $property_value) {
        if (isset($properties[$property_name]) && ($property = $field_item->get($property_name)) && $property instanceof PrimitiveInterface) {
          $value[$delta][$property_name] = $property->getCastedValue();
        }
      }
    }

    return $value;
  }

239 240 241
  /**
   * Responds to entity PATCH requests.
   *
242 243
   * @param \Drupal\Core\Entity\EntityInterface $original_entity
   *   The original entity object.
244 245 246
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity.
   *
247
   * @return \Drupal\rest\ModifiedResourceResponse
248 249 250 251
   *   The HTTP response object.
   *
   * @throws \Symfony\Component\HttpKernel\Exception\HttpException
   */
252
  public function patch(EntityInterface $original_entity, EntityInterface $entity = NULL) {
253
    if ($entity == NULL) {
254
      throw new BadRequestHttpException('No entity content received.');
255
    }
256
    $definition = $this->getPluginDefinition();
257
    if ($entity->getEntityTypeId() != $definition['entity_type']) {
258
      throw new BadRequestHttpException('Invalid entity type');
259
    }
260 261 262
    $entity_access = $original_entity->access('update', NULL, TRUE);
    if (!$entity_access->isAllowed()) {
      throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'update'));
263 264
    }

265
    // Overwrite the received properties.
266
    $entity_keys = $entity->getEntityType()->getKeys();
267
    foreach ($entity->_restSubmittedFields as $field_name) {
268
      $field = $entity->get($field_name);
269 270 271 272 273 274

      // Entity key fields need special treatment: together they uniquely
      // identify the entity. Therefore it does not make sense to modify any of
      // them. However, rather than throwing an error, we just ignore them as
      // long as their specified values match their current values.
      if (in_array($field_name, $entity_keys, TRUE)) {
275 276 277 278 279 280 281
        // @todo Work around the wrong assumption that entity keys need special
        // treatment, when only read-only fields need it.
        // This will be fixed in https://www.drupal.org/node/2824851.
        if ($entity->getEntityTypeId() == 'comment' && $field_name == 'status' && !$original_entity->get($field_name)->access('edit')) {
          throw new AccessDeniedHttpException("Access denied on updating field '$field_name'.");
        }

282
        // Unchanged values for entity keys don't need access checking.
283
        if ($this->getCastedValueFromFieldItemList($original_entity->get($field_name)) === $this->getCastedValueFromFieldItemList($entity->get($field_name))) {
284 285 286 287 288 289 290
          continue;
        }
        // It is not possible to set the language to NULL as it is automatically
        // re-initialized. As it must not be empty, skip it if it is.
        elseif (isset($entity_keys['langcode']) && $field_name === $entity_keys['langcode'] && $field->isEmpty()) {
          continue;
        }
291
      }
292 293

      if (!$original_entity->get($field_name)->access('edit')) {
294
        throw new AccessDeniedHttpException("Access denied on updating field '$field_name'.");
295
      }
296
      $original_entity->set($field_name, $field->getValue());
297
    }
298 299 300

    // Validate the received data before saving.
    $this->validate($original_entity);
301 302
    try {
      $original_entity->save();
303
      $this->logger->notice('Updated entity %type with ID %id.', ['%type' => $original_entity->getEntityTypeId(), '%id' => $original_entity->id()]);
304

305 306
      // Return the updated entity in the response body.
      return new ModifiedResourceResponse($original_entity, 200);
307 308
    }
    catch (EntityStorageException $e) {
309
      throw new HttpException(500, 'Internal Server Error', $e);
310 311 312
    }
  }

313 314 315
  /**
   * Responds to entity DELETE requests.
   *
316 317
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity object.
318
   *
319
   * @return \Drupal\rest\ModifiedResourceResponse
320
   *   The HTTP response object.
321 322 323
   *
   * @throws \Symfony\Component\HttpKernel\Exception\HttpException
   */
324
  public function delete(EntityInterface $entity) {
325 326 327
    $entity_access = $entity->access('delete', NULL, TRUE);
    if (!$entity_access->isAllowed()) {
      throw new AccessDeniedHttpException($entity_access->getReason() ?: $this->generateFallbackAccessDeniedMessage($entity, 'delete'));
328 329 330
    }
    try {
      $entity->delete();
331
      $this->logger->notice('Deleted entity %type with ID %id.', ['%type' => $entity->getEntityTypeId(), '%id' => $entity->id()]);
332

333
      // DELETE responses have an empty body.
334
      return new ModifiedResourceResponse(NULL, 204);
335 336
    }
    catch (EntityStorageException $e) {
337
      throw new HttpException(500, 'Internal Server Error', $e);
338 339
    }
  }
340

341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360
  /**
   * Generates a fallback access denied message, when no specific reason is set.
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity object.
   * @param string $operation
   *   The disallowed entity operation.
   *
   * @return string
   *   The proper message to display in the AccessDeniedHttpException.
   */
  protected function generateFallbackAccessDeniedMessage(EntityInterface $entity, $operation) {
    $message = "You are not authorized to {$operation} this {$entity->getEntityTypeId()} entity";

    if ($entity->bundle() !== $entity->getEntityTypeId()) {
      $message .= " of bundle {$entity->bundle()}";
    }
    return "{$message}.";
  }

361 362 363 364 365 366 367 368 369 370 371 372 373 374 375
  /**
   * {@inheritdoc}
   */
  public function permissions() {
    // @see https://www.drupal.org/node/2664780
    if ($this->configFactory->get('rest.settings')->get('bc_entity_resource_permissions')) {
      // The default Drupal 8.0.x and 8.1.x behavior.
      return parent::permissions();
    }
    else {
      // The default Drupal 8.2.x behavior.
      return [];
    }
  }

376 377 378 379 380 381 382
  /**
   * {@inheritdoc}
   */
  protected function getBaseRoute($canonical_path, $method) {
    $route = parent::getBaseRoute($canonical_path, $method);
    $definition = $this->getPluginDefinition();

383
    $parameters = $route->getOption('parameters') ?: [];
384 385 386 387 388 389
    $parameters[$definition['entity_type']]['type'] = 'entity:' . $definition['entity_type'];
    $route->setOption('parameters', $parameters);

    return $route;
  }

390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410
  /**
   * {@inheritdoc}
   */
  public function availableMethods() {
    $methods = parent::availableMethods();
    if ($this->isConfigEntityResource()) {
      // Currently only GET is supported for Config Entities.
      // @todo Remove when supported https://www.drupal.org/node/2300677
      $unsupported_methods = ['POST', 'PUT', 'DELETE', 'PATCH'];
      $methods = array_diff($methods, $unsupported_methods);
    }
    return $methods;
  }

  /**
   * Checks if this resource is for a Config Entity.
   *
   * @return bool
   *   TRUE if the entity is a Config Entity, FALSE otherwise.
   */
  protected function isConfigEntityResource() {
411 412 413 414 415 416 417 418 419 420
    return $this->entityType instanceof ConfigEntityType;
  }

  /**
   * {@inheritdoc}
   */
  public function calculateDependencies() {
    if (isset($this->entityType)) {
      return ['module' => [$this->entityType->getProvider()]];
    }
421 422
  }

423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453
  /**
   * Adds link headers to a response.
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity.
   * @param \Symfony\Component\HttpFoundation\Response $response
   *   The response.
   *
   * @see https://tools.ietf.org/html/rfc5988#section-5
   */
  protected function addLinkHeaders(EntityInterface $entity, Response $response) {
    foreach ($entity->getEntityType()->getLinkTemplates() as $relation_name => $link_template) {
      if ($definition = $this->linkRelationTypeManager->getDefinition($relation_name, FALSE)) {
        $generator_url = $entity->toUrl($relation_name)
          ->setAbsolute(TRUE)
          ->toString(TRUE);
        if ($response instanceof CacheableResponseInterface) {
          $response->addCacheableDependency($generator_url);
        }
        $uri = $generator_url->getGeneratedUrl();
        $relationship = $relation_name;
        if (!empty($definition['uri'])) {
          $relationship = $definition['uri'];
        }

        $link_header = '<' . $uri . '>; rel="' . $relationship . '"';
        $response->headers->set('Link', $link_header, FALSE);
      }
    }
  }

454
}