diff --git a/src/Laravel/workbench/app/Models/Book.php b/src/Laravel/workbench/app/Models/Book.php index 9a2127d0c9..f083a8386b 100644 --- a/src/Laravel/workbench/app/Models/Book.php +++ b/src/Laravel/workbench/app/Models/Book.php @@ -37,6 +37,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; use Workbench\App\Http\Requests\BookFormRequest; #[ApiResource( @@ -79,7 +81,7 @@ property: 'name' )] #[QueryParameter(key: 'properties', filter: PropertyFilter::class)] -#[QueryParameter(key: 'published', filter: BooleanFilter::class)] +#[QueryParameter(key: 'published', filter: BooleanFilter::class, nativeType: new BuiltinType(TypeIdentifier::BOOL))] class Book extends Model { use HasFactory; diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 65461c6518..3b4b27e018 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -27,10 +27,12 @@ abstract class Parameter * @param (array&array{type?: string, default?: string})|null $schema * @param array $extraProperties * @param ParameterProviderInterface|callable|string|null $provider - * @param list $properties a list of properties this parameter applies to (works with the :property placeholder) + * @param list $properties a list of properties this parameter applies to (works with the :property placeholder) * @param FilterInterface|string|null $filter - * @param mixed $constraints an array of Symfony constraints, or an array of Laravel rules - * @param Type $nativeType the PHP native type, we cast values to an array if its a CollectionType, if not and it's an array with a single value we use it (eg: HTTP Header) + * @param mixed $constraints an array of Symfony constraints, or an array of Laravel rules + * @param Type $nativeType the PHP native type, we cast values to an array if its a CollectionType, if not and it's an array with a single value we use it (eg: HTTP Header) + * @param ?bool $castToNativeType whether API Platform should cast your parameter to the nativeType declared + * @param ?callable(mixed): mixed $castFn the closure used to cast your parameter, this gets called only when $castToNativeType is set */ public function __construct( protected ?string $key = null, @@ -51,6 +53,8 @@ public function __construct( protected array|string|null $filterContext = null, protected ?Type $nativeType = null, protected ?bool $castToArray = null, + protected ?bool $castToNativeType = null, + protected mixed $castFn = null, ) { } @@ -332,4 +336,33 @@ public function withCastToArray(bool $castToArray): self return $self; } + + public function getCastToNativeType(): ?bool + { + return $this->castToNativeType; + } + + public function withCastToNativeType(bool $castToNativeType): self + { + $self = clone $this; + $self->castToNativeType = $castToNativeType; + + return $self; + } + + public function getCastFn(): ?callable + { + return $this->castFn; + } + + /** + * @param callable(mixed): mixed $castFn + */ + public function withCastFn(mixed $castFn): self + { + $self = clone $this; + $self->castFn = $castFn; + + return $self; + } } diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 738ce13ea3..2f01c1b03d 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -28,11 +28,13 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; +use ApiPlatform\State\Parameter\ValueCaster; use ApiPlatform\State\Util\StateOptionsTrait; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Prepares Parameters documentation by reading its filter details and declaring an OpenApi parameter. @@ -158,11 +160,27 @@ private function getDefaultParameters(Operation $operation, string $resourceClas $parameter = $parameter->withNativeType(Type::string()); } elseif ('boolean' === ($parameter->getSchema()['type'] ?? null)) { $parameter = $parameter->withNativeType(Type::bool()); + } elseif ('integer' === ($parameter->getSchema()['type'] ?? null)) { + $parameter = $parameter->withNativeType(Type::int()); + } elseif ('number' === ($parameter->getSchema()['type'] ?? null)) { + $parameter = $parameter->withNativeType(Type::float()); } else { $parameter = $parameter->withNativeType(Type::union(Type::string(), Type::list(Type::string()))); } } + if ($parameter->getCastToNativeType() && null === $parameter->getCastFn() && ($nativeType = $parameter->getNativeType())) { + if ($nativeType->isIdentifiedBy(TypeIdentifier::BOOL)) { + $parameter = $parameter->withCastFn([ValueCaster::class, 'toBool']); + } + if ($nativeType->isIdentifiedBy(TypeIdentifier::INT)) { + $parameter = $parameter->withCastFn([ValueCaster::class, 'toInt']); + } + if ($nativeType->isIdentifiedBy(TypeIdentifier::FLOAT)) { + $parameter = $parameter->withCastFn([ValueCaster::class, 'toFloat']); + } + } + $priority = $parameter->getPriority() ?? $internalPriority--; $parameters->add($key, $parameter->withPriority($priority)); } diff --git a/src/State/Parameter/ValueCaster.php b/src/State/Parameter/ValueCaster.php new file mode 100644 index 0000000000..d876f44b73 --- /dev/null +++ b/src/State/Parameter/ValueCaster.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Parameter; + +/** + * Caster returns the default value when a value can not be casted + * This is used by parameters before they get validated by constraints + * Therefore we do not need to throw exceptions, validation will just fail. + * + * @internal + */ +final class ValueCaster +{ + public static function toBool(mixed $v): mixed + { + if (!\is_string($v)) { + return $v; + } + + return match (strtolower($v)) { + '1', 'true' => true, + '0', 'false' => false, + default => $v, + }; + } + + public static function toInt(mixed $v): mixed + { + if (\is_int($v)) { + return $v; + } + + $value = filter_var($v, \FILTER_VALIDATE_INT); + + return false === $value ? $v : $value; + } + + public static function toFloat(mixed $v): mixed + { + if (\is_float($v)) { + return $v; + } + + $value = filter_var($v, \FILTER_VALIDATE_FLOAT); + + return false === $value ? $v : $value; + } +} diff --git a/src/State/Util/ParameterParserTrait.php b/src/State/Util/ParameterParserTrait.php index 26bc2630e1..a23ecbe864 100644 --- a/src/State/Util/ParameterParserTrait.php +++ b/src/State/Util/ParameterParserTrait.php @@ -42,10 +42,8 @@ private function getParameterValues(Parameter $parameter, ?Request $request, arr /** * @param array $values - * - * @return array|ParameterNotFound|array */ - private function extractParameterValues(Parameter $parameter, array $values): string|ParameterNotFound|array + private function extractParameterValues(Parameter $parameter, array $values): mixed { $accessors = null; $key = $parameter->getKey(); @@ -72,7 +70,6 @@ private function extractParameterValues(Parameter $parameter, array $values): st $value = $value[$accessor]; } else { $value = new ParameterNotFound(); - continue; } } @@ -100,6 +97,14 @@ private function extractParameterValues(Parameter $parameter, array $values): st $value = $value[0]; } + if (true === $parameter->getCastToNativeType() && ($castFn = $parameter->getCastFn())) { + if (\is_array($value)) { + $value = array_map(fn ($v) => $castFn($v, $parameter), $value); + } else { + $value = $castFn($value, $parameter); + } + } + return $value; } } diff --git a/src/State/composer.json b/src/State/composer.json index 6fb4675771..f788152dd2 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -28,7 +28,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1.11", + "api-platform/metadata": "^4.1.18", "psr/container": "^1.0 || ^2.0", "symfony/http-kernel": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", diff --git a/src/Validator/Util/ParameterValidationConstraints.php b/src/Validator/Util/ParameterValidationConstraints.php index 424cad4566..93fcc7502a 100644 --- a/src/Validator/Util/ParameterValidationConstraints.php +++ b/src/Validator/Util/ParameterValidationConstraints.php @@ -148,6 +148,18 @@ public static function getParameterValidationConstraints(Parameter $parameter, ? $assertions[] = new Type(type: 'array'); } + if (isset($schema['type']) && $parameter->getCastToNativeType()) { + $assertion = match ($schema['type']) { + 'boolean', 'integer' => new Type(type: $schema['type']), + 'number' => new Type(type: 'float'), + default => null, + }; + + if ($assertion) { + $assertions[] = $assertion; + } + } + return $assertions; } } diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php index 677574f258..ec0cf73047 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -171,7 +171,77 @@ 'minimum' => 1, 'maximum' => 5, ], - required: true, + ), + ], + provider: [self::class, 'noopProvider'] +)] +#[GetCollection( + uriTemplate: 'header_float', + parameters: [ + 'Bar' => new HeaderParameter( + schema: [ + 'type' => 'number', + 'example' => 42.0, + 'minimum' => 1.0, + 'maximum' => 100.0, + 'multipleOf' => 0.01, + ], + castToNativeType: true + ), + ], + provider: [self::class, 'noopProvider'] +)] +#[GetCollection( + uriTemplate: 'header_boolean', + parameters: [ + 'Lorem' => new HeaderParameter( + schema: [ + 'type' => 'boolean', + ], + castToNativeType: true, + ), + ], + provider: [self::class, 'noopProvider'] +)] +#[GetCollection( + uriTemplate: 'query_integer', + parameters: [ + 'Foo' => new QueryParameter( + schema: [ + 'type' => 'integer', + 'example' => 3, + 'minimum' => 1, + 'maximum' => 5, + ], + castToNativeType: true + ), + ], + provider: [self::class, 'noopProvider'] +)] +#[GetCollection( + uriTemplate: 'query_float', + parameters: [ + 'Bar' => new QueryParameter( + schema: [ + 'type' => 'number', + 'example' => 42.0, + 'minimum' => 1.0, + 'maximum' => 100.0, + 'multipleOf' => 0.01, + ], + castToNativeType: true + ), + ], + provider: [self::class, 'noopProvider'] +)] +#[GetCollection( + uriTemplate: 'query_boolean', + parameters: [ + 'Lorem' => new QueryParameter( + schema: [ + 'type' => 'boolean', + ], + castToNativeType: true, ), ], provider: [self::class, 'noopProvider'] diff --git a/tests/Functional/Parameters/ParameterTest.php b/tests/Functional/Parameters/ParameterTest.php index 42c217d0af..2df72c458c 100644 --- a/tests/Functional/Parameters/ParameterTest.php +++ b/tests/Functional/Parameters/ParameterTest.php @@ -121,18 +121,72 @@ public function testHeaderParameterRequired(): void } #[DataProvider('provideHeaderValues')] - public function testHeaderParameterInteger(string $value, int $expectedStatusCode): void + public function testHeaderParameter(string $url, array $headers, int $expectedStatusCode): void { - self::createClient()->request('GET', 'header_integer', ['headers' => ['Foo' => $value]]); + self::createClient()->request('GET', $url, ['headers' => $headers]); $this->assertResponseStatusCodeSame($expectedStatusCode); } public static function provideHeaderValues(): iterable { - yield 'valid integer' => ['3', 200]; - yield 'too high' => ['6', 422]; - yield 'too low' => ['0', 422]; - yield 'invalid integer' => ['string', 422]; + // header_integer + yield 'missing header header_integer' => ['header_integer', [], 200]; + yield 'valid integer header_integer' => ['header_integer', ['Foo' => '3'], 200]; + yield 'too high header_integer' => ['header_integer', ['Foo' => '6'], 422]; + yield 'too low header_integer' => ['header_integer', ['Foo' => '0'], 422]; + yield 'invalid integer header_integer' => ['header_integer', ['Foo' => 'string'], 422]; + + // header_float + yield 'missing header header_float' => ['header_float', [], 200]; + yield 'valid float header_float' => ['header_float', ['Bar' => '3.5'], 200]; + yield 'valid integer header_float' => ['header_float', ['Bar' => '3'], 200]; + yield 'too high header_float' => ['header_float', ['Bar' => '600'], 422]; + yield 'too low header_float' => ['header_float', ['Bar' => '0'], 422]; + yield 'invalid number header_float' => ['header_float', ['Bar' => 'string'], 422]; + + // header_boolean + yield 'missing header header_boolean' => ['header_boolean', [], 200]; + yield 'valid boolean false header_boolean' => ['header_boolean', ['Lorem' => 'false'], 200]; + yield 'valid boolean true header_boolean' => ['header_boolean', ['Lorem' => 'true'], 200]; + yield 'valid boolean 0 header_boolean' => ['header_boolean', ['Lorem' => 0], 200]; + yield 'valid boolean 0 string header_boolean' => ['header_boolean', ['Lorem' => '0'], 200]; + yield 'valid boolean 1 header_boolean' => ['header_boolean', ['Lorem' => 1], 200]; + yield 'valid boolean 1 string header_boolean' => ['header_boolean', ['Lorem' => '1'], 200]; + yield 'invalid boolean header_boolean' => ['header_boolean', ['Lorem' => 'string'], 422]; + } + + #[DataProvider('provideQueryValues')] + public function testQueryParameter(string $url, array $query, int $expectedStatusCode): void + { + self::createClient()->request('GET', $url, ['query' => $query]); + $this->assertResponseStatusCodeSame($expectedStatusCode); + } + + public static function provideQueryValues(): iterable + { + // query_integer + yield 'valid integer query_integer' => ['query_integer', ['Foo' => '3'], 200]; + yield 'too high query_integer' => ['query_integer', ['Foo' => '6'], 422]; + yield 'too low query_integer' => ['query_integer', ['Foo' => '0'], 422]; + yield 'invalid integer query_integer' => ['query_integer', ['Foo' => 'string'], 422]; + + // query_float + yield 'valid float query_float' => ['query_float', ['Bar' => '3.5'], 200]; + yield 'valid integer query_float' => ['query_float', ['Bar' => '3'], 200]; + yield 'too high query_float' => ['query_float', ['Bar' => '600'], 422]; + yield 'too low query_float' => ['query_float', ['Bar' => '0'], 422]; + yield 'invalid number query_float' => ['query_float', ['Bar' => 'string'], 422]; + + // query_boolean + yield 'valid boolean false query_boolean' => ['query_boolean', ['Lorem' => false], 200]; + yield 'valid boolean false string query_boolean' => ['query_boolean', ['Lorem' => 'false'], 200]; + yield 'valid boolean true query_boolean' => ['query_boolean', ['Lorem' => true], 200]; + yield 'valid boolean true string query_boolean' => ['query_boolean', ['Lorem' => 'true'], 200]; + yield 'valid boolean 0 query_boolean' => ['query_boolean', ['Lorem' => 0], 200]; + yield 'valid boolean 0 string query_boolean' => ['query_boolean', ['Lorem' => '0'], 200]; + yield 'valid boolean 1 query_boolean' => ['query_boolean', ['Lorem' => 1], 200]; + yield 'valid boolean 1 string query_boolean' => ['query_boolean', ['Lorem' => '1'], 200]; + yield 'invalid boolean query_boolean' => ['query_boolean', ['Lorem' => 'string'], 422]; } #[DataProvider('provideCountryValues')]