diff --git a/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php index 34fbf7612868d3db0591cd5d782770ce3f43dc6d..aebb2bb8fbeeaa9e3ce1186ac79e6ad11d7869c2 100644 --- a/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php +++ b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php @@ -7,11 +7,13 @@ 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; /** @@ -128,6 +130,46 @@ public function denormalize($data, $class, $format = NULL, array $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); @@ -140,6 +182,37 @@ public function denormalize($data, $class, $format = NULL, array $context = []): 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 || strpos($key, $search_key) !== FALSE) { + $alternatives[] = $key; + } + } + + return $alternatives; + } + /** * Gets a field item instance for use with SerializedColumnNormalizerTrait. * diff --git a/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php index f17fba164ba0a66fece4a34c8d45861405a3663a..f88fa41416d0e5d0842a6edc8ff7e5f8a593df9e 100644 --- a/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php +++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php @@ -1393,4 +1393,99 @@ public function testFilteringEntitiesByEntityReferenceTargetId() { $this->assertSame('Article created by ' . $users[1]->uuid(), $document['data'][0]['attributes']['title']); } + /** + * Ensure PATCHing a non-existing field property results in a helpful error. + * + * @see https://www.drupal.org/project/drupal/issues/3127883 + */ + public function testPatchInvalidFieldPropertyFromIssue3127883() { + $this->config('jsonapi.settings')->set('read_only', FALSE)->save(TRUE); + + // Set up data model. + $this->drupalCreateContentType(['type' => 'page']); + $this->rebuildAll(); + + // Create data. + $node = Node::create([ + 'title' => 'foo', + 'type' => 'page', + 'body' => [ + 'format' => 'plain_text', + 'value' => 'Hello World', + ], + ]); + $node->save(); + + // Test. + $user = $this->drupalCreateUser(['bypass node access']); + $url = Url::fromUri('internal:/jsonapi/node/page/' . $node->uuid()); + $request_options = [ + RequestOptions::HEADERS => [ + 'Content-Type' => 'application/vnd.api+json', + 'Accept' => 'application/vnd.api+json', + ], + RequestOptions::AUTH => [$user->getAccountName(), $user->pass_raw], + RequestOptions::JSON => [ + 'data' => [ + 'type' => 'node--page', + 'id' => $node->uuid(), + 'attributes' => [ + 'title' => 'Updated title', + 'body' => [ + 'value' => 'Hello World … still.', + // Intentional typo in the property name! + 'form' => 'plain_text', + // Another intentional typo. + // cSpell:disable-next-line + 'sumary' => 'Boring old "Hello World".', + // And finally, one that is completely absurd. + 'foobarbaz' => '<script>alert("HI!");</script>', + ], + ], + ], + ], + ]; + $response = $this->request('PATCH', $url, $request_options); + + // Assert a helpful error response is present. + $data = Json::decode((string) $response->getBody()); + $this->assertSame(422, $response->getStatusCode()); + $this->assertNotNull($data); + // cSpell:disable-next-line + $this->assertSame("The properties 'form', 'sumary', 'foobarbaz' do not exist on the 'body' field of type 'text_with_summary'. Writable properties are: 'value', 'format', 'summary'.", $data['errors'][0]['detail']); + + $request_options = [ + RequestOptions::HEADERS => [ + 'Content-Type' => 'application/vnd.api+json', + 'Accept' => 'application/vnd.api+json', + ], + RequestOptions::AUTH => [$user->getAccountName(), $user->pass_raw], + RequestOptions::JSON => [ + 'data' => [ + 'type' => 'node--page', + 'id' => $node->uuid(), + 'attributes' => [ + 'title' => 'Updated title', + 'body' => [ + 'value' => 'Hello World … still.', + // Intentional typo in the property name! + 'form' => 'plain_text', + // Another intentional typo. + // cSpell:disable-next-line + 'sumary' => 'Boring old "Hello World".', + ], + ], + ], + ], + ]; + $response = $this->request('PATCH', $url, $request_options); + + // Assert a helpful error response is present. + $data = Json::decode((string) $response->getBody()); + $this->assertSame(422, $response->getStatusCode()); + $this->assertNotNull($data); + // cSpell:disable-next-line + $this->assertSame("The properties 'form', 'sumary' do not exist on the 'body' field of type 'text_with_summary'. Did you mean 'format', 'summary'?", $data['errors'][0]['detail']); + } + }