diff --git a/src/Metadata/Exception/AccessDeniedException.php b/src/Metadata/Exception/AccessDeniedException.php new file mode 100644 index 00000000000..2bbe90fba1c --- /dev/null +++ b/src/Metadata/Exception/AccessDeniedException.php @@ -0,0 +1,29 @@ + + * + * 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\Metadata\Exception; + +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; + +final class AccessDeniedException extends AccessDeniedHttpException implements HttpExceptionInterface +{ + public function getStatusCode(): int + { + return 403; + } + + public function getHeaders(): array + { + return []; + } +} diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 454b2eacac1..af0c8a11ca7 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -349,6 +349,9 @@ public function getUriVariables() return $this->uriVariables; } + /** + * @param array|array|list $uriVariables + */ public function withUriVariables($uriVariables): static { $self = clone $this; diff --git a/src/Metadata/Link.php b/src/Metadata/Link.php index 3c4390f6e44..478c1327b9b 100644 --- a/src/Metadata/Link.php +++ b/src/Metadata/Link.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Metadata; use ApiPlatform\OpenApi; +use Symfony\Component\TypeInfo\Type; #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)] final class Link extends Parameter @@ -27,7 +28,8 @@ public function __construct( private ?array $identifiers = null, private ?bool $compositeIdentifier = null, private ?string $expandedValue = null, - ?string $security = null, + + string|\Stringable|null $security = null, ?string $securityMessage = null, private ?string $securityObjectName = null, @@ -37,9 +39,15 @@ public function __construct( mixed $provider = null, mixed $filter = null, ?string $property = null, + ?array $properties = null, ?string $description = null, ?bool $required = null, array $extraProperties = [], + + mixed $constraints = null, + array|string|null $filterContext = null, + ?Type $nativeType = null, + ?bool $castToArray = null, ) { // For the inverse property shortcut if ($this->parameterName && class_exists($this->parameterName)) { @@ -53,11 +61,16 @@ public function __construct( provider: $provider, filter: $filter, property: $property, + properties: $properties, description: $description, required: $required, + constraints: $constraints, security: $security, securityMessage: $securityMessage, - extraProperties: $extraProperties + extraProperties: $extraProperties, + filterContext: $filterContext, + nativeType: $nativeType, + castToArray: $castToArray, ); } diff --git a/src/Metadata/Parameter.php b/src/Metadata/Parameter.php index 0e070435156..65461c65184 100644 --- a/src/Metadata/Parameter.php +++ b/src/Metadata/Parameter.php @@ -133,6 +133,12 @@ public function getValue(mixed $default = new ParameterNotFound()): mixed return $this->extraProperties['_api_values'] ?? $default; } + /** + * Only use this in a parameter provider, the ApiPlatform\State\Provider\ParameterProvider + * resets this value to extract the correct value on each request. + * It's also possible to set the `_api_query_parameters` request attribute directly and + * API Platform will extract the value from there. + */ public function setValue(mixed $value): static { $this->extraProperties['_api_values'] = $value; diff --git a/src/Metadata/Parameters.php b/src/Metadata/Parameters.php index 2f20937cb64..7ada6de542a 100644 --- a/src/Metadata/Parameters.php +++ b/src/Metadata/Parameters.php @@ -122,6 +122,19 @@ public function has(string $key, string $parameterClass = QueryParameter::class) return false; } + /** + * @return list + */ + public function keys(): array + { + $keys = []; + foreach ($this->parameters as [$key]) { + $keys[] = $key; + } + + return $keys; + } + public function count(): int { return \count($this->parameters); diff --git a/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php index feace7c512c..3f973efeef0 100644 --- a/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/UriTemplateResourceMetadataCollectionFactory.php @@ -235,7 +235,7 @@ private function normalizeUriVariables(ApiResource|HttpOperation $operation): Ap } $normalizedUriVariable = $normalizedUriVariable->withParameterName($normalizedParameterName); - $normalizedUriVariables[$normalizedParameterName] = $normalizedUriVariable; + $normalizedUriVariables[$normalizedParameterName] = $normalizedUriVariable->withKey($normalizedParameterName); } return $operation->withUriVariables($normalizedUriVariables); diff --git a/src/Metadata/Tests/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php index 57a51109a6c..90ac6b9bac8 100644 --- a/src/Metadata/Tests/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/UriTemplateResourceMetadataCollectionFactoryTest.php @@ -139,10 +139,37 @@ class: AttributeResource::class, shortName: 'AttributeResource', class: AttributeResource::class, operations: [ - '_api_/attribute_resources/{id}{._format}_get' => new Get(uriTemplate: '/attribute_resources/{id}{._format}', shortName: 'AttributeResource', class: AttributeResource::class, controller: 'api_platform.action.placeholder', uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'], parameterName: 'id')], name: '_api_/attribute_resources/{id}{._format}_get'), - '_api_/attribute_resources/{id}{._format}_put' => new Put(uriTemplate: '/attribute_resources/{id}{._format}', shortName: 'AttributeResource', class: AttributeResource::class, controller: 'api_platform.action.placeholder', uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'], parameterName: 'id')], name: '_api_/attribute_resources/{id}{._format}_put'), - '_api_/attribute_resources/{id}{._format}_delete' => new Delete(uriTemplate: '/attribute_resources/{id}{._format}', shortName: 'AttributeResource', class: AttributeResource::class, controller: 'api_platform.action.placeholder', uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'], parameterName: 'id')], name: '_api_/attribute_resources/{id}{._format}_delete'), - '_api_/attribute_resources{._format}_get_collection' => new GetCollection(uriTemplate: '/attribute_resources{._format}', shortName: 'AttributeResource', class: AttributeResource::class, controller: 'api_platform.action.placeholder', name: '_api_/attribute_resources{._format}_get_collection'), + '_api_/attribute_resources/{id}{._format}_get' => new Get( + uriTemplate: '/attribute_resources/{id}{._format}', + shortName: 'AttributeResource', + class: AttributeResource::class, + controller: 'api_platform.action.placeholder', + uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'], parameterName: 'id')], + name: '_api_/attribute_resources/{id}{._format}_get', + ), + '_api_/attribute_resources/{id}{._format}_put' => new Put( + uriTemplate: '/attribute_resources/{id}{._format}', + shortName: 'AttributeResource', + class: AttributeResource::class, + controller: 'api_platform.action.placeholder', + uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'], parameterName: 'id')], + name: '_api_/attribute_resources/{id}{._format}_put', + ), + '_api_/attribute_resources/{id}{._format}_delete' => new Delete( + uriTemplate: '/attribute_resources/{id}{._format}', + shortName: 'AttributeResource', + class: AttributeResource::class, + controller: 'api_platform.action.placeholder', + uriVariables: ['id' => new Link(fromClass: AttributeResource::class, identifiers: ['id'], parameterName: 'id')], + name: '_api_/attribute_resources/{id}{._format}_delete', + ), + '_api_/attribute_resources{._format}_get_collection' => new GetCollection( + uriTemplate: '/attribute_resources{._format}', + shortName: 'AttributeResource', + class: AttributeResource::class, + controller: 'api_platform.action.placeholder', + name: '_api_/attribute_resources{._format}_get_collection', + ), ] ), new ApiResource( diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php new file mode 100644 index 00000000000..575e037e693 --- /dev/null +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -0,0 +1,58 @@ + + * + * 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\ParameterProvider; + +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\State\ParameterNotFound; +use ApiPlatform\State\ParameterProviderInterface; + +/** + * @experimental + * + * @author Vincent Amstoutz + */ +final readonly class IriConverterParameterProvider implements ParameterProviderInterface +{ + public function __construct( + private IriConverterInterface $iriConverter, + ) { + } + + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation + { + $operation = $context['operation'] ?? null; + if (!($value = $parameter->getValue()) || $value instanceof ParameterNotFound) { + return $operation; + } + + if (\is_array($value)) { + $entities = []; + foreach ($value as $v) { + $entities[] = $this->iriConverter->getResourceFromIri($v, [ + 'fetch_data' => $parameter->getExtraProperties()['fetch_data'] ?? false, + ]); + } + + $parameter->setValue($entities); + } else { + $parameter->setValue($this->iriConverter->getResourceFromIri($value, [ + 'fetch_data' => $parameter->getExtraProperties()['fetch_data'] ?? false, + ])); + } + + return $operation; + } +} diff --git a/src/State/ParameterProvider/ReadLinkParameterProvider.php b/src/State/ParameterProvider/ReadLinkParameterProvider.php new file mode 100644 index 00000000000..cc13a281c18 --- /dev/null +++ b/src/State/ParameterProvider/ReadLinkParameterProvider.php @@ -0,0 +1,116 @@ + + * + * 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\ParameterProvider; + +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\Exception\ProviderNotFoundException; +use ApiPlatform\State\ParameterProviderInterface; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * Checks if the linked resources have security attributes and prepares them for access checking. + * + * @experimental + */ +final class ReadLinkParameterProvider implements ParameterProviderInterface +{ + /** + * @param ProviderInterface $locator + */ + public function __construct( + private readonly ProviderInterface $locator, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + ) { + } + + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation + { + $operation = $context['operation']; + $extraProperties = $parameter->getExtraProperties(); + + if ($parameter instanceof Link) { + $linkClass = $parameter->getFromClass() ?? $parameter->getToClass(); + $securityObjectName = $parameter->getSecurityObjectName() ?? $parameter->getToProperty() ?? $parameter->getFromProperty(); + } + + $securityObjectName ??= $parameter->getKey(); + + $linkClass ??= $extraProperties['resource_class'] ?? $operation->getClass(); + + if (!$linkClass) { + return $operation; + } + + $linkOperation = $this->resourceMetadataCollectionFactory + ->create($linkClass) + ->getOperation($operation->getExtraProperties()['parent_uri_template'] ?? $extraProperties['uri_template'] ?? null); + + $value = $parameter->getValue(); + + if (\is_array($value) && array_is_list($value)) { + $relation = []; + + foreach ($value as $v) { + try { + $relation[] = $this->locator->provide($linkOperation, $this->getUriVariables($v, $parameter, $linkOperation), $context); + } catch (ProviderNotFoundException) { + } + } + } else { + try { + $relation = $this->locator->provide($linkOperation, $this->getUriVariables($value, $parameter, $linkOperation), $context); + } catch (ProviderNotFoundException) { + $relation = null; + } + } + + $parameter->setValue($relation); + + if (null === $relation && true === ($extraProperties['throw_not_found'] ?? true)) { + throw new NotFoundHttpException('Relation for link security not found.'); + } + + $context['request']?->attributes->set($securityObjectName, $relation); + + return $operation; + } + + /** + * @return array + */ + private function getUriVariables(mixed $value, Parameter $parameter, Operation $operation): array + { + $extraProperties = $parameter->getExtraProperties(); + + if ($operation instanceof HttpOperation) { + $links = $operation->getUriVariables(); + } elseif ($operation instanceof GraphQlOperation) { + $links = $operation->getLinks(); + } else { + $links = []; + } + + if (!\is_array($value)) { + return [1 === \count($links) ? current($links)->getKey() : ($extraProperties['uri_variable'] ?? $parameter->getKey()) => $value]; + } + + return $value; + } +} diff --git a/src/State/Provider/ParameterProvider.php b/src/State/Provider/ParameterProvider.php index bc4fd6fac3e..137bb51071f 100644 --- a/src/State/Provider/ParameterProvider.php +++ b/src/State/Provider/ParameterProvider.php @@ -13,11 +13,15 @@ namespace ApiPlatform\State\Provider; +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\Parameters; use ApiPlatform\State\Exception\ParameterNotSupportedException; use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ParameterNotFound; +use ApiPlatform\State\ParameterProvider\ReadLinkParameterProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\ParameterParserTrait; use ApiPlatform\State\Util\RequestParser; @@ -65,58 +69,110 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } } - foreach ($parameters ?? [] as $parameter) { - $extraProperties = $parameter->getExtraProperties(); - unset($extraProperties['_api_values']); - $parameters->add($parameter->getKey(), $parameter = $parameter->withExtraProperties($extraProperties)); + $context = ['operation' => $operation] + $context; - $context = ['operation' => $operation] + $context; + foreach ($parameters ?? [] as $parameter) { $values = $this->getParameterValues($parameter, $request, $context); $value = $this->extractParameterValues($parameter, $values); + // we force API Platform's value extraction, use _api_query_parameters or _api_header_parameters if you need to set a value + if (isset($parameter->getExtraProperties()['_api_values'])) { + unset($parameter->getExtraProperties()['_api_values']); + } if (($default = $parameter->getSchema()['default'] ?? false) && ($value instanceof ParameterNotFound || !$value)) { $value = $default; } - if ($value instanceof ParameterNotFound) { - continue; - } + $parameter->setValue($value); + $context['operation'] = $operation = $this->callParameterProvider($operation, $parameter, $values, $context); + } - $parameters->add($parameter->getKey(), $parameter = $parameter->withExtraProperties( - $parameter->getExtraProperties() + ['_api_values' => $value] - )); + if ($parameters) { + $operation = $operation->withParameters($parameters); + } - if (null === ($provider = $parameter->getProvider())) { - continue; + if ($operation instanceof HttpOperation || $operation instanceof GraphQlOperation) { + $operation = $this->handlePathParameters($operation, $uriVariables, $context); + } + + $request?->attributes->set('_api_operation', $operation); + $context['operation'] = $operation; + + return $this->decorated?->provide($operation, $uriVariables, $context); + } + + /** + * TODO: this could be in the Parameters list prepare that change in 4.3 as it can break things. + * + * @param array $uriVariables + * @param array $context + */ + private function handlePathParameters(GraphQlOperation|HttpOperation $operation, array $uriVariables, array $context): GraphQlOperation|HttpOperation + { + $links = $operation instanceof HttpOperation ? $operation->getUriVariables() : $operation->getLinks(); + foreach ($links ?? [] as $key => $uriVariable) { + if ($uriVariable->getSecurity() && !$uriVariable->getProvider()) { + $uriVariable = $uriVariable->withProvider(ReadLinkParameterProvider::class); } - if (\is_callable($provider)) { - if (($op = $provider($parameter, $values, $context)) instanceof Operation) { - $operation = $op; - } + $values = $uriVariables; + if (!isset($uriVariables[$key])) { continue; } - if (\is_string($provider)) { - if (!$this->locator->has($provider)) { - throw new ProviderNotFoundException(\sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); - } + $value = $uriVariables[$key]; + // we force API Platform's value extraction, use _api_query_parameters or _api_header_parameters if you need to set a value + if (isset($uriVariable->getExtraProperties()['_api_values'])) { + unset($uriVariable->getExtraProperties()['_api_values']); + } - $provider = $this->locator->get($provider); + if (($default = $uriVariable->getSchema()['default'] ?? false) && ($value instanceof ParameterNotFound || !$value)) { + $value = $default; + } + + $uriVariable->setValue($value); + if (($op = $this->callParameterProvider($operation, $uriVariable, $values, $context)) instanceof HttpOperation) { + $context['operation'] = $operation = $op; } + } + + return $operation; + } + + /** + * @param array $context + */ + private function callParameterProvider(Operation $operation, Parameter $parameter, mixed $values, array $context): Operation + { + if ($parameter->getValue() instanceof ParameterNotFound) { + return $operation; + } + + if (null === ($provider = $parameter->getProvider())) { + return $operation; + } - if (($op = $provider->provide($parameter, $values, $context)) instanceof Operation) { + if (\is_callable($provider)) { + if (($op = $provider($parameter, $values, $context)) instanceof Operation) { $operation = $op; } + + return $operation; } - if ($parameters) { - $operation = $operation->withParameters($parameters); + if (\is_string($provider)) { + if (!$this->locator->has($provider)) { + throw new ProviderNotFoundException(\sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); + } + + $provider = $this->locator->get($provider); } - $request?->attributes->set('_api_operation', $operation); - $context['operation'] = $operation; - return $this->decorated?->provide($operation, $uriVariables, $context); + if (($op = $provider->provide($parameter, $values, $context)) instanceof Operation) { + $operation = $op; + } + + return $operation; } } diff --git a/src/State/Provider/SecurityParameterProvider.php b/src/State/Provider/SecurityParameterProvider.php index 9e9c64906b2..238908b2362 100644 --- a/src/State/Provider/SecurityParameterProvider.php +++ b/src/State/Provider/SecurityParameterProvider.php @@ -13,8 +13,12 @@ namespace ApiPlatform\State\Provider; +use ApiPlatform\Metadata\Exception\AccessDeniedException as MetadataAccessDeniedException; use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\State\ParameterNotFound; use ApiPlatform\State\ProviderInterface; @@ -25,11 +29,18 @@ /** * Loops over parameters to check parameter security. * Throws an exception if security is not granted. + * + * @experimental + * + * @implements ProviderInterface */ final class SecurityParameterProvider implements ProviderInterface { use ParameterParserTrait; + /** + * @param ProviderInterface $decorated + */ public function __construct(private readonly ProviderInterface $decorated, private readonly ?ResourceAccessCheckerInterface $resourceAccessChecker = null) { } @@ -40,18 +51,66 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $request = $context['request'] ?? null; $operation = $request?->attributes->get('_api_operation') ?? $operation; - foreach ($operation->getParameters() ?? [] as $parameter) { + + $parameters = $operation->getParameters() ?? new Parameters(); + + if ($operation instanceof HttpOperation) { + foreach ($operation->getUriVariables() ?? [] as $key => $uriVariable) { + if ($uriVariable->getValue() instanceof ParameterNotFound) { + $uriVariable->setValue($uriVariables[$key] ?? new ParameterNotFound()); + } + + $parameters->add($key, $uriVariable->withKey($key)); + } + } + + foreach ($parameters as $parameter) { + $extraProperties = $parameter->getExtraProperties(); if (null === $security = $parameter->getSecurity()) { continue; } - if (($v = $parameter->getValue()) instanceof ParameterNotFound) { + $value = $parameter->getValue(); + if ($parameter instanceof Link) { + $targetResource = $parameter->getFromClass() ?? $parameter->getToClass() ?? null; + } + + if ($value instanceof ParameterNotFound) { + continue; + } + + $targetResource ??= $extraProperties['resource_class'] ?? $context['resource_class'] ?? null; + + if (!$targetResource) { continue; } - $securityContext = [$parameter->getKey() => $v, 'object' => $body, 'operation' => $operation]; - if (!$this->resourceAccessChecker->isGranted($context['resource_class'], $security, $securityContext)) { - throw $operation instanceof GraphQlOperation ? new AccessDeniedHttpException($parameter->getSecurityMessage() ?? 'Access Denied.') : new AccessDeniedException($parameter->getSecurityMessage() ?? 'Access Denied.'); + $securityObjectName = null; + if ($parameter instanceof Link) { + $securityObjectName = $parameter->getSecurityObjectName() ?? $parameter->getToProperty() ?? $parameter->getFromProperty() ?? null; + } + + $securityContext = [ + 'object' => $body, + 'operation' => $operation, + 'previous_object' => $request?->attributes->get('previous_data'), + 'request' => $request, + $parameter->getKey() => $value, + ]; + + if ($securityObjectName) { + $securityContext[$securityObjectName] = $request?->attributes->get($securityObjectName); + } + + if (!$this->resourceAccessChecker->isGranted($targetResource, $security, $securityContext)) { + $exception = match (true) { + class_exists(MetadataAccessDeniedException::class, true) => MetadataAccessDeniedException::class, + $operation instanceof GraphQlOperation => AccessDeniedHttpException::class, + class_exists(AccessDeniedException::class, true) => AccessDeniedException::class, + default => AccessDeniedHttpException::class, + }; + + throw new ($exception)($parameter->getSecurityMessage() ?? 'Access Denied.'); } } diff --git a/tests/Symfony/Security/State/LinkAccessCheckerProviderTest.php b/src/State/Tests/Provider/SecurityParameterProviderTest.php similarity index 77% rename from tests/Symfony/Security/State/LinkAccessCheckerProviderTest.php rename to src/State/Tests/Provider/SecurityParameterProviderTest.php index 67e1fcd06f3..80797ef0adf 100644 --- a/tests/Symfony/Security/State/LinkAccessCheckerProviderTest.php +++ b/src/State/Tests/Provider/SecurityParameterProviderTest.php @@ -11,19 +11,19 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Symfony\Security\State; +namespace ApiPlatform\State\Tests\Provider; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\State\Provider\SecurityParameterProvider; use ApiPlatform\State\ProviderInterface; -use ApiPlatform\Symfony\Security\Exception\AccessDeniedException; -use ApiPlatform\Symfony\Security\State\LinkAccessCheckerProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; -class LinkAccessCheckerProviderTest extends TestCase +final class SecurityParameterProviderTest extends TestCase { public function testIsGrantedLink(): void { @@ -39,14 +39,14 @@ public function testIsGrantedLink(): void $request->attributes = $parameterBag; $request->attributes->set('bar', $barObj); $resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class); - $resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj])->willReturn(true); - $accessChecker = new LinkAccessCheckerProvider($decorated, $resourceAccessChecker); - $accessChecker->provide($operation, [], ['request' => $request]); + $resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj, 'barId' => 1, 'operation' => $operation])->willReturn(true); + $accessChecker = new SecurityParameterProvider($decorated, $resourceAccessChecker); + $accessChecker->provide($operation, ['barId' => 1], ['request' => $request]); } public function testIsNotGrantedLink(): void { - $this->expectException(AccessDeniedException::class); + $this->expectException(AccessDeniedHttpException::class); $obj = new \stdClass(); $barObj = new \stdClass(); @@ -60,14 +60,14 @@ public function testIsNotGrantedLink(): void $request->attributes = $parameterBag; $request->attributes->set('bar', $barObj); $resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class); - $resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj])->willReturn(false); - $accessChecker = new LinkAccessCheckerProvider($decorated, $resourceAccessChecker); - $accessChecker->provide($operation, [], ['request' => $request]); + $resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj, 'barId' => 1, 'operation' => $operation])->willReturn(false); + $accessChecker = new SecurityParameterProvider($decorated, $resourceAccessChecker); + $accessChecker->provide($operation, ['barId' => 1], ['request' => $request]); } public function testSecurityMessageLink(): void { - $this->expectException(AccessDeniedException::class); + $this->expectException(AccessDeniedHttpException::class); $this->expectExceptionMessage('You are not admin.'); $obj = new \stdClass(); @@ -82,8 +82,8 @@ public function testSecurityMessageLink(): void $request->attributes = $parameterBag; $request->attributes->set('bar', $barObj); $resourceAccessChecker = $this->createMock(ResourceAccessCheckerInterface::class); - $resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj])->willReturn(false); - $accessChecker = new LinkAccessCheckerProvider($decorated, $resourceAccessChecker); - $accessChecker->provide($operation, [], ['request' => $request]); + $resourceAccessChecker->expects($this->once())->method('isGranted')->with('Bar', 'is_granted("some_voter", "bar")', ['object' => $obj, 'previous_object' => null, 'request' => $request, 'bar' => $barObj, 'barId' => 1, 'operation' => $operation])->willReturn(false); + $accessChecker = new SecurityParameterProvider($decorated, $resourceAccessChecker); + $accessChecker->provide($operation, ['barId' => 1], ['request' => $request]); } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 851bc90cead..03e4b8f946f 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -211,6 +211,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $loader->load('state/provider.xml'); $loader->load('state/processor.xml'); } + $loader->load('state/parameter_provider.xml'); $container->setParameter('api_platform.enable_entrypoint', $config['enable_entrypoint']); $container->setParameter('api_platform.enable_docs', $config['enable_docs']); diff --git a/src/Symfony/Bundle/Resources/config/link_security.xml b/src/Symfony/Bundle/Resources/config/link_security.xml index a33e45f3262..8bf049c781a 100644 --- a/src/Symfony/Bundle/Resources/config/link_security.xml +++ b/src/Symfony/Bundle/Resources/config/link_security.xml @@ -5,16 +5,10 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - - - + - - - - - + diff --git a/src/Symfony/Bundle/Resources/config/state/parameter_provider.xml b/src/Symfony/Bundle/Resources/config/state/parameter_provider.xml new file mode 100644 index 00000000000..d92a1a9aba5 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/state/parameter_provider.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/state/provider.xml b/src/Symfony/Bundle/Resources/config/state/provider.xml index 52bb2ea23f7..7a261fc7bbc 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.xml +++ b/src/Symfony/Bundle/Resources/config/state/provider.xml @@ -38,7 +38,7 @@ - + diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.xml b/src/Symfony/Bundle/Resources/config/symfony/events.xml index 4ecdb892921..a28184af50a 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.xml +++ b/src/Symfony/Bundle/Resources/config/symfony/events.xml @@ -24,11 +24,17 @@ + + null + + + + @@ -101,11 +107,6 @@ - - - - - diff --git a/src/Symfony/EventListener/ReadListener.php b/src/Symfony/EventListener/ReadListener.php index c43ba52d554..0b1b5a7600c 100644 --- a/src/Symfony/EventListener/ReadListener.php +++ b/src/Symfony/EventListener/ReadListener.php @@ -44,6 +44,7 @@ public function __construct( private readonly ProviderInterface $provider, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?UriVariablesConverterInterface $uriVariablesConverter = null, + private readonly ?ProviderInterface $parameterProvider = null, ) { $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; $this->uriVariablesConverter = $uriVariablesConverter; @@ -83,10 +84,12 @@ public function onKernelRequest(RequestEvent $event): void } $request->attributes->set('_api_uri_variables', $uriVariables); - $this->provider->provide($operation, $uriVariables, [ + $context = [ 'request' => $request, 'uri_variables' => $uriVariables, 'resource_class' => $operation->getClass(), - ]); + ]; + $this->parameterProvider?->provide($operation, $uriVariables, $context); + $this->provider->provide($operation, $uriVariables, $context); } } diff --git a/src/Symfony/Security/Exception/AccessDeniedException.php b/src/Symfony/Security/Exception/AccessDeniedException.php index 1d6682ada40..e5c594a428b 100644 --- a/src/Symfony/Security/Exception/AccessDeniedException.php +++ b/src/Symfony/Security/Exception/AccessDeniedException.php @@ -16,6 +16,9 @@ use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException as ExceptionAccessDeniedException; +/** + * TODO: deprecate in favor of Metadata. + */ final class AccessDeniedException extends ExceptionAccessDeniedException implements HttpExceptionInterface { public function getStatusCode(): int diff --git a/src/Symfony/Security/State/LinkAccessCheckerProvider.php b/src/Symfony/Security/State/LinkAccessCheckerProvider.php deleted file mode 100644 index dca6afdcb62..00000000000 --- a/src/Symfony/Security/State/LinkAccessCheckerProvider.php +++ /dev/null @@ -1,80 +0,0 @@ - - * - * 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\Symfony\Security\State; - -use ApiPlatform\Metadata\HttpOperation; -use ApiPlatform\Metadata\Link; -use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\ResourceAccessCheckerInterface; -use ApiPlatform\State\ProviderInterface; -use ApiPlatform\Symfony\Security\Exception\AccessDeniedException; - -/** - * Checks the individual parts of the linked resource for access rights. - * - * @experimental - */ -final class LinkAccessCheckerProvider implements ProviderInterface -{ - public function __construct( - private readonly ProviderInterface $decorated, - private readonly ResourceAccessCheckerInterface $resourceAccessChecker, - ) { - } - - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null - { - $request = ($context['request'] ?? null); - - $data = $this->decorated->provide($operation, $uriVariables, $context); - - if ($operation instanceof HttpOperation && $operation->getUriVariables()) { - foreach ($operation->getUriVariables() as $uriVariable) { - if (!$uriVariable instanceof Link || !$uriVariable->getSecurity()) { - continue; - } - - $targetResource = $uriVariable->getFromClass() ?? $uriVariable->getToClass(); - - if (!$targetResource) { - continue; - } - - $propertyName = $uriVariable->getToProperty() ?? $uriVariable->getFromProperty(); - $securityObjectName = $uriVariable->getSecurityObjectName(); - - if (!$securityObjectName) { - $securityObjectName = $propertyName; - } - - if (!$securityObjectName) { - continue; - } - - $resourceAccessCheckerContext = [ - 'object' => $data, - 'previous_object' => $request?->attributes->get('previous_data'), - $securityObjectName => $request?->attributes->get($securityObjectName), - 'request' => $request, - ]; - - if (!$this->resourceAccessChecker->isGranted($targetResource, $uriVariable->getSecurity(), $resourceAccessCheckerContext)) { - throw new AccessDeniedException($uriVariable->getSecurityMessage() ?? 'Access Denied.'); - } - } - } - - return $data; - } -} diff --git a/src/Symfony/Security/State/LinkedReadProvider.php b/src/Symfony/Security/State/LinkedReadProvider.php deleted file mode 100644 index 2709c881dac..00000000000 --- a/src/Symfony/Security/State/LinkedReadProvider.php +++ /dev/null @@ -1,91 +0,0 @@ - - * - * 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\Symfony\Security\State; - -use ApiPlatform\Metadata\Exception\InvalidIdentifierException; -use ApiPlatform\Metadata\Exception\InvalidUriVariableException; -use ApiPlatform\Metadata\HttpOperation; -use ApiPlatform\Metadata\Link; -use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use ApiPlatform\State\Exception\ProviderNotFoundException; -use ApiPlatform\State\ProviderInterface; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; - -/** - * Checks if the linked resources have security attributes and prepares them for access checking. - * - * @experimental - */ -final class LinkedReadProvider implements ProviderInterface -{ - public function __construct( - private readonly ProviderInterface $decorated, - private readonly ProviderInterface $locator, - private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, - ) { - } - - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null - { - $data = $this->decorated->provide($operation, $uriVariables, $context); - - if (!$operation instanceof HttpOperation) { - return $data; - } - - $request = ($context['request'] ?? null); - - if ($operation->getUriVariables()) { - foreach ($operation->getUriVariables() as $key => $uriVariable) { - if (!$uriVariable instanceof Link || !$uriVariable->getSecurity()) { - continue; - } - - $relationClass = $uriVariable->getFromClass() ?? $uriVariable->getToClass(); - - if (!$relationClass) { - continue; - } - - $parentOperation = $this->resourceMetadataCollectionFactory - ->create($relationClass) - ->getOperation($operation->getExtraProperties()['parent_uri_template'] ?? null); - try { - $relation = $this->locator->provide($parentOperation, [$uriVariable->getIdentifiers()[0] => $request?->attributes->all()[$key]], $context); - } catch (ProviderNotFoundException) { - $relation = null; - } - - if (!$relation) { - throw new NotFoundHttpException('Relation for link security not found.'); - } - - try { - $securityObjectName = $uriVariable->getSecurityObjectName(); - - if (!$securityObjectName) { - $securityObjectName = $uriVariable->getToProperty() ?? $uriVariable->getFromProperty(); - } - - $request?->attributes->set($securityObjectName, $relation); - } catch (InvalidIdentifierException|InvalidUriVariableException $e) { - throw new NotFoundHttpException('Invalid identifier value or configuration.', $e); - } - } - } - - return $data; - } -} diff --git a/src/Symfony/Validator/State/ParameterValidatorProvider.php b/src/Symfony/Validator/State/ParameterValidatorProvider.php index 741c4292edb..6cbe5429b12 100644 --- a/src/Symfony/Validator/State/ParameterValidatorProvider.php +++ b/src/Symfony/Validator/State/ParameterValidatorProvider.php @@ -13,8 +13,10 @@ namespace ApiPlatform\Symfony\Validator\State; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\Parameters; use ApiPlatform\State\ParameterNotFound; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\ParameterParserTrait; @@ -52,12 +54,25 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $constraintViolationList = new ConstraintViolationList(); - foreach ($operation->getParameters() ?? [] as $parameter) { + $parameters = $operation->getParameters() ?? new Parameters(); + + if ($operation instanceof HttpOperation) { + foreach ($operation->getUriVariables() ?? [] as $key => $uriVariable) { + if ($uriVariable->getValue() instanceof ParameterNotFound) { + $uriVariable->setValue($uriVariables[$key] ?? new ParameterNotFound()); + } + + $parameters->add($key, $uriVariable->withKey($key)); + } + } + + foreach ($parameters as $parameter) { if (!$constraints = $parameter->getConstraints()) { continue; } $value = $parameter->getValue(); + if ($value instanceof ParameterNotFound) { $value = null; } diff --git a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php index 31544ae7ea0..8c130804225 100644 --- a/tests/Fixtures/TestBundle/ApiResource/WithParameter.php +++ b/tests/Fixtures/TestBundle/ApiResource/WithParameter.php @@ -23,6 +23,9 @@ use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use ApiPlatform\Serializer\Filter\GroupFilter; +use ApiPlatform\State\ParameterProvider\IriConverterParameterProvider; +use ApiPlatform\State\ParameterProvider\ReadLinkParameterProvider; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Tests\Fixtures\TestBundle\Parameter\CustomGroupParameterProvider; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -167,6 +170,27 @@ ], provider: [self::class, 'noopProvider'] )] +#[Get( + uriTemplate: 'with_parameters_iris', + parameters: [ + 'dummy' => new QueryParameter(provider: IriConverterParameterProvider::class), + ], + provider: [self::class, 'provideDummyFromParameter'], +)] +#[Get( + uriTemplate: 'with_parameters_links', + parameters: [ + 'dummy' => new QueryParameter(provider: ReadLinkParameterProvider::class, extraProperties: ['resource_class' => Dummy::class]), + ], + provider: [self::class, 'provideDummyFromParameter'], +)] +#[Get( + uriTemplate: 'with_parameters_links_no_not_found', + parameters: [ + 'dummy' => new QueryParameter(provider: ReadLinkParameterProvider::class, extraProperties: ['resource_class' => Dummy::class, 'throw_not_found' => false]), + ], + provider: [self::class, 'noopProvider'], +)] #[QueryParameter(key: 'everywhere')] class WithParameter { @@ -235,12 +259,9 @@ public static function toInt(Parameter $parameter, array $parameters = [], array $value = (int) $value; } - $parameters = $operation->getParameters(); - $parameters->add($parameter->getKey(), $parameter = $parameter->withExtraProperties( - $parameter->getExtraProperties() + ['_api_values' => $value] - )); + $parameter->setValue($value); - return $operation->withParameters($parameters); + return $operation; } public static function headerProvider(Operation $operation, array $uriVariables = [], array $context = []): JsonResponse @@ -255,4 +276,9 @@ public static function noopProvider(Operation $operation, array $uriVariables = { return new JsonResponse([]); } + + public static function provideDummyFromParameter(Operation $operation, array $uriVariables = [], array $context = []): object|array + { + return $operation->getParameters()->get('dummy')->getValue(); + } } diff --git a/tests/Fixtures/TestBundle/Entity/Company.php b/tests/Fixtures/TestBundle/Entity/Company.php index 26abb965a28..976e23d3380 100644 --- a/tests/Fixtures/TestBundle/Entity/Company.php +++ b/tests/Fixtures/TestBundle/Entity/Company.php @@ -17,6 +17,8 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Post; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; @@ -24,6 +26,11 @@ #[ApiResource] #[GetCollection] #[Get] +#[NotExposed( + uriTemplate: '/company-by-name/{name}', + provider: [self::class, 'provideCompanyByName'], + uriVariables: ['name'] +)] #[Post] #[ApiResource( uriTemplate: '/employees/{employeeId}/company', @@ -34,6 +41,14 @@ #[ORM\Entity] class Company { + public static function provideCompanyByName(Operation $operation, array $uriVariables): object + { + $c = new self(); + $c->setName($uriVariables['name']); + + return $c; + } + /** * @var int|null The id */ diff --git a/tests/Fixtures/TestBundle/Entity/Employee.php b/tests/Fixtures/TestBundle/Entity/Employee.php index 1493fe29aec..2a28463a3ee 100644 --- a/tests/Fixtures/TestBundle/Entity/Employee.php +++ b/tests/Fixtures/TestBundle/Entity/Employee.php @@ -16,9 +16,11 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Post; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints\IdenticalTo; #[ApiResource] #[Post] @@ -38,6 +40,19 @@ normalizationContext: ['groups' => ['company_employees_read']] )] #[GetCollection] +#[GetCollection( + uriTemplate: '/companies-by-name/{name}/employees', + uriVariables: [ + 'name' => new Link( + identifiers: ['name'], + fromClass: Company::class, + toProperty: 'company', + security: 'company.name == "Test" or company.name == "NotTest"', + extraProperties: ['uri_template' => '/company-by-name/{name}'], + constraints: [new IdenticalTo('Test')] + ), + ], +)] #[ORM\Entity] class Employee { diff --git a/tests/Functional/Parameters/IriProviderParameterTest.php b/tests/Functional/Parameters/IriProviderParameterTest.php new file mode 100644 index 00000000000..f878c43b0ee --- /dev/null +++ b/tests/Functional/Parameters/IriProviderParameterTest.php @@ -0,0 +1,88 @@ + + * + * 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\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class IriProviderParameterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [WithParameter::class, Dummy::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $this->recreateSchema([Dummy::class]); + } + + public function testReadDummyIriFromQueryParameter(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('hi'); + $manager->persist($dummy); + $manager->flush(); + + $iri = $container->get('api_platform.iri_converter')->getIriFromResource($dummy); + $response = self::createClient()->request('GET', '/with_parameters_iris?dummy='.$iri); + $this->assertEquals('hi', $response->toArray()['name']); + self::assertEquals(200, $response->getStatusCode()); + } + + public function testReadDummyIrisFromQueryParameter(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('hi'); + $dummy2 = new Dummy(); + $dummy2->setName('ho'); + $manager->persist($dummy); + $manager->persist($dummy2); + $manager->flush(); + + $iri2 = $container->get('api_platform.iri_converter')->getIriFromResource($dummy2); + $iri = $container->get('api_platform.iri_converter')->getIriFromResource($dummy); + $response = self::createClient()->request('GET', \sprintf('/with_parameters_iris?dummy[]=%s&dummy[]=%s', $iri2, $iri)); + $res = $response->toArray(); + $this->assertEquals('ho', $res['hydra:member'][0]['name']); + $this->assertEquals('hi', $res['hydra:member'][1]['name']); + self::assertEquals(200, $response->getStatusCode()); + } +} diff --git a/tests/Functional/Parameters/LinkProviderParameterTest.php b/tests/Functional/Parameters/LinkProviderParameterTest.php new file mode 100644 index 00000000000..e7dab6f04f7 --- /dev/null +++ b/tests/Functional/Parameters/LinkProviderParameterTest.php @@ -0,0 +1,165 @@ + + * + * 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\Tests\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Company; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Employee; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class LinkProviderParameterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [WithParameter::class, Dummy::class, Employee::class, Company::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $this->recreateSchema([Dummy::class, RelatedOwnedDummy::class, RelatedDummy::class, Employee::class, Company::class]); + } + + public function testReadDummyProviderFromQueryParameter(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('hi'); + $manager->persist($dummy); + $manager->flush(); + + $response = self::createClient()->request('GET', '/with_parameters_links?dummy='.$dummy->getId()); + $this->assertEquals('hi', $response->toArray()['name']); + self::assertEquals(200, $response->getStatusCode()); + $response = self::createClient()->request('GET', '/with_parameters_links?dummy[id]='.$dummy->getId()); + $this->assertEquals('hi', $response->toArray()['name']); + self::assertEquals(200, $response->getStatusCode()); + } + + public function testReadDummyIrisFromQueryParameter(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('hi'); + $dummy2 = new Dummy(); + $dummy2->setName('ho'); + $manager->persist($dummy); + $manager->persist($dummy2); + $manager->flush(); + + $response = self::createClient()->request('GET', \sprintf('/with_parameters_links?dummy[]=%s&dummy[]=%s', $dummy2->getId(), $dummy->getId())); + $res = $response->toArray(); + $this->assertEquals('ho', $res['hydra:member'][0]['name']); + $this->assertEquals('hi', $res['hydra:member'][1]['name']); + self::assertEquals(200, $response->getStatusCode()); + } + + public function testReadDummyProviderFromQueryParameterNotFound(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + $response = self::createClient()->request('GET', '/with_parameters_links?dummy=1'); + self::assertEquals(404, $response->getStatusCode()); + } + + public function testReadDummyProviderFromQueryParameterNoNotFound(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + $response = self::createClient()->request('GET', '/with_parameters_links_no_not_found?dummy=1'); + self::assertEquals(200, $response->getStatusCode()); + } + + /** + * See https://github.com/api-platform/core/issues/7061. + */ + public function testLinkSecurityWithSlug(): void + { + $manager = $this->getManager(); + $employee = new Employee(); + $employee->setName('me'); + $dummy = new Company(); + $dummy->setName('Test'); + $employee->setCompany($dummy); + $manager->persist($employee); + $manager->persist($dummy); + $manager->flush(); + + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + self::createClient()->request('GET', '/companies-by-name/Test/employees'); + self::assertJsonContains([ + 'hydra:member' => [ + ['company' => ['name' => 'Test']], + ], + ]); + self::assertResponseStatusCodeSame(200); + } + + /** + * See https://github.com/api-platform/core/issues/7061. + */ + public function testLinkSecurityWithConstraint(): void + { + $manager = $this->getManager(); + $employee = new Employee(); + $employee->setName('me'); + $dummy = new Company(); + $dummy->setName('Test'); + $employee->setCompany($dummy); + $manager->persist($employee); + $manager->persist($dummy); + $manager->flush(); + + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('GET', '/companies-by-name/NotTest/employees'); + self::assertEquals(422, $response->getStatusCode()); + } +} diff --git a/tests/Functional/Parameters/SecurityTest.php b/tests/Functional/Parameters/SecurityTest.php index b062f1d1670..4e5919a7c93 100644 --- a/tests/Functional/Parameters/SecurityTest.php +++ b/tests/Functional/Parameters/SecurityTest.php @@ -61,7 +61,7 @@ public function testNoValueParameter(): void public static function dataSecurityValues(): iterable { yield ['secured', Response::HTTP_OK]; - yield ['not_the_expected_parameter_value', Response::HTTP_UNAUTHORIZED]; + yield ['not_the_expected_parameter_value', Response::HTTP_FORBIDDEN]; } #[\PHPUnit\Framework\Attributes\DataProvider('dataSecurityValues')]