diff --git a/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php b/core/modules/jsonapi/src/Normalizer/FieldItemNormalizer.php index 34fbf76128..8f4d28ef1f 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,29 @@ public function denormalize($data, $class, $format = NULL, array $context = []): $data_internal = []; if (!empty($property_definitions)) { + $invalid_property_names = []; + foreach ($data as $property_name => $property_value) { + if (!isset($property_definitions[$property_name])) { + $alt = static::getAlternatives($property_name, array_keys($property_definitions)); + $invalid_property_names[$property_name] = reset($alt); + } + } + if (!empty($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'? (Writable properties are: '%s'.)" + : "The properties '%s' do not exist on the '%s' field of type '%s'. Did you mean '%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("', '", array_values(array_filter($invalid_property_names))), + implode("', '", array_keys(array_filter($property_definitions, function (DataDefinitionInterface $data_definition) : bool { + return !$data_definition->isReadOnly(); + }))) + )); + } + 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 +165,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 f17fba164b..68242c7261 100644 --- a/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php +++ b/core/modules/jsonapi/tests/src/Functional/JsonApiRegressionTest.php @@ -1393,4 +1393,66 @@ 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' => '', + ], + ], + ], + ], + ]; + $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'. Did you mean 'format', 'summary'? (Writable properties are: 'value', 'format', 'summary'.)", $data['errors'][0]['detail']); + } + }