Skip to content

refactor(metadata): type parameters to list<string>|string #7134

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"symfony/property-info": "^6.4 || ^7.1",
"symfony/serializer": "^6.4 || ^7.0",
"symfony/translation-contracts": "^3.3",
"symfony/type-info": "^7.2",
"symfony/web-link": "^6.4 || ^7.1",
"willdurand/negotiation": "^3.1"
},
Expand Down
5 changes: 5 additions & 0 deletions src/Laravel/Eloquent/Extension/FilterQueryExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ public function apply(Builder $builder, array $uriVariables, Operation $operatio
continue;
}

// most eloquent filters work with only a single value
if (\is_array($values) && array_is_list($values) && 1 === \count($values)) {
$values = current($values);
}

$filter = $filterId instanceof FilterInterface ? $filterId : ($this->filterLocator->has($filterId) ? $this->filterLocator->get($filterId) : null);
if ($filter instanceof FilterInterface) {
$builder = $filter->apply($builder, $values, $parameter, $context + ($parameter->getFilterContext() ?? []));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ public function provide(Parameter $parameter, array $parameters = [], array $con
$parameters = $operation->getParameters();
$properties = $parameter->getExtraProperties()['_properties'] ?? [];
$value = $parameter->getValue();

// most eloquent filters work with only a single value
if (\is_array($value) && array_is_list($value) && 1 === \count($value)) {
$value = current($value);
}

if (!\is_string($value)) {
return $operation;
}
Expand Down
16 changes: 16 additions & 0 deletions src/Metadata/Parameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
use ApiPlatform\State\ParameterNotFound;
use ApiPlatform\State\ParameterProviderInterface;
use Symfony\Component\TypeInfo\Type;

/**
* @experimental
Expand All @@ -29,6 +30,7 @@ abstract class Parameter
* @param list<string> $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)
*/
public function __construct(
protected ?string $key = null,
Expand All @@ -47,6 +49,7 @@ public function __construct(
protected ?string $securityMessage = null,
protected ?array $extraProperties = [],
protected array|string|null $filterContext = null,
protected ?Type $nativeType = null,
) {
}

Expand Down Expand Up @@ -296,4 +299,17 @@ public function withProperties(?array $properties): self

return $self;
}

public function getNativeType(): ?Type
{
return $this->nativeType;
}

public function withNativeType(Type $nativeType): self
{
$self = clone $this;
$self->nativeType = $nativeType;

return $self;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
use Symfony\Component\TypeInfo\Type;

/**
* Prepares Parameters documentation by reading its filter details and declaring an OpenApi parameter.
Expand Down Expand Up @@ -148,6 +149,16 @@ private function getDefaultParameters(Operation $operation, string $resourceClas
}

$parameter = $this->setDefaults($key, $parameter, $resourceClass, $properties, $operation);
// We don't do any type cast yet, a query parameter or an header is always a string or a list of strings
if (null === $parameter->getNativeType()) {
// this forces the type to be only a list
if ('array' === ($parameter->getSchema()['type'] ?? null)) {
$parameter = $parameter->withNativeType(Type::list(Type::string()));
} else {
$parameter = $parameter->withNativeType(Type::union(Type::string(), Type::list(Type::string())));
}
}

$priority = $parameter->getPriority() ?? $internalPriority--;
$parameters->add($key, $parameter->withPriority($priority));
}
Expand Down
2 changes: 1 addition & 1 deletion src/Metadata/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/property-info": "^6.4 || ^7.1",
"symfony/string": "^6.4 || ^7.0",
"symfony/type-info": "^7.1"
"symfony/type-info": "^7.2"
},
"require-dev": {
"api-platform/json-schema": "^4.1",
Expand Down
31 changes: 26 additions & 5 deletions src/State/Util/ParameterParserTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@

namespace ApiPlatform\State\Util;

use ApiPlatform\Metadata\HeaderParameter;
use ApiPlatform\Metadata\HeaderParameterInterface;
use ApiPlatform\Metadata\Parameter;
use ApiPlatform\State\ParameterNotFound;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\TypeInfo\Type\CollectionType;
use Symfony\Component\TypeInfo\Type\UnionType;

/**
* @internal
Expand Down Expand Up @@ -64,11 +67,7 @@ private function extractParameterValues(Parameter $parameter, array $values): st
}

$value = $values[$key] ?? new ParameterNotFound();
if (!$accessors) {
return $value;
}

foreach ($accessors as $accessor) {
foreach ($accessors ?? [] as $accessor) {
if (\is_array($value) && isset($value[$accessor])) {
$value = $value[$accessor];
} else {
Expand All @@ -77,6 +76,28 @@ private function extractParameterValues(Parameter $parameter, array $values): st
}
}

if ($value instanceof ParameterNotFound) {
return $value;
}

$isCollectionType = fn ($t) => $t instanceof CollectionType;
$isCollection = $parameter->getNativeType()?->isSatisfiedBy($isCollectionType) ?? false;

// type-info 7.2
if (!$isCollection && $parameter->getNativeType() instanceof UnionType) {
foreach ($parameter->getNativeType()->getTypes() as $t) {
if ($isCollection = $t->isSatisfiedBy($isCollectionType)) {
break;
}
}
}

if ($isCollection) {
$value = \is_array($value) ? $value : [$value];
} elseif ($parameter instanceof HeaderParameter && \is_array($value) && array_is_list($value) && 1 === \count($value)) {
$value = $value[0];
}

Comment on lines +79 to +100
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello 👋

There is a BC break here.

On on resource, we have this (simplified version) :

#[ApiResource(
    shortName: 'PublicDraft',
    operations: [
        new GetCollection(
            parameters: new Parameters([
                new QueryParameter(
                    key: 'triggerUrl',
                    constraints: [new Source()],
                ),
            ])
        ),
    ],
)]

our request looks like this

curl --request GET \
  --url 'https://api.redirection-io.test/app_dev.php/drafts?projectId=d98a60e4-56f4-11ea-8b8d-0242ac150003&triggerUrl=foobar' \
  --header 'Authorization: Bearer 4781249f79bfbdfb1f3c11b2cfc88c5e1a434c9c579b19f5315ec5677051'

And in our validator, we use to receive a string, now we receive a array

We have symfony/type-info: 7.2.5 (but we do not reach line 88), because the $parameter->getNativeType is already a collection :

image

I tried to specify the nativeType, but I got the same result. It looks like it's not used.

nativeType: new BuiltinType(TypeIdentifier::STRING),

Copy link
Contributor

@lyrixx lyrixx May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I missed #7157, sorry ! and the last release fixed it. Thanks!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also parameters are experimental :D

return $value;
}
}
11 changes: 2 additions & 9 deletions src/Symfony/Validator/State/ParameterValidatorProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
$value = null;
}

$violations = [];
if (\is_array($value) && $properties = $parameter->getExtraProperties()['_properties'] ?? []) {
foreach ($properties as $property) {
$violations = [...$violations, ...$this->validator->validate($value[$property] ?? null, $constraints)];
}
} else {
$violations = $this->validator->validate($value, $constraints);
}
$violations = $this->validator->validate($value, $constraints);

foreach ($violations as $violation) {
$constraintViolationList->add(new ConstraintViolation(
Expand Down Expand Up @@ -108,7 +101,7 @@ private function getProperty(Parameter $parameter, ConstraintViolationInterface
}

if ($p = $violation->getPropertyPath()) {
return $p;
return $key.$p;
}

return $key;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter;
use Psr\Container\ContainerInterface;
use Symfony\Component\TypeInfo\Type\CollectionType;
use Symfony\Component\TypeInfo\Type\UnionType;
use Symfony\Component\Validator\Constraints\All;
use Symfony\Component\Validator\Constraints\Choice;
use Symfony\Component\Validator\Constraints\Collection;
use Symfony\Component\Validator\Constraints\Count;
use Symfony\Component\Validator\Constraints\DivisibleBy;
use Symfony\Component\Validator\Constraints\GreaterThan;
Expand All @@ -31,6 +35,7 @@
use Symfony\Component\Validator\Constraints\LessThanOrEqual;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
use Symfony\Component\Validator\Constraints\Range;
use Symfony\Component\Validator\Constraints\Regex;
use Symfony\Component\Validator\Constraints\Type;
use Symfony\Component\Validator\Constraints\Unique;
Expand Down Expand Up @@ -108,55 +113,82 @@ private function addSchemaValidation(Parameter $parameter, ?array $schema = null
}

$assertions = [];
$allowEmptyValue = $openApi?->getAllowEmptyValue();
if (false === ($allowEmptyValue ?? $openApi?->getAllowEmptyValue())) {
$assertions[] = new NotBlank(allowNull: !$required);
}

if ($required && false !== ($allowEmptyValue = $openApi?->getAllowEmptyValue())) {
$assertions[] = new NotNull(message: \sprintf('The parameter "%s" is required.', $parameter->getKey()));
$minimum = $schema['exclusiveMinimum'] ?? $schema['minimum'] ?? null;
$exclusiveMinimum = isset($schema['exclusiveMinimum']);
$maximum = $schema['exclusiveMaximum'] ?? $schema['maximum'] ?? null;
$exclusiveMaximum = isset($schema['exclusiveMaximum']);

if ($minimum && $maximum) {
if (!$exclusiveMinimum && !$exclusiveMaximum) {
$assertions[] = new Range(min: $minimum, max: $maximum);
} else {
$assertions[] = $exclusiveMinimum ? new GreaterThan(value: $minimum) : new GreaterThanOrEqual(value: $minimum);
$assertions[] = $exclusiveMaximum ? new LessThan(value: $maximum) : new LessThanOrEqual(value: $maximum);
}
} elseif ($minimum) {
$assertions[] = $exclusiveMinimum ? new GreaterThan(value: $minimum) : new GreaterThanOrEqual(value: $minimum);
} elseif ($maximum) {
$assertions[] = $exclusiveMaximum ? new LessThan(value: $maximum) : new LessThanOrEqual(value: $maximum);
}

if (false === ($allowEmptyValue ?? $openApi?->getAllowEmptyValue())) {
$assertions[] = new NotBlank(allowNull: !$required);
if (isset($schema['pattern'])) {
$assertions[] = new Regex('#'.$schema['pattern'].'#');
}

if (isset($schema['exclusiveMinimum'])) {
$assertions[] = new GreaterThan(value: $schema['exclusiveMinimum']);
if (isset($schema['maxLength']) || isset($schema['minLength'])) {
$assertions[] = new Length(min: $schema['minLength'] ?? null, max: $schema['maxLength'] ?? null);
}

if (isset($schema['exclusiveMaximum'])) {
$assertions[] = new LessThan(value: $schema['exclusiveMaximum']);
if (isset($schema['multipleOf'])) {
$assertions[] = new DivisibleBy(value: $schema['multipleOf']);
}

if (isset($schema['enum'])) {
$assertions[] = new Choice(choices: $schema['enum']);
}

if (isset($schema['minimum'])) {
$assertions[] = new GreaterThanOrEqual(value: $schema['minimum']);
if ($properties = $parameter->getExtraProperties()['_properties'] ?? []) {
$fields = [];
foreach ($properties as $propertyName) {
$fields[$propertyName] = $assertions;
}

return $parameter->withConstraints(new Collection(fields: $fields, allowMissingFields: true));
}

if (isset($schema['maximum'])) {
$assertions[] = new LessThanOrEqual(value: $schema['maximum']);
$isCollectionType = fn ($t) => $t instanceof CollectionType;
$isCollection = $parameter->getNativeType()?->isSatisfiedBy($isCollectionType) ?? false;

// type-info 7.2
if (!$isCollection && $parameter->getNativeType() instanceof UnionType) {
foreach ($parameter->getNativeType()->getTypes() as $t) {
if ($isCollection = $t->isSatisfiedBy($isCollectionType)) {
break;
}
}
}

if (isset($schema['pattern'])) {
$assertions[] = new Regex('#'.$schema['pattern'].'#');
if ($isCollection) {
$assertions = $assertions ? [new All($assertions)] : [];
}

if (isset($schema['maxLength']) || isset($schema['minLength'])) {
$assertions[] = new Length(min: $schema['minLength'] ?? null, max: $schema['maxLength'] ?? null);
if ($required && false !== $allowEmptyValue) {
$assertions[] = new NotNull(message: \sprintf('The parameter "%s" is required.', $parameter->getKey()));
}

if (isset($schema['minItems']) || isset($schema['maxItems'])) {
$assertions[] = new Count(min: $schema['minItems'] ?? null, max: $schema['maxItems'] ?? null);
}

if (isset($schema['multipleOf'])) {
$assertions[] = new DivisibleBy(value: $schema['multipleOf']);
}

if ($schema['uniqueItems'] ?? false) {
$assertions[] = new Unique();
}

if (isset($schema['enum'])) {
$assertions[] = new Choice(choices: $schema['enum']);
}

if (isset($schema['type']) && 'array' === $schema['type']) {
$assertions[] = new Type(type: 'array');
}
Expand Down
1 change: 1 addition & 0 deletions src/Validator/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"require": {
"php": ">=8.2",
"api-platform/metadata": "^4.1",
"symfony/type-info": "^7.2",
"symfony/web-link": "^6.4 || ^7.1"
},
"require-dev": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Parameter;
use ApiPlatform\Metadata\QueryParameter;
use Symfony\Component\TypeInfo\Type\BuiltinType;
use Symfony\Component\TypeInfo\TypeIdentifier;

#[GetCollection(
uriTemplate: 'issue6673_multiple_parameter_provider',
Expand All @@ -25,9 +27,11 @@
parameters: [
'a' => new QueryParameter(
provider: [self::class, 'parameterOneProvider'],
nativeType: new BuiltinType(TypeIdentifier::STRING),
),
'b' => new QueryParameter(
provider: [self::class, 'parameterTwoProvider'],
nativeType: new BuiltinType(TypeIdentifier::STRING),
),
],
provider: [self::class, 'provide']
Expand Down
Loading
Loading