Commit 6b5d670e authored by Dieter Holvoet's avatar Dieter Holvoet
Browse files

Issue #3285009 by DieterHolvoet: Populate request objects using a Symfony normalizer

parent bbe76f1e
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -14,6 +14,8 @@ services:

  api_toolkit.argument_resolver.api_request:
    class: Drupal\api_toolkit\ArgumentResolver\ApiRequestResolver
    arguments:
      - '@serializer'

  api_toolkit.validator_factory:
    class: Drupal\api_toolkit\Validation\ValidatorFactory
@@ -28,3 +30,7 @@ services:
    class: Drupal\api_toolkit\Validation\ContextFactory
    arguments:
      - '@api_toolkit.validator'

  api_toolkit.normalizer.api_request:
    class: Drupal\api_toolkit\Normalizer\ApiRequestNormalizer
    tags: [{ name: normalizer }]

composer.json

0 → 100644
+5 −0
Original line number Diff line number Diff line
{
    "require": {
        "symfony/property-access": "^4.4|^5.0",
    }
}
+24 −1
Original line number Diff line number Diff line
@@ -6,12 +6,32 @@ use Drupal\api_toolkit\Request\ApiRequestInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

/**
 * Converts normal Synfomy requests to ApiRequestInterface instances.
 */
class ApiRequestResolver implements ArgumentValueResolverInterface {

  /**
   * The Symfony normalizer.
   *
   * @var \Symfony\Component\Serializer\Normalizer\NormalizerInterface
   */
  protected $normalizer;

  /**
   * Constructs a new ApiRequestResolver object.
   *
   * @param \Symfony\Component\Serializer\Normalizer\NormalizerInterface $normalizer
   *   The Symfony normalizer.
   */
  public function __construct(
    NormalizerInterface $normalizer
  ) {
    $this->normalizer = $normalizer;
  }

  /**
   * {@inheritdoc}
   */
@@ -23,8 +43,11 @@ class ApiRequestResolver implements ArgumentValueResolverInterface {
   * {@inheritdoc}
   */
  public function resolve(Request $request, ArgumentMetadata $argument) {
    /** @var class-string<ApiRequestInterface> $requestClass */
    $requestClass = $argument->getType();
    yield $requestClass::fromRequest($request);

    // Transform to API request class.
    yield $this->normalizer->denormalize($request, $requestClass);
  }

}
+184 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\api_toolkit\Normalizer;

use Drupal\api_toolkit\Exception\ApiValidationException;
use Drupal\api_toolkit\Request\ApiRequestBase;
use Drupal\api_toolkit\Request\ApiRequestInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Validator\Constraints\Type;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use function GuzzleHttp\json_decode;

/**
 *
 */
class ApiRequestNormalizer extends ObjectNormalizer {

  /**
   * A string map with values that should always be mapped to other values.
   *
   * @var string[]
   */
  const VALUE_MAP = [
    '' => NULL,
    'null' => NULL,
    'true' => TRUE,
    'false' => FALSE,
    '[]' => NULL,
  ];

  /**
   * A string map from possible type declarations to types returned by gettype().
   *
   * @var string[]
   */
  const TYPE_MAP = [
    'integer' => 'int',
    'double' => 'float',
    'boolean' => 'bool',
  ];

  /**
   * {@inheritdoc}
   */
  public function denormalize($data, $type, $format = NULL, array $context = []) {
    /** @var \Symfony\Component\HttpFoundation\Request $data */

    $values = [];
    $allValues = array_merge(
      $data->request->all(),
      $data->query->all(),
    );

    // Add non-internal attributes.
    foreach ($data->attributes->all() as $key => $value) {
      if (strpos($key, '_') === 0) {
        continue;
      }

      $allValues[$key] = $value;
    }

    if ($data->getContentType() === 'json') {
      try {
        $content = json_decode($data->getContent(), TRUE, 512, JSON_THROW_ON_ERROR);
        $allValues = array_merge($allValues, $content);
      }
      catch (\JsonException $exception) {
        throw ApiValidationException::create(
          NULL,
          Response::HTTP_BAD_REQUEST,
          sprintf('Error while parsing json: %s', $exception->getMessage()),
          $exception
        );
      }
    }

    $reflection = new \ReflectionClass($type);
    $violations = new ConstraintViolationList();

    foreach ($reflection->getProperties() as $property) {
      // Skip properties from the base class.
      if ($property->getDeclaringClass()->getName() === ApiRequestBase::class) {
        continue;
      }

      $name = $property->getName();
      $typeName = $property->getType()->getName();

      if (!isset($allValues[$name])) {
        continue;
      }

      // Transform values to their proper scalar types.
      $value = $allValues[$name];

      if (is_string($value) && array_key_exists($value, self::VALUE_MAP)) {
        $value = self::VALUE_MAP[$value];
      }

      if (is_array($value)) {
        foreach ($value as $subKey => $subValue) {
          if (is_string($subValue) && array_key_exists($subValue, self::VALUE_MAP)) {
            $value[$subKey] = $subValue = self::VALUE_MAP[$subValue];
          }
          if (is_null($subValue)) {
            unset($value[$subKey]);
          }
        }
      }

      if ($value === NULL && $typeName === 'array') {
        $value = [];
      }

      // Check whether the value is allowed to be assigned to the property.
      // @todo Use DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS once Drupal supports Symfony 5.4
      // @see https://symfony.com/blog/new-in-symfony-5-4-serializer-improvements#collect-denormalization-type-errors
      if (!$this->isValueAllowedOnProperty($value, $property)) {
        $constraint = new Type([
          'type' => self::TYPE_MAP[$typeName] ?? $typeName,
        ]);

        $violation = new ConstraintViolation(
          str_replace('{{ type }}', $typeName, $constraint->message),
          NULL,
          [],
          $allValues[$name],
          $name,
          $value,
          NULL,
          NULL,
          $constraint
        );

        $violations->add($violation);
        continue;
      }

      $values[$name] = $value;

      // If the value comes from the query string, add the right cache context.
      if ($data->query->has($name)) {
        $values['cacheContexts'][] = 'url.query_args:' . $name;
      }
    }

    if ($violations->count() > 0) {
      throw ApiValidationException::create($violations);
    }

    return parent::denormalize($values, $type, $format, $context);
  }

  /**
   * {@inheritdoc}
   */
  public function supportsDenormalization($data, $type, $format = NULL) {
    return $data instanceof Request
      && is_a($type, ApiRequestInterface::class, TRUE);
  }

  /**
   * Check whether a certain value is allowed to be assigned to a certain property.
   */
  protected function isValueAllowedOnProperty($value, \ReflectionProperty $property): bool {
    if ($value === NULL && $property->getType()->allowsNull()) {
      return TRUE;
    }

    $propertyType = $property->getType()->getName();
    $valueType = self::TYPE_MAP[gettype($value)] ?? gettype($value);

    if ($propertyType === $valueType) {
      return TRUE;
    }

    return FALSE;
  }

}
+2 −90
Original line number Diff line number Diff line
@@ -2,12 +2,8 @@

namespace Drupal\api_toolkit\Request;

use Drupal\api_toolkit\Exception\ApiValidationException;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\CacheableDependencyTrait;
use Symfony\Component\HttpFoundation\Response;
use function GuzzleHttp\json_decode;
use Symfony\Component\HttpFoundation\Request;

/**
 * Base class for API requests.
@@ -17,101 +13,17 @@ abstract class ApiRequestBase implements ApiRequestInterface, CacheableDependenc
  use CacheableDependencyTrait;

  /**
   * Get an array with all properties of the given object.
   *
   * We use get_object_vars because uninitialized (not provided) properties are
   * missing from this array. This way, we can differentiate between
   * uninitialized and null values.
   * {@inheritdoc}
   */
  public function all(): array {
    return get_object_vars($this);
  }

  /**
   * Checks whether a property exists and is initialized.
   * {@inheritdoc}
   */
  public function has(string $key): bool {
    return array_key_exists($key, $this->all());
  }

  /**
   * {@inheritdoc}
   */
  public static function fromRequest(Request $request): self {
    // Collect values.
    $values = array_merge(
      $request->request->all(),
      $request->query->all(),
    );

    // Add non-internal attributes.
    foreach ($request->attributes->all() as $key => $value) {
      if (strpos($key, '_') === 0) {
        continue;
      }

      $values[$key] = $value;
    }

    if ($request->getContentType() === 'json' && $content = $request->getContent()) {
      try {
        $content = json_decode($content, TRUE, 512, JSON_THROW_ON_ERROR);
        $values = array_merge($values, $content);
      }
      catch (\JsonException $exception) {
        throw ApiValidationException::create(
          NULL,
          Response::HTTP_BAD_REQUEST,
          sprintf('Error while parsing json: %s', $exception->getMessage()),
          $exception
        );
      }
    }

    // Create instance.
    $instance = static::fromValues($values);

    // Add cacheability metadata.
    $reflection = new \ReflectionClass($instance);
    $cacheContexts = [];
    $cacheTags = [];

    foreach ($reflection->getProperties() as $property) {
      if ($property->getDeclaringClass()->getName() === self::class) {
        continue;
      }

      $attribute = $request->attributes->get($property->getName());
      if ($attribute instanceof CacheableDependencyInterface) {
        $cacheContexts = array_merge($cacheContexts, $attribute->getCacheContexts());
        $cacheTags = array_merge($cacheTags, $attribute->getCacheTags());
      }

      $cacheContexts[] = 'url.query_args:' . $property->getName();
    }

    $instance->cacheContexts = $cacheContexts;
    $instance->cacheTags = $cacheTags;

    return $instance;
  }

  /**
   * Create a new instance from an array of values.
   */
  public static function fromValues(array $values): self {
    $instance = new static();
    $reflection = new \ReflectionClass($instance);

    foreach ($reflection->getProperties() as $property) {
      $key = $property->getName();

      if (array_key_exists($key, $values)) {
        $instance->{$property->getName()} = $values[$key];
      }
    }

    return $instance;
  }

}
Loading