Loading api_toolkit.services.yml +6 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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", } } src/ArgumentResolver/ApiRequestResolver.php +24 −1 Original line number Diff line number Diff line Loading @@ -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} */ Loading @@ -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); } } src/Normalizer/ApiRequestNormalizer.php 0 → 100644 +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; } } src/Request/ApiRequestBase.php +2 −90 Original line number Diff line number Diff line Loading @@ -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. Loading @@ -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
api_toolkit.services.yml +6 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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", } }
src/ArgumentResolver/ApiRequestResolver.php +24 −1 Original line number Diff line number Diff line Loading @@ -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} */ Loading @@ -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); } }
src/Normalizer/ApiRequestNormalizer.php 0 → 100644 +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; } }
src/Request/ApiRequestBase.php +2 −90 Original line number Diff line number Diff line Loading @@ -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. Loading @@ -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; } }