.../Node/NodeHalJsonCookieTranslationsTest.php | 198 +++++++++++++++++++++ .../src/Plugin/rest/resource/EntityResource.php | 95 +++++----- .../resource/EntityResourceValidationTrait.php | 34 ++-- .../Comment/CommentResourceTestBase.php | 4 +- .../EntityResource/EntityResourceTestBase.php | 8 +- 5 files changed, 269 insertions(+), 70 deletions(-) diff --git a/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonCookieTranslationsTest.php b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonCookieTranslationsTest.php new file mode 100644 index 0000000..9d10dd5 --- /dev/null +++ b/core/modules/hal/tests/src/Functional/EntityResource/Node/NodeHalJsonCookieTranslationsTest.php @@ -0,0 +1,198 @@ +grantPermissionsToTestedRole(['translate any entity', 'administer nodes']); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + if (!ConfigurableLanguage::load('de')) { + ConfigurableLanguage::createFromLangcode('de')->save(); + } + + /** @var \Drupal\node\NodeInterface $node */ + $node = parent::createEntity(); + + ContentLanguageSettings::loadByEntityTypeBundle('node', 'camelids') + ->setLanguageAlterable(TRUE) + ->setDefaultLangcode('en') + ->save(); + + $translation = $node->addTranslation('de', $node->toArray()); + $translation->get('title')->value = 'Lama'; + $node->save(); + return $node; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + $normalization = parent::getExpectedNormalizedEntity(); + + foreach ($this->entity->getFieldDefinitions() as $field_name => $field_definition) { + // @todo It seems that currently two items exist if the *storage* is + // translatable, should be when the field is? + if ($field_definition->getFieldStorageDefinition()->isTranslatable() && isset($normalization[$field_name])) { + $normalization[$field_name][1] = [ + 'lang' => 'de', + ] + $normalization[$field_name][0]; + } + } + + $normalization['title'][1]['value'] = 'Lama'; + $normalization['langcode'][1]['value'] = 'de'; + $normalization['default_langcode'][1]['value'] = '0'; + + $author = User::load($this->entity->getOwnerId()); + $normalization['_links'][$this->baseUrl . '/rest/relation/node/camelids/uid'][1] = [ + 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json', + 'lang' => 'de', + ]; + $normalization['_embedded'][$this->baseUrl . '/rest/relation/node/camelids/uid'][1] = [ + '_links' => [ + 'self' => [ + 'href' => $this->baseUrl . '/user/' . $author->id() . '?_format=hal_json', + ], + 'type' => [ + 'href' => $this->baseUrl . '/rest/type/user/user', + ], + ], + 'uuid' => [ + ['value' => $author->uuid()] + ], + 'lang' => 'de', + ]; + + return $normalization; + } + + /** + * {@inheritdoc} + */ + public function testPost() { + parent::testPost(); + + // Create an entity with a translation. + $normalized_entity = $this->getExpectedNormalizedEntity(); + unset($normalized_entity['nid']); + unset($normalized_entity['vid']); + unset($normalized_entity['uuid']); + unset($normalized_entity['changed']); + unset($normalized_entity['revision_timestamp']); + unset($normalized_entity['_embedded'][$this->baseUrl . '/rest/relation/node/camelids/revision_uid']); + $entity_body_with_a_translations = $this->serializer->encode($normalized_entity, static::$format); + + $request_options = []; + $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; + $request_options[RequestOptions::BODY] = $entity_body_with_a_translations; + $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST')); + + $url = $this->getPostUrl(); + $url->setOption('query', ['_format' => static::$format]); + + // 201 for well-formed request. + $response = $this->request('POST', $url, $request_options); + $this->assertResourceResponse(201, FALSE, $response); + /** @var \Drupal\node\NodeInterface $created_node */ + $created_node = $this->serializer->deserialize((string) $response->getBody(), get_class($this->entity), static::$format); + $this->assertSame([$created_node->toUrl('canonical')->setAbsolute(TRUE)->toString()], $response->getHeader('Location')); + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + + $this->assertSame('Llama', $created_node->getTitle()); + $this->assertSame('Lama', $created_node->getTranslation('de')->getTitle()); + } + + /** + * {@inheritdoc} + */ + public function testPatch() { + parent::testPatch(); + + $title_with_translation = $this->getNormalizedPatchEntity(); + $title_with_translation['title'][0]['lang'] = 'en'; + $title_with_translation['title'][1] = [ + 'value' => 'Drama-Lama', + 'lang' => 'de', + ]; + + $entity_body_title_with_translation = $this->serializer->encode($title_with_translation, static::$format); + + $request_options = []; + $request_options[RequestOptions::HEADERS]['Content-Type'] = static::$mimeType; + $request_options[RequestOptions::BODY] = $entity_body_title_with_translation; + $request_options = NestedArray::mergeDeep($request_options, $this->getAuthenticationRequestOptions('POST')); + + $url = $this->getUrl(); + $url->setOption('query', ['_format' => static::$format]); + + $response = $this->request('PATCH', $url, $request_options); + $this->assertResourceResponse(200, FALSE, $response); + $this->assertFalse($response->hasHeader('X-Drupal-Cache')); + + $storage = \Drupal::entityTypeManager()->getStorage('node'); + $storage->resetCache(); + + $patched_node = $storage->load($this->entity->id()); + $this->assertSame('Dramallama', $patched_node->getTitle()); + $this->assertSame('Drama-Lama', $patched_node->getTranslation('de')->getTitle()); + } + +} diff --git a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php index a4162b5..a2a64be 100644 --- a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php +++ b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php @@ -6,6 +6,7 @@ use Drupal\Component\Plugin\PluginManagerInterface; use Drupal\Core\Cache\CacheableResponseInterface; use Drupal\Core\Config\Entity\ConfigEntityType; +use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Config\ConfigFactoryInterface; @@ -182,19 +183,14 @@ public function post(EntityInterface $entity = NULL) { // Validate the received data before saving. $this->validate($entity); - try { - $entity->save(); - $this->logger->notice('Created entity %type with ID %id.', ['%type' => $entity->getEntityTypeId(), '%id' => $entity->id()]); - - // 201 Created responses return the newly created entity in the response - // body. These responses are not cacheable, so we add no cacheability - // metadata here. - $url = $entity->urlInfo('canonical', ['absolute' => TRUE])->toString(TRUE); - return new ModifiedResourceResponse($entity, 201, ['Location' => $url->getGeneratedUrl()]); - } - catch (EntityStorageException $e) { - throw new HttpException(500, 'Internal Server Error', $e); - } + $entity->save(); + $this->logger->notice('Created entity %type with ID %id.', ['%type' => $entity->getEntityTypeId(), '%id' => $entity->id()]); + + // 201 Created responses return the newly created entity in the response + // body. These responses are not cacheable, so we add no cacheability + // metadata here. + $url = $entity->urlInfo('canonical', ['absolute' => TRUE])->toString(TRUE); + return new ModifiedResourceResponse($entity, 201, ['Location' => $url->getGeneratedUrl()]); } /** @@ -235,9 +231,9 @@ protected function getCastedValueFromFieldItemList(FieldItemListInterface $field /** * Responds to entity PATCH requests. * - * @param \Drupal\Core\Entity\EntityInterface $original_entity + * @param \Drupal\Core\Entity\ContentEntityInterface $original_entity * The original entity object. - * @param \Drupal\Core\Entity\EntityInterface $entity + * @param \Drupal\Core\Entity\ContentEntityInterface $entity * The entity. * * @return \Drupal\rest\ModifiedResourceResponse @@ -245,7 +241,7 @@ protected function getCastedValueFromFieldItemList(FieldItemListInterface $field * * @throws \Symfony\Component\HttpKernel\Exception\HttpException */ - public function patch(EntityInterface $original_entity, EntityInterface $entity = NULL) { + public function patch(ContentEntityInterface $original_entity, ContentEntityInterface $entity = NULL) { if ($entity == NULL) { throw new BadRequestHttpException('No entity content received.'); } @@ -261,49 +257,50 @@ public function patch(EntityInterface $original_entity, EntityInterface $entity // Overwrite the received properties. $entity_keys = $entity->getEntityType()->getKeys(); foreach ($entity->_restSubmittedFields as $field_name) { - $field = $entity->get($field_name); - - // 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)) { - // @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'."); + foreach ($entity->getTranslationLanguages() as $langcode => $language) { + if (!$original_entity->hasTranslation($langcode)) { + $original_entity->addTranslation($langcode, $original_entity->toArray()); } - - // Unchanged values for entity keys don't need access checking. - if ($this->getCastedValueFromFieldItemList($original_entity->get($field_name)) === $this->getCastedValueFromFieldItemList($entity->get($field_name))) { - continue; + $field = $entity->getTranslation($langcode)->get($field_name); + + // 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)) { + // @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->getTranslation($langcode)->get($field_name)->access('edit')) { + throw new AccessDeniedHttpException("Access denied on updating field '$field_name'."); + } + + // Unchanged values for entity keys don't need access checking. + if ($this->getCastedValueFromFieldItemList($original_entity->getTranslation($langcode)->get($field_name)) === $this->getCastedValueFromFieldItemList($field)) { + 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; + } } - // 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; + if (!$original_entity->getTranslation($langcode)->get($field_name)->access('edit')) { + throw new AccessDeniedHttpException("Access denied on updating field '$field_name'."); } - } - if (!$original_entity->get($field_name)->access('edit')) { - throw new AccessDeniedHttpException("Access denied on updating field '$field_name'."); + + $original_entity->getTranslation($langcode)->set($field_name, $field->getValue()); } - $original_entity->set($field_name, $field->getValue()); } // Validate the received data before saving. $this->validate($original_entity); - try { - $original_entity->save(); - $this->logger->notice('Updated entity %type with ID %id.', ['%type' => $original_entity->getEntityTypeId(), '%id' => $original_entity->id()]); + $original_entity->save(); + $this->logger->notice('Updated entity %type with ID %id.', ['%type' => $original_entity->getEntityTypeId(), '%id' => $original_entity->id()]); - // Return the updated entity in the response body. - return new ModifiedResourceResponse($original_entity, 200); - } - catch (EntityStorageException $e) { - throw new HttpException(500, 'Internal Server Error', $e); - } + // Return the updated entity in the response body. + return new ModifiedResourceResponse($original_entity, 200); } /** diff --git a/core/modules/rest/src/Plugin/rest/resource/EntityResourceValidationTrait.php b/core/modules/rest/src/Plugin/rest/resource/EntityResourceValidationTrait.php index 09b4b64..6c51322 100644 --- a/core/modules/rest/src/Plugin/rest/resource/EntityResourceValidationTrait.php +++ b/core/modules/rest/src/Plugin/rest/resource/EntityResourceValidationTrait.php @@ -3,7 +3,7 @@ namespace Drupal\rest\Plugin\rest\resource; use Drupal\Component\Render\PlainTextOutput; -use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; @@ -13,34 +13,34 @@ */ trait EntityResourceValidationTrait { - /** + /**->getTranslation($langcode) * Verifies that the whole entity does not violate any validation constraints. * - * @param \Drupal\Core\Entity\EntityInterface $entity + * @param \Drupal\Core\Entity\ContentEntityInterface $entity * The entity to validate. * * @throws \Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException * If validation errors are found. */ - protected function validate(EntityInterface $entity) { + protected function validate(ContentEntityInterface $entity) { // @todo Remove when https://www.drupal.org/node/2164373 is committed. if (!$entity instanceof FieldableEntityInterface) { return; } - $violations = $entity->validate(); - - // Remove violations of inaccessible fields as they cannot stem from our - // changes. - $violations->filterByFieldAccess(); - - if ($violations->count() > 0) { - $message = "Unprocessable Entity: validation failed.\n"; - foreach ($violations as $violation) { - // We strip every HTML from the error message to have a nicer to read - // message on REST responses. - $message .= $violation->getPropertyPath() . ': ' . PlainTextOutput::renderFromHtml($violation->getMessage()) . "\n"; + foreach ($entity->getTranslationLanguages() as $langcode => $language) { + $violations = $entity->getTranslation($langcode)->validate(); + // Remove violations of inaccessible fields as they cannot stem from our + // changes. + $violations->filterByFieldAccess(); + if ($violations->count() > 0) { + $message = "Unprocessable Entity: validation failed.\n"; + foreach ($violations as $violation) { + // We strip every HTML from the error message to have a nicer to read + // message on REST responses. + $message .= $violation->getPropertyPath() . ': ' . PlainTextOutput::renderFromHtml($violation->getMessage()) . "\n"; + } + throw new UnprocessableEntityHttpException($message); } - throw new UnprocessableEntityHttpException($message); } } diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php index b46eec9..9a68baa 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Comment/CommentResourceTestBase.php @@ -280,8 +280,8 @@ public function testPostDxWithoutCriticalBaseFields() { $response = $this->request('POST', $url, $request_options); // @todo Uncomment, remove next 3 lines in https://www.drupal.org/node/2820364. $this->assertSame(500, $response->getStatusCode()); - $this->assertSame(['text/plain; charset=UTF-8'], $response->getHeader('Content-Type')); - $this->assertSame('Internal Server Error', (string) $response->getBody()); + $this->assertSame(['application/json'], $response->getHeader('Content-Type')); + $this->assertSame('{"message":"A fatal error occurred: The \u0022\u0022 entity type does not exist."}', (string) $response->getBody()); //$this->assertResourceErrorResponse(422, "Unprocessable Entity: validation failed.\nentity_type: This value should not be null.\n", $response); // DX: 422 when missing 'entity_id' field. diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php index 713d403..e09c8b3 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php @@ -424,12 +424,16 @@ public function testGet() { if ($this->entity->getEntityType()->getLinkTemplates()) { $this->assertArrayHasKey('Link', $response->getHeaders()); $link_relation_type_manager = $this->container->get('plugin.manager.link_relation_type'); - $expected_link_relation_headers = array_map(function ($rel) use ($link_relation_type_manager) { + $expected_link_relation_headers = array_filter(array_map(function ($rel) use ($link_relation_type_manager) { + // @todo What to do about content_translation link relationships? + if (strpos($rel, 'drupal:content-translation') !== FALSE) { + return FALSE; + } $definition = $link_relation_type_manager->getDefinition($rel, FALSE); return (!empty($definition['uri'])) ? $definition['uri'] : $rel; - }, array_keys($this->entity->getEntityType()->getLinkTemplates())); + }, array_keys($this->entity->getEntityType()->getLinkTemplates()))); $parse_rel_from_link_header = function ($value) use ($link_relation_type_manager) { $matches = []; if (preg_match('/rel="([^"]+)"/', $value, $matches) === 1) {