Commit f1e84838 authored by catch's avatar catch
Browse files

Issue #3127883 by Wim Leers, Oscaner, bbrala, ptmkenny, alexpott, jhedstrom,...

Issue #3127883 by Wim Leers, Oscaner, bbrala, ptmkenny, alexpott, jhedstrom, kswamy, catch: JSON:API should provide a helpful error response if there is a typo in a field property name

(cherry picked from commit ec67c18a)
parent 3aebb57f
Loading
Loading
Loading
Loading
+73 −0
Original line number Diff line number Diff line
@@ -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.
   *
+95 −0
Original line number Diff line number Diff line
@@ -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']);
  }

}