From 9e50ddd80335fa2d1ba2598e3596adbe25f15758 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 23 May 2023 22:43:31 +0200 Subject: [PATCH 001/132] style: symfony rules has use_nullable_type_declaration to false --- Pagination/Pagination.php | 2 +- UriVariablesResolverTrait.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Pagination/Pagination.php b/Pagination/Pagination.php index 88bfd4e..919f675 100644 --- a/Pagination/Pagination.php +++ b/Pagination/Pagination.php @@ -171,7 +171,7 @@ public function isEnabled(Operation $operation = null, array $context = []): boo /** * Is the pagination enabled for GraphQL? */ - public function isGraphQlEnabled(?Operation $operation = null, array $context = []): bool + public function isGraphQlEnabled(Operation $operation = null, array $context = []): bool { return $this->getGraphQlEnabled($operation); } diff --git a/UriVariablesResolverTrait.php b/UriVariablesResolverTrait.php index fedb2cb..9c2ec9a 100644 --- a/UriVariablesResolverTrait.php +++ b/UriVariablesResolverTrait.php @@ -25,7 +25,7 @@ trait UriVariablesResolverTrait /** * Resolves an operation's UriVariables to their identifiers values. */ - private function getOperationUriVariables(?HttpOperation $operation = null, array $parameters = [], ?string $resourceClass = null): array + private function getOperationUriVariables(HttpOperation $operation = null, array $parameters = [], string $resourceClass = null): array { $identifiers = []; From aaa09c6d1cf8fbdaeb878709aad679a4d46ab233 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 11 May 2023 01:01:14 -0700 Subject: [PATCH 002/132] fix(symfony): provider can throw validation exception (#5586) fixes #5585 --- CallableProvider.php | 6 +++--- Exception/ProviderNotFoundException.php | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 Exception/ProviderNotFoundException.php diff --git a/CallableProvider.php b/CallableProvider.php index 4926e7e..f669c07 100644 --- a/CallableProvider.php +++ b/CallableProvider.php @@ -13,8 +13,8 @@ namespace ApiPlatform\State; -use ApiPlatform\Exception\RuntimeException; use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Exception\ProviderNotFoundException; use Psr\Container\ContainerInterface; final class CallableProvider implements ProviderInterface @@ -34,7 +34,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c if (\is_string($provider)) { if (!$this->locator->has($provider)) { - throw new RuntimeException(sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); + throw new ProviderNotFoundException(sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); } /** @var ProviderInterface $providerInstance */ @@ -43,6 +43,6 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $providerInstance->provide($operation, $uriVariables, $context); } - throw new RuntimeException(sprintf('Provider not found on operation "%s"', $operation->getName())); + throw new ProviderNotFoundException(sprintf('Provider not found on operation "%s"', $operation->getName())); } } diff --git a/Exception/ProviderNotFoundException.php b/Exception/ProviderNotFoundException.php new file mode 100644 index 0000000..a12537f --- /dev/null +++ b/Exception/ProviderNotFoundException.php @@ -0,0 +1,20 @@ + + * + * 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\Exception; + +use ApiPlatform\Metadata\Exception\RuntimeException; + +final class ProviderNotFoundException extends RuntimeException +{ +} From ab47580d4518bc4aafa9d2c4473e9cc38235e72a Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 23 May 2023 11:34:59 +0200 Subject: [PATCH 003/132] fix(metadata): convert composite uri variables w/ proper type --- UriVariablesResolverTrait.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/UriVariablesResolverTrait.php b/UriVariablesResolverTrait.php index 9c2ec9a..61a54a2 100644 --- a/UriVariablesResolverTrait.php +++ b/UriVariablesResolverTrait.php @@ -33,6 +33,7 @@ private function getOperationUriVariables(HttpOperation $operation = null, array return $identifiers; } + $uriVariablesMap = []; foreach ($operation->getUriVariables() ?? [] as $parameterName => $uriVariableDefinition) { if (!isset($parameters[$parameterName])) { if (!isset($parameters['id'])) { @@ -51,16 +52,18 @@ private function getOperationUriVariables(HttpOperation $operation = null, array foreach ($currentIdentifiers as $key => $value) { $identifiers[$key] = $value; + $uriVariableMap[$key] = $uriVariableDefinition; } continue; } $identifiers[$parameterName] = $parameters[$parameterName]; + $uriVariableMap[$parameterName] = $uriVariableDefinition; } if ($this->uriVariablesConverter) { - $context = ['operation' => $operation]; + $context = ['operation' => $operation, 'uri_variables_map' => $uriVariablesMap]; $identifiers = $this->uriVariablesConverter->convert($identifiers, $operation->getClass() ?? $resourceClass, $context); } From 6bb5e9b64de0f401ee825047c5e75d32eb2b2945 Mon Sep 17 00:00:00 2001 From: JacquesDurand <59364973+JacquesDurand@users.noreply.github.com> Date: Mon, 5 Jun 2023 11:38:54 +0200 Subject: [PATCH 004/132] feat: error as resources, jsonld errors are now problem-compliant (#5433) --- DefaultErrorProvider.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 DefaultErrorProvider.php diff --git a/DefaultErrorProvider.php b/DefaultErrorProvider.php new file mode 100644 index 0000000..f7f08d8 --- /dev/null +++ b/DefaultErrorProvider.php @@ -0,0 +1,27 @@ + + * + * 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; + +use ApiPlatform\Metadata\Operation; + +/** + * @internal + */ +final class DefaultErrorProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object + { + return $context['previous_data']; + } +} From 8793bc9999807298ff2a36f74897ce5a46d15e79 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Thu, 6 Jul 2023 10:39:36 +0200 Subject: [PATCH 005/132] feat: union/intersect types (#5470) * fix(metadata): handle union/intersect types * review * try to move SchemaFactory onto SchemaPropertyMetadataFactory * complete property schema on SchemaFactory * Apply suggestions from code review Co-authored-by: Antoine Bluchet * fix: review * fix: cs * fix: phpunit * fix: cs * fix: tests about maker * fix: JsonSchema::SchemaFactory * fix: behat tests * fix deprec * tests --------- Co-authored-by: Antoine Bluchet --- CreateProvider.php | 1 - 1 file changed, 1 deletion(-) diff --git a/CreateProvider.php b/CreateProvider.php index ad1d6dc..7343b37 100644 --- a/CreateProvider.php +++ b/CreateProvider.php @@ -18,7 +18,6 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\Post; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; From 0b8935076fb6cbe11f81993f4b2c5b17c62073b9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 10 Aug 2023 11:41:02 +0200 Subject: [PATCH 006/132] chore(serializer): api-platform/serializer --- UriVariablesResolverTrait.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/UriVariablesResolverTrait.php b/UriVariablesResolverTrait.php index 61a54a2..2742da9 100644 --- a/UriVariablesResolverTrait.php +++ b/UriVariablesResolverTrait.php @@ -13,9 +13,9 @@ namespace ApiPlatform\State; -use ApiPlatform\Api\CompositeIdentifierParser; -use ApiPlatform\Api\UriVariablesConverterInterface; -use ApiPlatform\Exception\InvalidIdentifierException; +use ApiPlatform\Metadata\Util\CompositeIdentifierParser; +use ApiPlatform\Metadata\UriVariablesConverterInterface; +use ApiPlatform\Metadata\Exception\InvalidIdentifierException; use ApiPlatform\Metadata\HttpOperation; trait UriVariablesResolverTrait From 25574d9883dd0164397e8191a6cb6d833dc99c34 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 11 Aug 2023 15:43:30 +0200 Subject: [PATCH 007/132] cs: fixes --- UriVariablesResolverTrait.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UriVariablesResolverTrait.php b/UriVariablesResolverTrait.php index 2742da9..b9e222a 100644 --- a/UriVariablesResolverTrait.php +++ b/UriVariablesResolverTrait.php @@ -13,10 +13,10 @@ namespace ApiPlatform\State; -use ApiPlatform\Metadata\Util\CompositeIdentifierParser; -use ApiPlatform\Metadata\UriVariablesConverterInterface; use ApiPlatform\Metadata\Exception\InvalidIdentifierException; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\UriVariablesConverterInterface; +use ApiPlatform\Metadata\Util\CompositeIdentifierParser; trait UriVariablesResolverTrait { From cf7c65956bc65a1f7c432c3de650c07b4dcdb319 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 2 Sep 2023 09:26:22 +0200 Subject: [PATCH 008/132] refactor: use provider/processor instead of event listeners (#5657) * refactor: use provider/processor instead of event listeners * tests --- CallableProcessor.php | 2 +- CreateProvider.php | 2 +- DefaultErrorProvider.php | 27 ------ Processor/AddLinkHeaderProcessor.php | 48 +++++++++ Processor/RespondProcessor.php | 105 ++++++++++++++++++++ Processor/SerializeProcessor.php | 77 +++++++++++++++ Processor/WriteProcessor.php | 47 +++++++++ ProcessorInterface.php | 6 +- Provider/ContentNegotiationProvider.php | 123 ++++++++++++++++++++++++ Provider/DeserializeProvider.php | 110 +++++++++++++++++++++ Provider/ReadProvider.php | 95 ++++++++++++++++++ ProviderInterface.php | 4 +- 12 files changed, 612 insertions(+), 34 deletions(-) delete mode 100644 DefaultErrorProvider.php create mode 100644 Processor/AddLinkHeaderProcessor.php create mode 100644 Processor/RespondProcessor.php create mode 100644 Processor/SerializeProcessor.php create mode 100644 Processor/WriteProcessor.php create mode 100644 Provider/ContentNegotiationProvider.php create mode 100644 Provider/DeserializeProvider.php create mode 100644 Provider/ReadProvider.php diff --git a/CallableProcessor.php b/CallableProcessor.php index eff8f56..64435b6 100644 --- a/CallableProcessor.php +++ b/CallableProcessor.php @@ -29,7 +29,7 @@ public function __construct(private readonly ContainerInterface $locator) public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { if (!($processor = $operation->getProcessor())) { - return null; + return $data; } if (\is_callable($processor)) { diff --git a/CreateProvider.php b/CreateProvider.php index cdd83dc..7b33ef0 100644 --- a/CreateProvider.php +++ b/CreateProvider.php @@ -40,7 +40,7 @@ public function __construct(private ProviderInterface $decorated, private ?Prope public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object { - if (!$uriVariables || !$operation instanceof HttpOperation || null !== $operation->getController()) { + if (!$uriVariables || !$operation instanceof HttpOperation || (null !== $operation->getController() && 'api_platform.symfony.main_controller' !== $operation->getController())) { return $this->decorated->provide($operation, $uriVariables, $context); } diff --git a/DefaultErrorProvider.php b/DefaultErrorProvider.php deleted file mode 100644 index f7f08d8..0000000 --- a/DefaultErrorProvider.php +++ /dev/null @@ -1,27 +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\State; - -use ApiPlatform\Metadata\Operation; - -/** - * @internal - */ -final class DefaultErrorProvider implements ProviderInterface -{ - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object - { - return $context['previous_data']; - } -} diff --git a/Processor/AddLinkHeaderProcessor.php b/Processor/AddLinkHeaderProcessor.php new file mode 100644 index 0000000..59b6f85 --- /dev/null +++ b/Processor/AddLinkHeaderProcessor.php @@ -0,0 +1,48 @@ + + * + * 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\Processor; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\WebLink\HttpHeaderSerializer; + +final class AddLinkHeaderProcessor implements ProcessorInterface +{ + public function __construct(private readonly ProcessorInterface $decorated, private readonly ?HttpHeaderSerializer $serializer = new HttpHeaderSerializer()) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + $response = $this->decorated->process($data, $operation, $uriVariables, $context); + + if ( + !($request = $context['request'] ?? null) + || !$response instanceof Response + ) { + return $response; + } + + // We add our header here as Symfony does it only for the main Request and we want it to be done on errors (sub-request) as well + $linksProvider = $request->attributes->get('_links'); + if ($this->serializer && ($links = $linksProvider->getLinks())) { + $response->headers->set('Link', $this->serializer->serialize($links)); + // We don't want Symfony WebLink component do add links twice + $request->attributes->set('_links', []); + } + + return $response; + } +} diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php new file mode 100644 index 0000000..6464f04 --- /dev/null +++ b/Processor/RespondProcessor.php @@ -0,0 +1,105 @@ + + * + * 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\Processor; + +use ApiPlatform\Api\IriConverterInterface; +use ApiPlatform\Api\UrlGeneratorInterface; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\Util\CloneTrait; +use Symfony\Component\HttpFoundation\Response; + +/** + * Serializes data. + * + * @author Kévin Dunglas + */ +final class RespondProcessor implements ProcessorInterface +{ + use ClassInfoTrait; + use CloneTrait; + + public const METHOD_TO_CODE = [ + 'POST' => Response::HTTP_CREATED, + 'DELETE' => Response::HTTP_NO_CONTENT, + ]; + + public function __construct(private ?IriConverterInterface $iriConverter = null, private readonly ?ResourceClassResolverInterface $resourceClassResolver = null) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if ($data instanceof Response || !$operation instanceof HttpOperation) { + return $data; + } + + if (!($request = $context['request'] ?? null)) { + return $data; + } + + $headers = [ + 'Content-Type' => sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())), + 'Vary' => 'Accept', + 'X-Content-Type-Options' => 'nosniff', + 'X-Frame-Options' => 'deny', + ]; + + $status = $operation->getStatus(); + + if ($sunset = $operation->getSunset()) { + $headers['Sunset'] = (new \DateTimeImmutable($sunset))->format(\DateTime::RFC1123); + } + + if ($acceptPatch = $operation->getAcceptPatch()) { + $headers['Accept-Patch'] = $acceptPatch; + } + + $method = $request->getMethod(); + $originalData = $context['original_data'] ?? null; + + if ($hasData = ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))) && $this->iriConverter) { + if ( + ($operation->getExtraProperties()['is_alternate_resource_metadata'] ?? false) + && 301 === $operation->getStatus() + ) { + $status = 301; + $headers['Location'] = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, $operation); + } elseif ('PUT' === $method && !$request->attributes->get('previous_data') && null === $status && ($operation instanceof Put && ($operation->getAllowCreate() ?? false))) { + $status = 201; + } + } + + $status ??= self::METHOD_TO_CODE[$method] ?? 200; + + if ($hasData && $this->iriConverter) { + $iri = $this->iriConverter->getIriFromResource($originalData); + $headers['Content-Location'] = $iri; + + if ((201 === $status || (300 <= $status && $status < 400)) && 'POST' === $method) { + $headers['Location'] = $iri; + } + } + + return new Response( + $data, + $status, + $headers + ); + } +} diff --git a/Processor/SerializeProcessor.php b/Processor/SerializeProcessor.php new file mode 100644 index 0000000..0bc4739 --- /dev/null +++ b/Processor/SerializeProcessor.php @@ -0,0 +1,77 @@ + + * + * 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\Processor; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Serializer\ResourceList; +use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\WebLink\GenericLinkProvider; +use Symfony\Component\WebLink\Link; + +/** + * Serializes data. + * + * @author Kévin Dunglas + */ +final class SerializeProcessor implements ProcessorInterface +{ + public function __construct(private readonly ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if ($data instanceof Response || !($operation->canSerialize() ?? true) || !($request = $context['request'] ?? null)) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + // @see ApiPlatform\State\Processor\RespondProcessor + $context['original_data'] = $data; + + $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ + 'resource_class' => $operation->getClass(), + 'operation' => $operation, + ]); + + $serializerContext['uri_variables'] = $uriVariables; + + if (isset($serializerContext['output']) && \array_key_exists('class', $serializerContext['output']) && null === $serializerContext['output']['class']) { + return $this->processor->process(null, $operation, $uriVariables, $context); + } + + $resources = new ResourceList(); + $serializerContext['resources'] = &$resources; + $serializerContext[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY][] = 'resources'; + + $resourcesToPush = new ResourceList(); + $serializerContext['resources_to_push'] = &$resourcesToPush; + $serializerContext[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY][] = 'resources_to_push'; + + $serialized = $this->serializer->serialize($data, $request->getRequestFormat(), $serializerContext); + $request->attributes->set('_resources', $request->attributes->get('_resources', []) + (array) $resources); + if (\count($resourcesToPush)) { + $linkProvider = $request->attributes->get('_links', new GenericLinkProvider()); + foreach ($resourcesToPush as $resourceToPush) { + $linkProvider = $linkProvider->withLink((new Link('preload', $resourceToPush))->withAttribute('as', 'fetch')); + } + $request->attributes->set('_links', $linkProvider); + } + + return $this->processor->process($serialized, $operation, $uriVariables, $context); + } +} diff --git a/Processor/WriteProcessor.php b/Processor/WriteProcessor.php new file mode 100644 index 0000000..eb611ac --- /dev/null +++ b/Processor/WriteProcessor.php @@ -0,0 +1,47 @@ + + * + * 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\Processor; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; + +/** + * Bridges persistence and the API system. + * + * @author Kévin Dunglas + * @author Baptiste Meyer + */ +final class WriteProcessor implements ProcessorInterface +{ + use ClassInfoTrait; + + public function __construct(private readonly ProcessorInterface $processor, private readonly ProcessorInterface $callableProcessor) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if ( + $data instanceof Response + || !($operation->canWrite() ?? true) + || !$operation->getProcessor() + ) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + return $this->processor->process($this->callableProcessor->process($data, $operation, $uriVariables, $context), $operation, $uriVariables, $context); + } +} diff --git a/ProcessorInterface.php b/ProcessorInterface.php index 123f970..2dfbf86 100644 --- a/ProcessorInterface.php +++ b/ProcessorInterface.php @@ -25,10 +25,10 @@ interface ProcessorInterface { /** - * Processes the state. + * Handle the state. * - * @param array $uriVariables - * @param array $context + * @param array $uriVariables + * @param array&array{request?: \Symfony\Component\HttpFoundation\Request, previous_data?: mixed, resource_class?: string, original_data?: mixed} $context * * @return T */ diff --git a/Provider/ContentNegotiationProvider.php b/Provider/ContentNegotiationProvider.php new file mode 100644 index 0000000..8b3cb53 --- /dev/null +++ b/Provider/ContentNegotiationProvider.php @@ -0,0 +1,123 @@ + + * + * 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\Provider; + +use ApiPlatform\Metadata\Error as ErrorOperation; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\ContentNegotiationTrait; +use ApiPlatform\State\ProviderInterface; +use Negotiation\Negotiator; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; + +final class ContentNegotiationProvider implements ProviderInterface +{ + use ContentNegotiationTrait; + + /** + * @param array $formats + * @param array $errorFormats + */ + public function __construct(private readonly ProviderInterface $decorated, Negotiator $negotiator = null, private readonly array $formats = [], private readonly array $errorFormats = []) + { + $this->negotiator = $negotiator ?? new Negotiator(); + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation) { + return $this->decorated->provide($operation, $uriVariables, $context); + } + + $isErrorOperation = $operation instanceof ErrorOperation; + + $formats = $operation->getOutputFormats() ?? ($isErrorOperation ? $this->errorFormats : $this->formats); + $this->addRequestFormats($request, $formats); + $request->attributes->set('input_format', $this->getInputFormat($operation, $request)); + + if (!$isErrorOperation) { + $request->setRequestFormat($this->getRequestFormat($request, $formats)); + } else { + $request->setRequestFormat($this->getRequestFormat($request, $formats, false)); + } + + return $this->decorated->provide($operation, $uriVariables, $context); + } + + /** + * Adds the supported formats to the request. + * + * This is necessary for {@see Request::getMimeType} and {@see Request::getMimeTypes} to work. + * Note that this replaces default mime types configured at {@see Request::initializeFormats} + * + * @param array $formats + */ + private function addRequestFormats(Request $request, array $formats): void + { + foreach ($formats as $format => $mimeTypes) { + $request->setFormat($format, (array) $mimeTypes); + } + } + + /** + * Flattened the list of MIME types. + * + * @param array $formats + * + * @return array + */ + private function flattenMimeTypes(array $formats): array + { + $flattenedMimeTypes = []; + foreach ($formats as $format => $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $flattenedMimeTypes[$mimeType] = $format; + } + } + + return $flattenedMimeTypes; + } + + /** + * Extracts the format from the Content-Type header and check that it is supported. + * + * @throws UnsupportedMediaTypeHttpException + */ + private function getInputFormat(HttpOperation $operation, Request $request): ?string + { + if (null === ($contentType = $request->headers->get('CONTENT_TYPE'))) { + return null; + } + + /** @var string $contentType */ + $formats = $operation->getInputFormats() ?? []; + if ($format = $this->getMimeTypeFormat($contentType, $formats)) { + return $format; + } + + $supportedMimeTypes = []; + foreach ($formats as $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $supportedMimeTypes[] = $mimeType; + } + } + + if (!$request->isMethodSafe() && 'DELETE' !== $request->getMethod()) { + throw new UnsupportedMediaTypeHttpException(sprintf('The content-type "%s" is not supported. Supported MIME types are "%s".', $contentType, implode('", "', $supportedMimeTypes))); + } + + return null; + } +} diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php new file mode 100644 index 0000000..fd502ae --- /dev/null +++ b/Provider/DeserializeProvider.php @@ -0,0 +1,110 @@ + + * + * 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\Provider; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Symfony\Validator\Exception\ValidationException; +use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Contracts\Translation\LocaleAwareInterface; +use Symfony\Contracts\Translation\TranslatorInterface; +use Symfony\Contracts\Translation\TranslatorTrait; + +final class DeserializeProvider implements ProviderInterface +{ + public function __construct(private readonly ProviderInterface $decorated, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, private ?TranslatorInterface $translator = null) + { + if (null === $this->translator) { + $this->translator = new class() implements TranslatorInterface, LocaleAwareInterface { + use TranslatorTrait; + }; + $this->translator->setLocale('en'); + } + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $data = $this->decorated->provide($operation, $uriVariables, $context); + + // We need request content + if (!$operation instanceof HttpOperation || !($request = $context['request'] ?? null)) { + return $data; + } + + if ( + !($operation->canDeserialize() ?? true) + || !\in_array($method = $operation->getMethod(), ['POST', 'PUT', 'PATCH'], true) + ) { + return $data; + } + + $contentType = $request->headers->get('CONTENT_TYPE'); + if (null === $contentType) { + throw new UnsupportedMediaTypeHttpException('The "Content-Type" header must exist.'); + } + + $serializerContext = $this->serializerContextBuilder->createFromRequest($request, false, [ + 'resource_class' => $operation->getClass(), + 'operation' => $operation, + ]); + + $serializerContext['uri_variables'] = $uriVariables; + + if (!$format = $request->attributes->get('input_format') ?? null) { + throw new UnsupportedMediaTypeHttpException('Format not supported.'); + } + + if ( + null !== $data + && ( + 'POST' === $method + || 'PATCH' === $method + || ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? false)) + ) + ) { + $serializerContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; + } + + try { + return $this->serializer->deserialize((string) $request->getContent(), $operation->getClass(), $format, $serializerContext); + } catch (PartialDenormalizationException $e) { + $violations = new ConstraintViolationList(); + foreach ($e->getErrors() as $exception) { + if (!$exception instanceof NotNormalizableValueException) { + continue; + } + $message = (new Type($exception->getExpectedTypes() ?? []))->message; + $parameters = []; + if ($exception->canUseMessageForUser()) { + $parameters['hint'] = $exception->getMessage(); + } + $violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $exception->getExpectedTypes() ?? [])], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) $exception->getCode())); + } + if (0 !== \count($violations)) { + throw new ValidationException($violations); + } + } + + return $data; + } +} diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php new file mode 100644 index 0000000..bf33c87 --- /dev/null +++ b/Provider/ReadProvider.php @@ -0,0 +1,95 @@ + + * + * 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\Provider; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Put; +use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\Exception\ProviderNotFoundException; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\UriVariablesResolverTrait; +use ApiPlatform\Util\CloneTrait; +use ApiPlatform\Util\OperationRequestInitiatorTrait; +use ApiPlatform\Util\RequestParser; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * Retrieves data from the applicable data provider, based on the current IRI, and sets it as a request parameter called data. + * + * @author Kévin Dunglas + */ +final class ReadProvider implements ProviderInterface +{ + use CloneTrait; + use OperationRequestInitiatorTrait; + use UriVariablesResolverTrait; + + public function __construct( + private readonly ProviderInterface $provider, + private readonly ?SerializerContextBuilderInterface $serializerContextBuilder = null, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if (!$operation instanceof HttpOperation) { + return null; + } + + $request = ($context['request'] ?? null); + if (!($operation->canRead() ?? true) || (!$operation->getUriVariables() && !$request?->isMethodSafe())) { + return null; + } + + if (null === $filters = $request->attributes->get('_api_filters')) { + $queryString = RequestParser::getQueryString($request); + $filters = $queryString ? RequestParser::parseRequestParams($queryString) : null; + } + + if ($filters) { + $context['filters'] = $filters; + } + + if ($this->serializerContextBuilder) { + // Builtin data providers are able to use the serialization context to automatically add join clauses + $context += $this->serializerContextBuilder->createFromRequest($request, true, [ + 'resource_class' => $operation->getClass(), + 'operation' => $operation, + ]); + } + + try { + $data = $this->provider->provide($operation, $uriVariables, $context); + } catch (ProviderNotFoundException $e) { + $data = null; + } + + if ( + null === $data + && 'POST' !== $operation->getMethod() + && ( + 'PUT' !== $operation->getMethod() + || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) + ) + ) { + throw new NotFoundHttpException('Not Found'); + } + + $request->attributes->set('data', $data); + $request->attributes->set('previous_data', $this->clone($data)); + + return $data; + } +} diff --git a/ProviderInterface.php b/ProviderInterface.php index 41f2241..7404f36 100644 --- a/ProviderInterface.php +++ b/ProviderInterface.php @@ -27,8 +27,8 @@ interface ProviderInterface /** * Provides data. * - * @param array $uriVariables - * @param array $context + * @param array $uriVariables + * @param array|array{request?: \Symfony\Component\HttpFoundation\Request, resource_class?: string} $context * * @return T|Pagination\PartialPaginatorInterface|iterable|null */ From 4e975fd982753309c38b2f41999fe97ad7e86aa5 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 8 Sep 2023 14:56:20 +0200 Subject: [PATCH 009/132] feat(metadata): improve CreateProvider (#5770) --- CreateProvider.php | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/CreateProvider.php b/CreateProvider.php index 7b33ef0..b805d2e 100644 --- a/CreateProvider.php +++ b/CreateProvider.php @@ -14,10 +14,10 @@ namespace ApiPlatform\State; use ApiPlatform\Exception\RuntimeException; -use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\HttpOperation; -use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\Exception\ProviderNotFoundException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -30,11 +30,16 @@ * @author Antoine Bluchet * * @experimental + * + * @internal */ final class CreateProvider implements ProviderInterface { - public function __construct(private ProviderInterface $decorated, private ?PropertyAccessorInterface $propertyAccessor = null) - { + public function __construct( + private ProviderInterface $decorated, + private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + private ?PropertyAccessorInterface $propertyAccessor = null, + ) { $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); } @@ -47,18 +52,15 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $operationUriVariables = $operation->getUriVariables(); $relationClass = current($operationUriVariables)->getFromClass(); $key = key($operationUriVariables); - $relationUriVariables = []; - - foreach ($operationUriVariables as $parameterName => $value) { - if ($key === $parameterName) { - $relationUriVariables['id'] = new Link(identifiers: $value->getIdentifiers(), fromClass: $value->getFromClass(), parameterName: $key); - continue; - } - $relationUriVariables[$parameterName] = $value; + $parentOperation = $this->resourceMetadataCollectionFactory + ->create($relationClass) + ->getOperation($operation->getExtraProperties()['parent_uri_template'] ?? null); + try { + $relation = $this->decorated->provide($parentOperation, $uriVariables); + } catch (ProviderNotFoundException) { + $relation = null; } - - $relation = $this->decorated->provide(new Get(uriVariables: $relationUriVariables, class: $relationClass), $uriVariables); if (!$relation) { throw new NotFoundHttpException('Not Found'); } @@ -68,6 +70,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } catch (\Throwable $e) { throw new RuntimeException(sprintf('An error occurred while trying to create an instance of the "%s" resource. Consider writing your own "%s" implementation and setting it as `provider` on your operation instead.', $operation->getClass(), ProviderInterface::class), 0, $e); } + $property = $operationUriVariables[$key]->getToProperty() ?? $key; $this->propertyAccessor->setValue($resource, $property, $relation); From bf030ffee49e7608575457c963674f4c2010eb19 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 8 Sep 2023 16:05:11 +0200 Subject: [PATCH 010/132] fix(metadata): fix CreateProvider Behat tests (#5802) --- CreateProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CreateProvider.php b/CreateProvider.php index b805d2e..1ff9c6e 100644 --- a/CreateProvider.php +++ b/CreateProvider.php @@ -57,7 +57,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c ->create($relationClass) ->getOperation($operation->getExtraProperties()['parent_uri_template'] ?? null); try { - $relation = $this->decorated->provide($parentOperation, $uriVariables); + $relation = $this->decorated->provide($parentOperation, $uriVariables, $context); } catch (ProviderNotFoundException) { $relation = null; } From 62ee96b9226727d331b713f83f9d6bfab02c37e3 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 8 Sep 2023 16:05:29 +0200 Subject: [PATCH 011/132] fix(state): read provider without request (#5803) --- Provider/ReadProvider.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php index bf33c87..8a98132 100644 --- a/Provider/ReadProvider.php +++ b/Provider/ReadProvider.php @@ -53,7 +53,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return null; } - if (null === $filters = $request->attributes->get('_api_filters')) { + if (null === $filters = $request?->attributes->get('_api_filters')) { $queryString = RequestParser::getQueryString($request); $filters = $queryString ? RequestParser::parseRequestParams($queryString) : null; } @@ -62,7 +62,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $context['filters'] = $filters; } - if ($this->serializerContextBuilder) { + if ($this->serializerContextBuilder && $request) { // Builtin data providers are able to use the serialization context to automatically add join clauses $context += $this->serializerContextBuilder->createFromRequest($request, true, [ 'resource_class' => $operation->getClass(), @@ -87,8 +87,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new NotFoundHttpException('Not Found'); } - $request->attributes->set('data', $data); - $request->attributes->set('previous_data', $this->clone($data)); + $request?->attributes->set('data', $data); + $request?->attributes->set('previous_data', $this->clone($data)); return $data; } From cf89c2ce7fa8fbb119208d73ba5815d2f5a1aa93 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 13 Sep 2023 12:22:45 +0200 Subject: [PATCH 012/132] chore: move internal dependencies --- CallableProcessor.php | 4 +- CallableProvider.php | 4 +- Processor/RespondProcessor.php | 6 +- Processor/SerializeProcessor.php | 77 --------------------- Provider/ReadProvider.php | 9 ++- Tests/CallableProcessorTest.php | 47 +++++++++++++ Tests/CallableProviderTest.php | 38 ++++++++++ Util/CorsTrait.php | 31 +++++++++ Util/OperationRequestInitiatorTrait.php | 47 +++++++++++++ Util/RequestParser.php | 92 +++++++++++++++++++++++++ composer.json | 23 +++++-- phpunit.xml.dist | 48 +++++-------- 12 files changed, 304 insertions(+), 122 deletions(-) delete mode 100644 Processor/SerializeProcessor.php create mode 100644 Tests/CallableProcessorTest.php create mode 100644 Tests/CallableProviderTest.php create mode 100644 Util/CorsTrait.php create mode 100644 Util/OperationRequestInitiatorTrait.php create mode 100644 Util/RequestParser.php diff --git a/CallableProcessor.php b/CallableProcessor.php index 64435b6..4aace69 100644 --- a/CallableProcessor.php +++ b/CallableProcessor.php @@ -13,13 +13,13 @@ namespace ApiPlatform\State; -use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Operation; use Psr\Container\ContainerInterface; final class CallableProcessor implements ProcessorInterface { - public function __construct(private readonly ContainerInterface $locator) + public function __construct(private readonly ?ContainerInterface $locator = null) { } diff --git a/CallableProvider.php b/CallableProvider.php index 80c548b..f668447 100644 --- a/CallableProvider.php +++ b/CallableProvider.php @@ -19,7 +19,7 @@ final class CallableProvider implements ProviderInterface { - public function __construct(private readonly ContainerInterface $locator) + public function __construct(private readonly ?ContainerInterface $locator = null) { } @@ -32,7 +32,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $provider($operation, $uriVariables, $context); } - if (\is_string($provider)) { + if ($this->locator && \is_string($provider)) { if (!$this->locator->has($provider)) { throw new ProviderNotFoundException(sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); } diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index 6464f04..b1b6cdb 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -13,15 +13,15 @@ namespace ApiPlatform\State\Processor; -use ApiPlatform\Api\IriConverterInterface; -use ApiPlatform\Api\UrlGeneratorInterface; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\State\ProcessorInterface; -use ApiPlatform\Util\CloneTrait; +use ApiPlatform\Metadata\Util\CloneTrait; use Symfony\Component\HttpFoundation\Response; /** diff --git a/Processor/SerializeProcessor.php b/Processor/SerializeProcessor.php deleted file mode 100644 index 0bc4739..0000000 --- a/Processor/SerializeProcessor.php +++ /dev/null @@ -1,77 +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\State\Processor; - -use ApiPlatform\Metadata\Operation; -use ApiPlatform\Serializer\ResourceList; -use ApiPlatform\Serializer\SerializerContextBuilderInterface; -use ApiPlatform\State\ProcessorInterface; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; -use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\WebLink\GenericLinkProvider; -use Symfony\Component\WebLink\Link; - -/** - * Serializes data. - * - * @author Kévin Dunglas - */ -final class SerializeProcessor implements ProcessorInterface -{ - public function __construct(private readonly ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder) - { - } - - public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) - { - if ($data instanceof Response || !($operation->canSerialize() ?? true) || !($request = $context['request'] ?? null)) { - return $this->processor->process($data, $operation, $uriVariables, $context); - } - - // @see ApiPlatform\State\Processor\RespondProcessor - $context['original_data'] = $data; - - $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ - 'resource_class' => $operation->getClass(), - 'operation' => $operation, - ]); - - $serializerContext['uri_variables'] = $uriVariables; - - if (isset($serializerContext['output']) && \array_key_exists('class', $serializerContext['output']) && null === $serializerContext['output']['class']) { - return $this->processor->process(null, $operation, $uriVariables, $context); - } - - $resources = new ResourceList(); - $serializerContext['resources'] = &$resources; - $serializerContext[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY][] = 'resources'; - - $resourcesToPush = new ResourceList(); - $serializerContext['resources_to_push'] = &$resourcesToPush; - $serializerContext[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY][] = 'resources_to_push'; - - $serialized = $this->serializer->serialize($data, $request->getRequestFormat(), $serializerContext); - $request->attributes->set('_resources', $request->attributes->get('_resources', []) + (array) $resources); - if (\count($resourcesToPush)) { - $linkProvider = $request->attributes->get('_links', new GenericLinkProvider()); - foreach ($resourcesToPush as $resourceToPush) { - $linkProvider = $linkProvider->withLink((new Link('preload', $resourceToPush))->withAttribute('as', 'fetch')); - } - $request->attributes->set('_links', $linkProvider); - } - - return $this->processor->process($serialized, $operation, $uriVariables, $context); - } -} diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php index 8a98132..05a9182 100644 --- a/Provider/ReadProvider.php +++ b/Provider/ReadProvider.php @@ -20,9 +20,9 @@ use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\UriVariablesResolverTrait; -use ApiPlatform\Util\CloneTrait; -use ApiPlatform\Util\OperationRequestInitiatorTrait; -use ApiPlatform\Util\RequestParser; +use ApiPlatform\Metadata\Util\CloneTrait; +use ApiPlatform\State\Util\OperationRequestInitiatorTrait; +use ApiPlatform\State\Util\RequestParser; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** @@ -79,8 +79,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c if ( null === $data && 'POST' !== $operation->getMethod() - && ( - 'PUT' !== $operation->getMethod() + && ('PUT' !== $operation->getMethod() || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) ) ) { diff --git a/Tests/CallableProcessorTest.php b/Tests/CallableProcessorTest.php new file mode 100644 index 0000000..59c5deb --- /dev/null +++ b/Tests/CallableProcessorTest.php @@ -0,0 +1,47 @@ +assertEquals($data, (new CallableProcessor())->process($data, $operation)); + } + + public function testCallable(): void + { + $operation = new Get(name: 'hello', processor: fn () => ['ok']); + $this->assertEquals((new CallableProcessor())->process(new \stdClass(), $operation), ['ok']); + } + + public function testCallableServiceLocator(): void + { + $operation = new Get(name: 'hello', processor: 'processor'); + $provider = $this->createMock(ProcessorInterface::class); + $provider->method('process')->willReturn(['ok']); + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->with('processor')->willReturn(true); + $container->method('get')->with('processor')->willReturn($provider); + $this->assertEquals((new CallableProcessor($container))->process(new \stdClass(), $operation), ['ok']); + } + + public function testCallableServiceLocatorDoesNotExist(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Processor "processor" not found on operation "hello"'); + $operation = new Get(name: 'hello', processor: 'processor'); + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->with('processor')->willReturn(false); + (new CallableProcessor($container))->process(new \stdClass(), $operation); + } +} diff --git a/Tests/CallableProviderTest.php b/Tests/CallableProviderTest.php new file mode 100644 index 0000000..07dcb6b --- /dev/null +++ b/Tests/CallableProviderTest.php @@ -0,0 +1,38 @@ +expectException(ProviderNotFoundException::class); + $this->expectExceptionMessage('Provider not found on operation "hello"'); + (new CallableProvider())->provide($operation); + } + + public function testCallable(): void + { + $operation = new Get(name: 'hello', provider: fn () => ['ok']); + $this->assertEquals((new CallableProvider())->provide($operation), ['ok']); + } + + public function testCallableServiceLocator(): void + { + $operation = new Get(name: 'hello', provider: 'provider'); + $provider = $this->createMock(ProviderInterface::class); + $provider->method('provide')->willReturn(['ok']); + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->with('provider')->willReturn(true); + $container->method('get')->with('provider')->willReturn($provider); + $this->assertEquals((new CallableProvider($container))->provide($operation), ['ok']); + } +} diff --git a/Util/CorsTrait.php b/Util/CorsTrait.php new file mode 100644 index 0000000..a39ba40 --- /dev/null +++ b/Util/CorsTrait.php @@ -0,0 +1,31 @@ + + * + * 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\Util; + +use Symfony\Component\HttpFoundation\Request; + +/** + * CORS utils. + * + * @internal + * + * @author Kévin Dunglas + */ +trait CorsTrait +{ + public function isPreflightRequest(Request $request): bool + { + return $request->isMethod('OPTIONS') && $request->headers->has('Access-Control-Request-Method'); + } +} diff --git a/Util/OperationRequestInitiatorTrait.php b/Util/OperationRequestInitiatorTrait.php new file mode 100644 index 0000000..b70e9d2 --- /dev/null +++ b/Util/OperationRequestInitiatorTrait.php @@ -0,0 +1,47 @@ + + * + * 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\Util; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * @internal + */ +trait OperationRequestInitiatorTrait +{ + private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null; + + /** + * TODO: Kernel terminate remove the _api_operation attribute? + */ + private function initializeOperation(Request $request): ?HttpOperation + { + if ($request->attributes->get('_api_operation')) { + return $request->attributes->get('_api_operation'); + } + + if (null === $this->resourceMetadataCollectionFactory || null === $request->attributes->get('_api_resource_class')) { + return null; + } + + $operationName = $request->attributes->get('_api_operation_name'); + /** @var HttpOperation $operation */ + $operation = $this->resourceMetadataCollectionFactory->create($request->attributes->get('_api_resource_class'))->getOperation($operationName); + $request->attributes->set('_api_operation', $operation); + + return $operation; + } +} diff --git a/Util/RequestParser.php b/Util/RequestParser.php new file mode 100644 index 0000000..4ac1018 --- /dev/null +++ b/Util/RequestParser.php @@ -0,0 +1,92 @@ + + * + * 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\Util; + +use Symfony\Component\HttpFoundation\Request; + +/** + * Utility functions for working with Symfony's HttpFoundation request. + * + * @internal + * + * @author Teoh Han Hui + * @author Vincent Chalamon + */ +final class RequestParser +{ + private function __construct() + { + } + + /** + * Parses request parameters from the specified source. + * + * @author Rok Kralj + * + * @see https://stackoverflow.com/a/18209799/1529493 + */ + public static function parseRequestParams(string $source): array + { + // '[' is urlencoded ('%5B') in the input, but we must urldecode it in order + // to find it when replacing names with the regexp below. + $source = str_replace('%5B', '[', $source); + + $source = preg_replace_callback( + '/(^|(?<=&))[^=[&]+/', + static fn ($key): string => bin2hex(urldecode($key[0])), + $source + ); + + // parse_str urldecodes both keys and values in resulting array. + parse_str($source, $params); + + return array_combine(array_map('hex2bin', array_keys($params)), $params); + } + + /** + * Generates the normalized query string for the Request. + * + * It builds a normalized query string, where keys/value pairs are alphabetized + * and have consistent escaping. + */ + public static function getQueryString(Request $request): ?string + { + $qs = $request->server->get('QUERY_STRING', ''); + if ('' === $qs) { + return null; + } + + $parts = []; + + foreach (explode('&', (string) $qs) as $param) { + if ('' === $param || '=' === $param[0]) { + // Ignore useless delimiters, e.g. "x=y&". + // Also ignore pairs with empty key, even if there was a value, e.g. "=value", as such nameless values cannot be retrieved anyway. + // PHP also does not include them when building _GET. + continue; + } + + $keyValuePair = explode('=', $param, 2); + + // GET parameters, that are submitted from a HTML form, encode spaces as "+" by default (as defined in enctype application/x-www-form-urlencoded). + // PHP also converts "+" to spaces when filling the global _GET or when using the function parse_str. This is why we use urldecode and then normalize to + // RFC 3986 with rawurlencode. + $parts[] = isset($keyValuePair[1]) ? + rawurlencode(urldecode($keyValuePair[0])).'='.rawurlencode(urldecode($keyValuePair[1])) : + rawurlencode(urldecode($keyValuePair[0])); + } + + return implode('&', $parts); + } +} diff --git a/composer.json b/composer.json index da25eec..0c092d4 100644 --- a/composer.json +++ b/composer.json @@ -17,11 +17,15 @@ } ], "require": { - "php": ">=8.1" + "php": ">=8.1", + "api-platform/metadata": "*@dev || ^3.1", + "psr/container": "^2.0" }, "require-dev": { - "phpspec/prophecy-phpunit": "^2.0", - "symfony/phpunit-bridge": "^6.1" + "phpunit/phpunit": "^10.3", + "symfony/web-link": "^6.3", + "symfony/http-foundation": "^6.3", + "willdurand/negotiation": "^3.1" }, "autoload": { "psr-4": { @@ -48,5 +52,16 @@ "symfony": { "require": "^6.1" } - } + }, + "suggest": { + "symfony/web-link": "To support adding web links to the response headers.", + "symfony/http-foundation": "To use our HTTP providers and processor.", + "willdurand/negotiation": "To use the API Platform content negoatiation provider." + }, + "repositories": [ + { + "type": "path", + "url": "../Metadata" + } + ] } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index eec6b86..ab2f6b4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,31 +1,21 @@ - - - - - - - - - ./Tests/ - - - - - - ./ - - - ./Tests - ./vendor - - + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + - From 035765d832cee37c248f45ccd86d4698a35ee5b5 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 13 Sep 2023 12:32:14 +0200 Subject: [PATCH 013/132] chore: subtree component descriptions --- Processor/RespondProcessor.php | 6 ++-- Provider/ReadProvider.php | 2 +- Tests/CallableProcessorTest.php | 11 ++++++++ Tests/CallableProviderTest.php | 11 ++++++++ Tests/Util/RequestParserTest.php | 48 ++++++++++++++++++++++++++++++++ composer.json | 2 +- 6 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 Tests/Util/RequestParserTest.php diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index b1b6cdb..5017591 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -13,15 +13,15 @@ namespace ApiPlatform\State\Processor; -use ApiPlatform\Metadata\IriConverterInterface; -use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; -use ApiPlatform\State\ProcessorInterface; use ApiPlatform\Metadata\Util\CloneTrait; +use ApiPlatform\State\ProcessorInterface; use Symfony\Component\HttpFoundation\Response; /** diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php index 05a9182..556c10b 100644 --- a/Provider/ReadProvider.php +++ b/Provider/ReadProvider.php @@ -16,11 +16,11 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\Util\CloneTrait; use ApiPlatform\Serializer\SerializerContextBuilderInterface; use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\UriVariablesResolverTrait; -use ApiPlatform\Metadata\Util\CloneTrait; use ApiPlatform\State\Util\OperationRequestInitiatorTrait; use ApiPlatform\State\Util\RequestParser; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; diff --git a/Tests/CallableProcessorTest.php b/Tests/CallableProcessorTest.php index 59c5deb..43733ff 100644 --- a/Tests/CallableProcessorTest.php +++ b/Tests/CallableProcessorTest.php @@ -1,5 +1,16 @@ + * + * 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\Tests; use ApiPlatform\Metadata\Exception\RuntimeException; diff --git a/Tests/CallableProviderTest.php b/Tests/CallableProviderTest.php index 07dcb6b..5b51c60 100644 --- a/Tests/CallableProviderTest.php +++ b/Tests/CallableProviderTest.php @@ -1,5 +1,16 @@ + * + * 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\Tests; use ApiPlatform\Metadata\Get; diff --git a/Tests/Util/RequestParserTest.php b/Tests/Util/RequestParserTest.php new file mode 100644 index 0000000..831aac3 --- /dev/null +++ b/Tests/Util/RequestParserTest.php @@ -0,0 +1,48 @@ + + * + * 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\Tests\Util; + +use ApiPlatform\State\Util\RequestParser; +use PHPUnit\Framework\TestCase; + +/** + * @author Amrouche Hamza + */ +class RequestParserTest extends TestCase +{ + /** + * @dataProvider parseRequestParamsProvider + */ + public function testParseRequestParams(string $source, array $expected): void + { + $actual = RequestParser::parseRequestParams($source); + $this->assertSame($expected, $actual); + } + + public static function parseRequestParamsProvider(): array + { + return [ + ['gerard.name=dargent', ['gerard.name' => 'dargent']], + + // urlencoded + (plus) in query string. + ['date=2000-01-01T00%3A00%3A00%2B00%3A00', ['date' => '2000-01-01T00:00:00+00:00']], + + // urlencoded % (percent sign) in query string. + ['%2525=%2525', ['%25' => '%25']], + + // urlencoded [] (square brackets) in query string. + ['a%5B1%5D=%2525', ['a' => ['1' => '%25']]], + ]; + } +} diff --git a/composer.json b/composer.json index 0c092d4..1464415 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "api-platform/state", - "description": "API Platform state interfaces", + "description": "API Platform State component ", "type": "library", "keywords": ["REST", "GraphQL", "API", "JSON-LD", "Hydra", "JSONAPI", "OpenAPI", "HAL", "Swagger"], "homepage": "https://api-platform.com", From caf5da0bf01880004ff443cadbf91ee2981000aa Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 15 Sep 2023 15:50:03 +0200 Subject: [PATCH 014/132] fix: exception to status on error resource (#5823) --- Provider/ReadProvider.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php index 556c10b..a8b6d6e 100644 --- a/Provider/ReadProvider.php +++ b/Provider/ReadProvider.php @@ -49,7 +49,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $request = ($context['request'] ?? null); - if (!($operation->canRead() ?? true) || (!$operation->getUriVariables() && !$request?->isMethodSafe())) { + + if (!$operation->canRead()) { return null; } From f45faf71fae5e013b14d08beb039f5146eae0d85 Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Tue, 19 Sep 2023 10:30:27 +0200 Subject: [PATCH 015/132] feat(metadata): add canonical_uri_template (#5832) --- Processor/RespondProcessor.php | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index 5017591..99e6836 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; @@ -39,8 +40,11 @@ final class RespondProcessor implements ProcessorInterface 'DELETE' => Response::HTTP_NO_CONTENT, ]; - public function __construct(private ?IriConverterInterface $iriConverter = null, private readonly ?ResourceClassResolverInterface $resourceClassResolver = null) - { + public function __construct( + private ?IriConverterInterface $iriConverter = null, + private readonly ?ResourceClassResolverInterface $resourceClassResolver = null, + private readonly ?OperationMetadataFactoryInterface $operationMetadataFactory = null, + ) { } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) @@ -75,11 +79,15 @@ public function process(mixed $data, Operation $operation, array $uriVariables = if ($hasData = ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))) && $this->iriConverter) { if ( - ($operation->getExtraProperties()['is_alternate_resource_metadata'] ?? false) - && 301 === $operation->getStatus() + 300 <= $status && $status < 400 + && (($operation->getExtraProperties()['is_alternate_resource_metadata'] ?? false) || ($operation->getExtraProperties()['canonical_uri_template'] ?? null)) ) { - $status = 301; - $headers['Location'] = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, $operation); + $canonicalOperation = $operation; + if ($this->operationMetadataFactory && null !== ($operation->getExtraProperties()['canonical_uri_template'] ?? null)) { + $canonicalOperation = $this->operationMetadataFactory->create($operation->getExtraProperties()['canonical_uri_template'], $context); + } + + $headers['Location'] = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, $canonicalOperation); } elseif ('PUT' === $method && !$request->attributes->get('previous_data') && null === $status && ($operation instanceof Put && ($operation->getAllowCreate() ?? false))) { $status = 201; } From f718aa36d6db7d6bdf744b82eec9634ba6b94043 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 20 Sep 2023 17:04:12 +0200 Subject: [PATCH 016/132] fix: errors without compatibility flag (#5841) --- Processor/AddLinkHeaderProcessor.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Processor/AddLinkHeaderProcessor.php b/Processor/AddLinkHeaderProcessor.php index 59b6f85..27b7330 100644 --- a/Processor/AddLinkHeaderProcessor.php +++ b/Processor/AddLinkHeaderProcessor.php @@ -36,11 +36,9 @@ public function process(mixed $data, Operation $operation, array $uriVariables = } // We add our header here as Symfony does it only for the main Request and we want it to be done on errors (sub-request) as well - $linksProvider = $request->attributes->get('_links'); + $linksProvider = $request->attributes->get('_api_platform_links'); if ($this->serializer && ($links = $linksProvider->getLinks())) { $response->headers->set('Link', $this->serializer->serialize($links)); - // We don't want Symfony WebLink component do add links twice - $request->attributes->set('_links', []); } return $response; From a3464cdf8dce72bc07c822a4fb0b385e0a3b7e4e Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 5 Oct 2023 14:11:22 +0200 Subject: [PATCH 017/132] chore: move namespace Api Metadata (#5857) --- Processor/SerializeProcessor.php | 77 ++++++++++++++++++++++++++++++++ Provider/DeserializeProvider.php | 7 ++- 2 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 Processor/SerializeProcessor.php diff --git a/Processor/SerializeProcessor.php b/Processor/SerializeProcessor.php new file mode 100644 index 0000000..c01098e --- /dev/null +++ b/Processor/SerializeProcessor.php @@ -0,0 +1,77 @@ + + * + * 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\Processor; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Serializer\ResourceList; +use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\WebLink\GenericLinkProvider; +use Symfony\Component\WebLink\Link; + +/** + * Serializes data. + * + * @author Kévin Dunglas + */ +final class SerializeProcessor implements ProcessorInterface +{ + public function __construct(private readonly ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder) + { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if ($data instanceof Response || !$operation->canSerialize() || !($request = $context['request'] ?? null)) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + // @see ApiPlatform\State\Processor\RespondProcessor + $context['original_data'] = $data; + + $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ + 'resource_class' => $operation->getClass(), + 'operation' => $operation, + ]); + + $serializerContext['uri_variables'] = $uriVariables; + + if (isset($serializerContext['output']) && \array_key_exists('class', $serializerContext['output']) && null === $serializerContext['output']['class']) { + return $this->processor->process(null, $operation, $uriVariables, $context); + } + + $resources = new ResourceList(); + $serializerContext['resources'] = &$resources; + $serializerContext[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY][] = 'resources'; + + $resourcesToPush = new ResourceList(); + $serializerContext['resources_to_push'] = &$resourcesToPush; + $serializerContext[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY][] = 'resources_to_push'; + + $serialized = $this->serializer->serialize($data, $request->getRequestFormat(), $serializerContext); + $request->attributes->set('_resources', $request->attributes->get('_resources', []) + (array) $resources); + if (\count($resourcesToPush)) { + $linkProvider = $request->attributes->get('_api_platform_links', new GenericLinkProvider()); + foreach ($resourcesToPush as $resourceToPush) { + $linkProvider = $linkProvider->withLink((new Link('preload', $resourceToPush))->withAttribute('as', 'fetch')); + } + $request->attributes->set('_api_platform_links', $linkProvider); + } + + return $this->processor->process($serialized, $operation, $uriVariables, $context); + } +} diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index fd502ae..0bebb6b 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -51,10 +51,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $data; } - if ( - !($operation->canDeserialize() ?? true) - || !\in_array($method = $operation->getMethod(), ['POST', 'PUT', 'PATCH'], true) - ) { + if (!$operation->canDeserialize()) { return $data; } @@ -74,6 +71,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new UnsupportedMediaTypeHttpException('Format not supported.'); } + $method = $operation->getMethod(); + if ( null !== $data && ( From 746e6f61d35a5505d5c05a20dc2372565757ffc0 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 16 Oct 2023 15:59:13 +0200 Subject: [PATCH 018/132] fix(metadata): interface breaking in 3.2 (#5883) To ease subtree split we move the `Api` to `Metadata` namespace. --- UriVariablesResolverTrait.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/UriVariablesResolverTrait.php b/UriVariablesResolverTrait.php index b9e222a..5982890 100644 --- a/UriVariablesResolverTrait.php +++ b/UriVariablesResolverTrait.php @@ -13,6 +13,7 @@ namespace ApiPlatform\State; +use ApiPlatform\Api\UriVariablesConverterInterface as LegacyUriVariablesConverterInterface; use ApiPlatform\Metadata\Exception\InvalidIdentifierException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\UriVariablesConverterInterface; @@ -20,7 +21,7 @@ trait UriVariablesResolverTrait { - private ?UriVariablesConverterInterface $uriVariablesConverter = null; + private null|LegacyUriVariablesConverterInterface|UriVariablesConverterInterface $uriVariablesConverter = null; /** * Resolves an operation's UriVariables to their identifiers values. From c6dc72334594e201dcd866f885f6ee6283b62410 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 17 Oct 2023 14:56:17 +0200 Subject: [PATCH 019/132] fix(state): add link header processor without links (#5888) fixes #5886 --- Processor/AddLinkHeaderProcessor.php | 2 +- .../Processor/AddLinkHeaderProcessorTest.php | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 Tests/Processor/AddLinkHeaderProcessorTest.php diff --git a/Processor/AddLinkHeaderProcessor.php b/Processor/AddLinkHeaderProcessor.php index 27b7330..de73848 100644 --- a/Processor/AddLinkHeaderProcessor.php +++ b/Processor/AddLinkHeaderProcessor.php @@ -37,7 +37,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = // We add our header here as Symfony does it only for the main Request and we want it to be done on errors (sub-request) as well $linksProvider = $request->attributes->get('_api_platform_links'); - if ($this->serializer && ($links = $linksProvider->getLinks())) { + if ($this->serializer && ($links = $linksProvider?->getLinks())) { $response->headers->set('Link', $this->serializer->serialize($links)); } diff --git a/Tests/Processor/AddLinkHeaderProcessorTest.php b/Tests/Processor/AddLinkHeaderProcessorTest.php new file mode 100644 index 0000000..a18d798 --- /dev/null +++ b/Tests/Processor/AddLinkHeaderProcessorTest.php @@ -0,0 +1,32 @@ + + * + * 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\Tests\Processor; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\Processor\AddLinkHeaderProcessor; +use ApiPlatform\State\ProcessorInterface; +use PHPUnit\Framework\TestCase; + +class AddLinkHeaderProcessorTest extends TestCase +{ + public function testWithoutLinks(): void + { + $data = new \stdClass(); + $operation = new Get(); + $decorated = $this->createStub(ProcessorInterface::class); + $decorated->method('process')->willReturn($data); + $processor = new AddLinkHeaderProcessor($decorated); + $this->assertEquals($data, $processor->process($data, $operation)); + } +} From 53b0cc1b5cc8e257424189db246738e367403427 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 26 Oct 2023 17:52:10 +0200 Subject: [PATCH 020/132] fix: use http exception headers (#5932) fixes #5924 --- Processor/RespondProcessor.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index 99e6836..b58f972 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -13,6 +13,7 @@ namespace ApiPlatform\State\Processor; +use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; @@ -24,6 +25,7 @@ use ApiPlatform\Metadata\Util\CloneTrait; use ApiPlatform\State\ProcessorInterface; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; /** * Serializes data. @@ -64,6 +66,11 @@ public function process(mixed $data, Operation $operation, array $uriVariables = 'X-Frame-Options' => 'deny', ]; + $exception = $request->attributes->get('exception'); + if (($exception instanceof HttpExceptionInterface || $exception instanceof SymfonyHttpExceptionInterface) && $exceptionHeaders = $exception->getHeaders()) { + $headers = array_merge($headers, $exceptionHeaders); + } + $status = $operation->getStatus(); if ($sunset = $operation->getSunset()) { From ebbb911d746cb896cfd8ff8fa65e013e0a1b94cf Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 24 Nov 2023 09:43:41 +0100 Subject: [PATCH 021/132] fix: errors bc with rfc_7807_compliant_errors false (#5974) * fix: errors bc with rfc_7807_compliant_errors false --- ApiResource/Error.php | 196 ++++++++++++++++++++++++++++++++++++ ErrorProvider.php | 48 +++++++++ Tests/ErrorProviderTest.php | 32 ++++++ 3 files changed, 276 insertions(+) create mode 100644 ApiResource/Error.php create mode 100644 ErrorProvider.php create mode 100644 Tests/ErrorProviderTest.php diff --git a/ApiResource/Error.php b/ApiResource/Error.php new file mode 100644 index 0000000..d606fc7 --- /dev/null +++ b/ApiResource/Error.php @@ -0,0 +1,196 @@ + + * + * 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\ApiResource; + +use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Error as Operation; +use ApiPlatform\Metadata\ErrorResource; +use ApiPlatform\Metadata\Exception\HttpExceptionInterface; +use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Annotation\Ignore; +use Symfony\Component\Serializer\Annotation\SerializedName; +use Symfony\Component\WebLink\Link; + +#[ErrorResource( + types: ['hydra:Error'], + openapi: false, + uriVariables: ['status'], + uriTemplate: '/errors/{status}', + operations: [ + new Operation( + name: '_api_errors_problem', + outputFormats: ['json' => ['application/problem+json']], + normalizationContext: [ + 'groups' => ['jsonproblem'], + 'skip_null_values' => true, + 'rfc_7807_compliant_errors' => true, + ], + ), + new Operation( + name: '_api_errors_hydra', + outputFormats: ['jsonld' => ['application/problem+json']], + normalizationContext: [ + 'groups' => ['jsonld'], + 'skip_null_values' => true, + 'rfc_7807_compliant_errors' => true, + ], + links: [new Link(rel: ContextBuilderInterface::JSONLD_NS.'error', href: 'http://www.w3.org/ns/hydra/error')], + ), + new Operation( + name: '_api_errors_jsonapi', + outputFormats: ['jsonapi' => ['application/vnd.api+json']], + normalizationContext: [ + 'groups' => ['jsonapi'], + 'skip_null_values' => true, + 'rfc_7807_compliant_errors' => true, + ], + ), + ], + provider: 'api_platform.state.error_provider', + graphQlOperations: [] +)] +class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface +{ + public function __construct( + private string $title, + private string $detail, + #[ApiProperty(identifier: true)] private int $status, + array $originalTrace = null, + private ?string $instance = null, + private string $type = 'about:blank', + private array $headers = [], + \Throwable $previous = null + ) { + parent::__construct($title, $status, $previous); + + if (!$originalTrace) { + return; + } + + $this->originalTrace = []; + foreach ($originalTrace as $i => $t) { + unset($t['args']); // we don't want arguments in our JSON traces, especially with xdebug + $this->originalTrace[$i] = $t; + } + } + + #[SerializedName('trace')] + #[Groups(['trace'])] + public ?array $originalTrace = null; + + #[SerializedName('hydra:title')] + #[Groups(['jsonld'])] + public function getHydraTitle(): ?string + { + return $this->title; + } + + #[SerializedName('hydra:description')] + #[Groups(['jsonld'])] + public function getHydraDescription(): ?string + { + return $this->detail; + } + + #[SerializedName('description')] + public function getDescription(): ?string + { + return $this->detail; + } + + public static function createFromException(\Exception|\Throwable $exception, int $status): self + { + $headers = ($exception instanceof SymfonyHttpExceptionInterface || $exception instanceof HttpExceptionInterface) ? $exception->getHeaders() : []; + + return new self('An error occurred', $exception->getMessage(), $status, $exception->getTrace(), type: "/errors/$status", headers: $headers, previous: $exception->getPrevious()); + } + + #[Ignore] + public function getHeaders(): array + { + return $this->headers; + } + + #[Ignore] + public function getStatusCode(): int + { + return $this->status; + } + + /** + * @param array $headers + */ + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + #[Groups(['jsonld', 'jsonproblem'])] + public function getType(): string + { + return $this->type; + } + + public function setType(string $type): void + { + $this->type = $type; + } + + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title = null): void + { + $this->title = $title; + } + + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] + public function getStatus(): ?int + { + return $this->status; + } + + public function setStatus(int $status): void + { + $this->status = $status; + } + + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] + public function getDetail(): ?string + { + return $this->detail; + } + + public function setDetail(string $detail = null): void + { + $this->detail = $detail; + } + + #[Groups(['jsonld', 'jsonproblem'])] + public function getInstance(): ?string + { + return $this->instance; + } + + public function setInstance(string $instance = null): void + { + $this->instance = $instance; + } +} diff --git a/ErrorProvider.php b/ErrorProvider.php new file mode 100644 index 0000000..e85a7f3 --- /dev/null +++ b/ErrorProvider.php @@ -0,0 +1,48 @@ + + * + * 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; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\State\ApiResource\Error; + +/** + * @internal + */ +final class ErrorProvider implements ProviderInterface +{ + public function __construct(private readonly bool $debug = false, private ?ResourceClassResolverInterface $resourceClassResolver = null) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object + { + if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation || null === ($exception = $request->attributes->get('exception'))) { + throw new \RuntimeException('Not an HTTP request'); + } + + if ($this->resourceClassResolver?->isResourceClass($exception::class)) { + return $exception; + } + + $status = $operation->getStatus() ?? 500; + $error = Error::createFromException($exception, $status); + if (!$this->debug && $status >= 500) { + $error->setDetail('Internal Server Error'); + } + + return $error; + } +} diff --git a/Tests/ErrorProviderTest.php b/Tests/ErrorProviderTest.php new file mode 100644 index 0000000..7bf1524 --- /dev/null +++ b/Tests/ErrorProviderTest.php @@ -0,0 +1,32 @@ + + * + * 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\Tests; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\ErrorProvider; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +class ErrorProviderTest extends TestCase +{ + public function testErrorProviderProduction(): void + { + $provider = new ErrorProvider(debug: false); + $request = Request::create('/'); + $request->attributes->set('exception', new \Exception()); + /** @var \ApiPlatform\State\ApiResource\Error */ + $error = $provider->provide(new Get(), [], ['request' => $request]); + $this->assertEquals('Internal Server Error', $error->getDetail()); + } +} From e18c5d4c2610f4e346ae41a90b9434e00ff5298f Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 13 Dec 2023 15:17:18 +0100 Subject: [PATCH 022/132] ci: conflict sebastian/comparator (#6032) * ci: conflict sebastian/comparator * for lowest --- composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer.json b/composer.json index 1464415..95addc2 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,9 @@ "/Tests/" ] }, + "conflict": { + "sebastian/comparator": ">=5.0" + }, "config": { "preferred-install": { "*": "dist" From f825758dd57b4eae23e1b9b89f877db08b246831 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 23 Dec 2023 09:31:52 +0100 Subject: [PATCH 023/132] fix(symfony): use Type constraint violation code instead of exception code --- Provider/DeserializeProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index 0bebb6b..b221727 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -97,7 +97,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c if ($exception->canUseMessageForUser()) { $parameters['hint'] = $exception->getMessage(); } - $violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $exception->getExpectedTypes() ?? [])], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) $exception->getCode())); + $violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $exception->getExpectedTypes() ?? [])], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR)); } if (0 !== \count($violations)) { throw new ValidationException($violations); From 108d6b2f255ae5dc2393c43c31564ce2eb4d6fd5 Mon Sep 17 00:00:00 2001 From: Priyadi Iman Nurcahyo Date: Fri, 5 Jan 2024 16:04:46 +0700 Subject: [PATCH 024/132] fix(symfony): handle empty content-type as set by Symfony (#6078) * fix: requests with an empty content type In PHP, requests without content-type will result in a content-type header containing an empty string, not null * test: add tests for content type containing an empty string * test: added a test for DeserializeListener --- Provider/ContentNegotiationProvider.php | 3 ++- Provider/DeserializeProvider.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Provider/ContentNegotiationProvider.php b/Provider/ContentNegotiationProvider.php index 8b3cb53..05da2a0 100644 --- a/Provider/ContentNegotiationProvider.php +++ b/Provider/ContentNegotiationProvider.php @@ -97,7 +97,8 @@ private function flattenMimeTypes(array $formats): array */ private function getInputFormat(HttpOperation $operation, Request $request): ?string { - if (null === ($contentType = $request->headers->get('CONTENT_TYPE'))) { + $contentType = $request->headers->get('CONTENT_TYPE'); + if (null === $contentType || '' === $contentType) { return null; } diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index b221727..09a3f7b 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -56,7 +56,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $contentType = $request->headers->get('CONTENT_TYPE'); - if (null === $contentType) { + if (null === $contentType || '' === $contentType) { throw new UnsupportedMediaTypeHttpException('The "Content-Type" header must exist.'); } From 37958e392339f157320a60dd058bfd4f8c915bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Mon, 15 Jan 2024 21:48:59 +0100 Subject: [PATCH 025/132] fix: better generics support for State\ProcessorInterface (#6103) * fix: better generics support for State\ProcessorInterface --- CallableProcessor.php | 8 +++++++- Processor/AddLinkHeaderProcessor.php | 9 +++++++++ Processor/RespondProcessor.php | 2 +- Processor/SerializeProcessor.php | 8 ++++++++ Processor/WriteProcessor.php | 9 +++++++++ ProcessorInterface.php | 15 +++++++++------ ProviderInterface.php | 8 +++++--- 7 files changed, 48 insertions(+), 11 deletions(-) diff --git a/CallableProcessor.php b/CallableProcessor.php index 4aace69..e913283 100644 --- a/CallableProcessor.php +++ b/CallableProcessor.php @@ -17,6 +17,12 @@ use ApiPlatform\Metadata\Operation; use Psr\Container\ContainerInterface; +/** + * @template T1 + * @template T2 + * + * @implements ProcessorInterface + */ final class CallableProcessor implements ProcessorInterface { public function __construct(private readonly ?ContainerInterface $locator = null) @@ -40,7 +46,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = throw new RuntimeException(sprintf('Processor "%s" not found on operation "%s"', $processor, $operation->getName())); } - /** @var ProcessorInterface $processorInstance */ + /** @var ProcessorInterface $processorInstance */ $processorInstance = $this->locator->get($processor); return $processorInstance->process($data, $operation, $uriVariables, $context); diff --git a/Processor/AddLinkHeaderProcessor.php b/Processor/AddLinkHeaderProcessor.php index de73848..2f58e92 100644 --- a/Processor/AddLinkHeaderProcessor.php +++ b/Processor/AddLinkHeaderProcessor.php @@ -18,8 +18,17 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\WebLink\HttpHeaderSerializer; +/** + * @template T1 + * @template T2 + * + * @implements ProcessorInterface + */ final class AddLinkHeaderProcessor implements ProcessorInterface { + /** + * @param ProcessorInterface $decorated + */ public function __construct(private readonly ProcessorInterface $decorated, private readonly ?HttpHeaderSerializer $serializer = new HttpHeaderSerializer()) { } diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index b58f972..0dec1fa 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -74,7 +74,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $status = $operation->getStatus(); if ($sunset = $operation->getSunset()) { - $headers['Sunset'] = (new \DateTimeImmutable($sunset))->format(\DateTime::RFC1123); + $headers['Sunset'] = (new \DateTimeImmutable($sunset))->format(\DateTimeInterface::RFC1123); } if ($acceptPatch = $operation->getAcceptPatch()) { diff --git a/Processor/SerializeProcessor.php b/Processor/SerializeProcessor.php index c01098e..a2cc9da 100644 --- a/Processor/SerializeProcessor.php +++ b/Processor/SerializeProcessor.php @@ -26,10 +26,18 @@ /** * Serializes data. * + * @template T1 + * @template T2 + * + * @implements ProcessorInterface + * * @author Kévin Dunglas */ final class SerializeProcessor implements ProcessorInterface { + /** + * @param ProcessorInterface $processor + */ public function __construct(private readonly ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder) { } diff --git a/Processor/WriteProcessor.php b/Processor/WriteProcessor.php index eb611ac..b449d87 100644 --- a/Processor/WriteProcessor.php +++ b/Processor/WriteProcessor.php @@ -21,6 +21,11 @@ /** * Bridges persistence and the API system. * + * @template T1 + * @template T2 + * + * @implements ProcessorInterface + * * @author Kévin Dunglas * @author Baptiste Meyer */ @@ -28,6 +33,10 @@ final class WriteProcessor implements ProcessorInterface { use ClassInfoTrait; + /** + * @param ProcessorInterface $processor + * @param ProcessorInterface $callableProcessor + */ public function __construct(private readonly ProcessorInterface $processor, private readonly ProcessorInterface $callableProcessor) { } diff --git a/ProcessorInterface.php b/ProcessorInterface.php index 2dfbf86..b88f77f 100644 --- a/ProcessorInterface.php +++ b/ProcessorInterface.php @@ -14,23 +14,26 @@ namespace ApiPlatform\State; use ApiPlatform\Metadata\Operation; +use Symfony\Component\HttpFoundation\Request; /** - * Process data: send an email, persist to storage, add to queue etc. + * Processes data: sends an email, persists to storage, adds to queue etc. * - * @template T + * @template T1 + * @template T2 * * @author Antoine Bluchet */ interface ProcessorInterface { /** - * Handle the state. + * Handles the state. * - * @param array $uriVariables - * @param array&array{request?: \Symfony\Component\HttpFoundation\Request, previous_data?: mixed, resource_class?: string, original_data?: mixed} $context + * @param T1 $data + * @param array $uriVariables + * @param array&array{request?: Request, previous_data?: mixed, resource_class?: string, original_data?: mixed} $context * - * @return T + * @return T2 */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []); } diff --git a/ProviderInterface.php b/ProviderInterface.php index 7404f36..eadcaa0 100644 --- a/ProviderInterface.php +++ b/ProviderInterface.php @@ -14,6 +14,8 @@ namespace ApiPlatform\State; use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; +use Symfony\Component\HttpFoundation\Request; /** * Retrieves data from a persistence layer. @@ -27,10 +29,10 @@ interface ProviderInterface /** * Provides data. * - * @param array $uriVariables - * @param array|array{request?: \Symfony\Component\HttpFoundation\Request, resource_class?: string} $context + * @param array $uriVariables + * @param array|array{request?: Request, resource_class?: string} $context * - * @return T|Pagination\PartialPaginatorInterface|iterable|null + * @return T|PartialPaginatorInterface|iterable|null */ public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null; } From 9d1a313be8631fc9d0ac553af2b7fb56201e30e9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 19 Jan 2024 11:45:15 +0100 Subject: [PATCH 026/132] feat(symfony): request and view kernel listeners (#6102) --- Processor/SerializeProcessor.php | 10 +++++----- Processor/WriteProcessor.php | 12 +++++++----- Provider/ContentNegotiationProvider.php | 6 +++--- Provider/DeserializeProvider.php | 8 ++++---- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/Processor/SerializeProcessor.php b/Processor/SerializeProcessor.php index a2cc9da..63d3957 100644 --- a/Processor/SerializeProcessor.php +++ b/Processor/SerializeProcessor.php @@ -36,16 +36,16 @@ final class SerializeProcessor implements ProcessorInterface { /** - * @param ProcessorInterface $processor + * @param ProcessorInterface|null $processor */ - public function __construct(private readonly ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder) + public function __construct(private readonly ?ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder) { } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { if ($data instanceof Response || !$operation->canSerialize() || !($request = $context['request'] ?? null)) { - return $this->processor->process($data, $operation, $uriVariables, $context); + return $this->processor ? $this->processor->process($data, $operation, $uriVariables, $context) : $data; } // @see ApiPlatform\State\Processor\RespondProcessor @@ -59,7 +59,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $serializerContext['uri_variables'] = $uriVariables; if (isset($serializerContext['output']) && \array_key_exists('class', $serializerContext['output']) && null === $serializerContext['output']['class']) { - return $this->processor->process(null, $operation, $uriVariables, $context); + return $this->processor ? $this->processor->process(null, $operation, $uriVariables, $context) : null; } $resources = new ResourceList(); @@ -80,6 +80,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $request->attributes->set('_api_platform_links', $linkProvider); } - return $this->processor->process($serialized, $operation, $uriVariables, $context); + return $this->processor ? $this->processor->process($serialized, $operation, $uriVariables, $context) : $serialized; } } diff --git a/Processor/WriteProcessor.php b/Processor/WriteProcessor.php index b449d87..ed37837 100644 --- a/Processor/WriteProcessor.php +++ b/Processor/WriteProcessor.php @@ -34,10 +34,10 @@ final class WriteProcessor implements ProcessorInterface use ClassInfoTrait; /** - * @param ProcessorInterface $processor - * @param ProcessorInterface $callableProcessor + * @param ProcessorInterface $processor + * @param ProcessorInterface $callableProcessor */ - public function __construct(private readonly ProcessorInterface $processor, private readonly ProcessorInterface $callableProcessor) + public function __construct(private readonly ?ProcessorInterface $processor, private readonly ProcessorInterface $callableProcessor) { } @@ -48,9 +48,11 @@ public function process(mixed $data, Operation $operation, array $uriVariables = || !($operation->canWrite() ?? true) || !$operation->getProcessor() ) { - return $this->processor->process($data, $operation, $uriVariables, $context); + return $this->processor ? $this->processor->process($data, $operation, $uriVariables, $context) : $data; } - return $this->processor->process($this->callableProcessor->process($data, $operation, $uriVariables, $context), $operation, $uriVariables, $context); + $data = $this->callableProcessor->process($data, $operation, $uriVariables, $context); + + return $this->processor ? $this->processor->process($data, $operation, $uriVariables, $context) : $data; } } diff --git a/Provider/ContentNegotiationProvider.php b/Provider/ContentNegotiationProvider.php index 05da2a0..8f2b7e8 100644 --- a/Provider/ContentNegotiationProvider.php +++ b/Provider/ContentNegotiationProvider.php @@ -30,7 +30,7 @@ final class ContentNegotiationProvider implements ProviderInterface * @param array $formats * @param array $errorFormats */ - public function __construct(private readonly ProviderInterface $decorated, Negotiator $negotiator = null, private readonly array $formats = [], private readonly array $errorFormats = []) + public function __construct(private readonly ?ProviderInterface $decorated = null, Negotiator $negotiator = null, private readonly array $formats = [], private readonly array $errorFormats = []) { $this->negotiator = $negotiator ?? new Negotiator(); } @@ -38,7 +38,7 @@ public function __construct(private readonly ProviderInterface $decorated, Negot public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation) { - return $this->decorated->provide($operation, $uriVariables, $context); + return $this->decorated?->provide($operation, $uriVariables, $context); } $isErrorOperation = $operation instanceof ErrorOperation; @@ -53,7 +53,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $request->setRequestFormat($this->getRequestFormat($request, $formats, false)); } - return $this->decorated->provide($operation, $uriVariables, $context); + return $this->decorated?->provide($operation, $uriVariables, $context); } /** diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index 09a3f7b..b4b4131 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -32,7 +32,7 @@ final class DeserializeProvider implements ProviderInterface { - public function __construct(private readonly ProviderInterface $decorated, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, private ?TranslatorInterface $translator = null) + public function __construct(private readonly ?ProviderInterface $decorated, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, private ?TranslatorInterface $translator = null) { if (null === $this->translator) { $this->translator = new class() implements TranslatorInterface, LocaleAwareInterface { @@ -44,13 +44,13 @@ public function __construct(private readonly ProviderInterface $decorated, priva public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - $data = $this->decorated->provide($operation, $uriVariables, $context); - // We need request content if (!$operation instanceof HttpOperation || !($request = $context['request'] ?? null)) { - return $data; + return $this->decorated?->provide($operation, $uriVariables, $context); } + $data = $this->decorated ? $this->decorated->provide($operation, $uriVariables, $context) : $request->attributes->get('data'); + if (!$operation->canDeserialize()) { return $data; } From e88d785b6c74600ad9f1373eccf3a46559af9552 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 19 Jan 2024 12:09:20 +0100 Subject: [PATCH 027/132] feat(metadata): headers configuration (#6074) --- Processor/RespondProcessor.php | 11 ++++++++--- Provider/ReadProvider.php | 8 +++++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index 0dec1fa..bd0eb89 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -71,6 +71,10 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $headers = array_merge($headers, $exceptionHeaders); } + if ($operationHeaders = $operation->getHeaders()) { + $headers = array_merge($headers, $operationHeaders); + } + $status = $operation->getStatus(); if ($sunset = $operation->getSunset()) { @@ -86,7 +90,8 @@ public function process(mixed $data, Operation $operation, array $uriVariables = if ($hasData = ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))) && $this->iriConverter) { if ( - 300 <= $status && $status < 400 + !isset($headers['Location']) + && 300 <= $status && $status < 400 && (($operation->getExtraProperties()['is_alternate_resource_metadata'] ?? false) || ($operation->getExtraProperties()['canonical_uri_template'] ?? null)) ) { $canonicalOperation = $operation; @@ -102,11 +107,11 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $status ??= self::METHOD_TO_CODE[$method] ?? 200; - if ($hasData && $this->iriConverter) { + if ($hasData && $this->iriConverter && !isset($headers['Content-Location'])) { $iri = $this->iriConverter->getIriFromResource($originalData); $headers['Content-Location'] = $iri; - if ((201 === $status || (300 <= $status && $status < 400)) && 'POST' === $method) { + if ((201 === $status || (300 <= $status && $status < 400)) && 'POST' === $method && !isset($headers['Location'])) { $headers['Location'] = $iri; } } diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php index a8b6d6e..11209c9 100644 --- a/Provider/ReadProvider.php +++ b/Provider/ReadProvider.php @@ -49,11 +49,17 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $request = ($context['request'] ?? null); - if (!$operation->canRead()) { return null; } + $output = $operation->getOutput() ?? []; + if (\array_key_exists('class', $output) && null === $output['class']) { + $request?->attributes->set('data', null); + + return null; + } + if (null === $filters = $request?->attributes->get('_api_filters')) { $queryString = RequestParser::getQueryString($request); $filters = $queryString ? RequestParser::parseRequestParams($queryString) : null; From 0710d5269f079e95fcf36e47fd936d38a3f728d6 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 19 Jan 2024 19:55:30 +0100 Subject: [PATCH 028/132] chore: components dependencies (#6113) * chore: components dependencies * test --- .gitattributes | 2 ++ Pagination/Pagination.php | 2 +- Provider/DeserializeProvider.php | 6 ++++- Util/RequestAttributesExtractor.php | 40 +++++++++++++++++++++++++++++ composer.json | 26 +++++++++++++++---- 5 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 .gitattributes create mode 100644 Util/RequestAttributesExtractor.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ae3c2e1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/.gitignore export-ignore +/Tests export-ignore diff --git a/Pagination/Pagination.php b/Pagination/Pagination.php index e680a47..45c51ec 100644 --- a/Pagination/Pagination.php +++ b/Pagination/Pagination.php @@ -13,7 +13,7 @@ namespace ApiPlatform\State\Pagination; -use ApiPlatform\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; /** diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index b4b4131..bea57d6 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -17,7 +17,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Serializer\SerializerContextBuilderInterface; use ApiPlatform\State\ProviderInterface; -use ApiPlatform\Symfony\Validator\Exception\ValidationException; +use ApiPlatform\Validator\Exception\ValidationException; use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\PartialDenormalizationException; @@ -87,6 +87,10 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { return $this->serializer->deserialize((string) $request->getContent(), $operation->getClass(), $format, $serializerContext); } catch (PartialDenormalizationException $e) { + if (!class_exists(ConstraintViolationList::class)) { + throw $e; + } + $violations = new ConstraintViolationList(); foreach ($e->getErrors() as $exception) { if (!$exception instanceof NotNormalizableValueException) { diff --git a/Util/RequestAttributesExtractor.php b/Util/RequestAttributesExtractor.php new file mode 100644 index 0000000..87f6f38 --- /dev/null +++ b/Util/RequestAttributesExtractor.php @@ -0,0 +1,40 @@ + + * + * 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\Util; + +use ApiPlatform\Metadata\Util\AttributesExtractor; +use Symfony\Component\HttpFoundation\Request; + +/** + * Extracts data used by the library form a Request instance. + * + * @internal + * + * @author Kévin Dunglas + */ +final class RequestAttributesExtractor +{ + private function __construct() + { + } + + /** + * Extracts resource class, operation name and format request attributes. Returns an empty array if the request does + * not contain required attributes. + */ + public static function extractAttributes(Request $request): array + { + return AttributesExtractor::extractAttributes($request->attributes->all()); + } +} diff --git a/composer.json b/composer.json index 55eeaf6..e5526fa 100644 --- a/composer.json +++ b/composer.json @@ -19,14 +19,16 @@ "require": { "php": ">=8.1", "api-platform/metadata": "*@dev || ^3.1", + "api-platform/jsonld": "*@dev || ^3.1", "psr/container": "^2.0" }, "require-dev": { "phpunit/phpunit": "^10.3", - "symfony/web-link": "^6.3", - "symfony/http-foundation": "^6.3", + "symfony/web-link": "^6.4 || ^7.0", + "symfony/http-foundation": "^6.4 || 7.0", "willdurand/negotiation": "^3.1", - "sebastian/comparator": "<5.0" + "api-platform/serializer": "*@dev || ^3.1", + "api-platform/validator": "*@dev || ^3.1" }, "autoload": { "psr-4": { @@ -51,18 +53,32 @@ "dev-main": "3.2.x-dev" }, "symfony": { - "require": "^6.1" + "require": "^6.4" } }, "suggest": { "symfony/web-link": "To support adding web links to the response headers.", "symfony/http-foundation": "To use our HTTP providers and processor.", - "willdurand/negotiation": "To use the API Platform content negoatiation provider." + "willdurand/negotiation": "To use the API Platform content negoatiation provider.", + "api-platform/serializer": "To use API Platform serializer.", + "api-platform/validator": "To use API Platform validation." }, "repositories": [ { "type": "path", "url": "../Metadata" + }, + { + "type": "path", + "url": "../Serializer" + }, + { + "type": "path", + "url": "../Validator" + }, + { + "type": "path", + "url": "../JsonLd" } ] } From cf98968e05f80e3dba306990e41bce1eed093c66 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 2 Feb 2024 22:45:18 +0100 Subject: [PATCH 029/132] chore: fix CI (#6143) * chore: fix wrong namespace in test document * chore: fix CS nullable_type_declaration_for_default_null_value * chore: update GitHub Actions versions --- ApiResource/Error.php | 10 +++++----- Pagination/Pagination.php | 14 +++++++------- Provider/ContentNegotiationProvider.php | 2 +- UriVariablesResolverTrait.php | 4 ++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index d606fc7..3141026 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -69,11 +69,11 @@ public function __construct( private string $title, private string $detail, #[ApiProperty(identifier: true)] private int $status, - array $originalTrace = null, + ?array $originalTrace = null, private ?string $instance = null, private string $type = 'about:blank', private array $headers = [], - \Throwable $previous = null + ?\Throwable $previous = null ) { parent::__construct($title, $status, $previous); @@ -156,7 +156,7 @@ public function getTitle(): ?string return $this->title; } - public function setTitle(string $title = null): void + public function setTitle(?string $title = null): void { $this->title = $title; } @@ -178,7 +178,7 @@ public function getDetail(): ?string return $this->detail; } - public function setDetail(string $detail = null): void + public function setDetail(?string $detail = null): void { $this->detail = $detail; } @@ -189,7 +189,7 @@ public function getInstance(): ?string return $this->instance; } - public function setInstance(string $instance = null): void + public function setInstance(?string $instance = null): void { $this->instance = $instance; } diff --git a/Pagination/Pagination.php b/Pagination/Pagination.php index e680a47..28c21a6 100644 --- a/Pagination/Pagination.php +++ b/Pagination/Pagination.php @@ -70,7 +70,7 @@ public function getPage(array $context = []): int /** * Gets the current offset. */ - public function getOffset(Operation $operation = null, array $context = []): int + public function getOffset(?Operation $operation = null, array $context = []): int { $graphql = (bool) ($context['graphql_operation_name'] ?? false); @@ -102,7 +102,7 @@ public function getOffset(Operation $operation = null, array $context = []): int * * @throws InvalidArgumentException */ - public function getLimit(Operation $operation = null, array $context = []): int + public function getLimit(?Operation $operation = null, array $context = []): int { $graphql = (bool) ($context['graphql_operation_name'] ?? false); @@ -148,7 +148,7 @@ public function getLimit(Operation $operation = null, array $context = []): int * * @throws InvalidArgumentException */ - public function getPagination(Operation $operation = null, array $context = []): array + public function getPagination(?Operation $operation = null, array $context = []): array { $page = $this->getPage($context); $limit = $this->getLimit($operation, $context); @@ -163,7 +163,7 @@ public function getPagination(Operation $operation = null, array $context = []): /** * Is the pagination enabled? */ - public function isEnabled(Operation $operation = null, array $context = []): bool + public function isEnabled(?Operation $operation = null, array $context = []): bool { return $this->getEnabled($context, $operation); } @@ -171,7 +171,7 @@ public function isEnabled(Operation $operation = null, array $context = []): boo /** * Is the pagination enabled for GraphQL? */ - public function isGraphQlEnabled(Operation $operation = null, array $context = []): bool + public function isGraphQlEnabled(?Operation $operation = null, array $context = []): bool { return $this->getGraphQlEnabled($operation); } @@ -179,7 +179,7 @@ public function isGraphQlEnabled(Operation $operation = null, array $context = [ /** * Is the partial pagination enabled? */ - public function isPartialEnabled(Operation $operation = null, array $context = []): bool + public function isPartialEnabled(?Operation $operation = null, array $context = []): bool { return $this->getEnabled($context, $operation, true); } @@ -197,7 +197,7 @@ public function getGraphQlPaginationType(Operation $operation): string /** * Is the classic or partial pagination enabled? */ - private function getEnabled(array $context, Operation $operation = null, bool $partial = false): bool + private function getEnabled(array $context, ?Operation $operation = null, bool $partial = false): bool { $enabled = $this->options[$partial ? 'partial' : 'enabled']; $clientEnabled = $this->options[$partial ? 'client_partial' : 'client_enabled']; diff --git a/Provider/ContentNegotiationProvider.php b/Provider/ContentNegotiationProvider.php index 05da2a0..a6baf0e 100644 --- a/Provider/ContentNegotiationProvider.php +++ b/Provider/ContentNegotiationProvider.php @@ -30,7 +30,7 @@ final class ContentNegotiationProvider implements ProviderInterface * @param array $formats * @param array $errorFormats */ - public function __construct(private readonly ProviderInterface $decorated, Negotiator $negotiator = null, private readonly array $formats = [], private readonly array $errorFormats = []) + public function __construct(private readonly ProviderInterface $decorated, ?Negotiator $negotiator = null, private readonly array $formats = [], private readonly array $errorFormats = []) { $this->negotiator = $negotiator ?? new Negotiator(); } diff --git a/UriVariablesResolverTrait.php b/UriVariablesResolverTrait.php index b67695b..ffabb6d 100644 --- a/UriVariablesResolverTrait.php +++ b/UriVariablesResolverTrait.php @@ -21,12 +21,12 @@ trait UriVariablesResolverTrait { - private null|LegacyUriVariablesConverterInterface|UriVariablesConverterInterface $uriVariablesConverter = null; + private LegacyUriVariablesConverterInterface|UriVariablesConverterInterface|null $uriVariablesConverter = null; /** * Resolves an operation's UriVariables to their identifiers values. */ - private function getOperationUriVariables(HttpOperation $operation = null, array $parameters = [], string $resourceClass = null): array + private function getOperationUriVariables(?HttpOperation $operation = null, array $parameters = [], ?string $resourceClass = null): array { $identifiers = []; From 12369e125641f08e9ec12ad96e5e95edca713e69 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 3 Feb 2024 13:33:43 +0100 Subject: [PATCH 030/132] Merge 3.2 (#6145) * fix(hydra): move owl:maxCardinality from JsonSchema to Hydra (#6136) * docs: changelog v3.2.13 * cs: missing strict type * docs: guide sf/apip version * test: security configuration * chore: fix CI (#6143) * chore: fix wrong namespace in test document * chore: fix CS nullable_type_declaration_for_default_null_value * chore: update GitHub Actions versions --------- Co-authored-by: Vincent <407859+vincentchalamon@users.noreply.github.com> --- ApiResource/Error.php | 10 +++++----- Pagination/Pagination.php | 14 +++++++------- UriVariablesResolverTrait.php | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index d606fc7..3141026 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -69,11 +69,11 @@ public function __construct( private string $title, private string $detail, #[ApiProperty(identifier: true)] private int $status, - array $originalTrace = null, + ?array $originalTrace = null, private ?string $instance = null, private string $type = 'about:blank', private array $headers = [], - \Throwable $previous = null + ?\Throwable $previous = null ) { parent::__construct($title, $status, $previous); @@ -156,7 +156,7 @@ public function getTitle(): ?string return $this->title; } - public function setTitle(string $title = null): void + public function setTitle(?string $title = null): void { $this->title = $title; } @@ -178,7 +178,7 @@ public function getDetail(): ?string return $this->detail; } - public function setDetail(string $detail = null): void + public function setDetail(?string $detail = null): void { $this->detail = $detail; } @@ -189,7 +189,7 @@ public function getInstance(): ?string return $this->instance; } - public function setInstance(string $instance = null): void + public function setInstance(?string $instance = null): void { $this->instance = $instance; } diff --git a/Pagination/Pagination.php b/Pagination/Pagination.php index 45c51ec..9d07063 100644 --- a/Pagination/Pagination.php +++ b/Pagination/Pagination.php @@ -70,7 +70,7 @@ public function getPage(array $context = []): int /** * Gets the current offset. */ - public function getOffset(Operation $operation = null, array $context = []): int + public function getOffset(?Operation $operation = null, array $context = []): int { $graphql = (bool) ($context['graphql_operation_name'] ?? false); @@ -102,7 +102,7 @@ public function getOffset(Operation $operation = null, array $context = []): int * * @throws InvalidArgumentException */ - public function getLimit(Operation $operation = null, array $context = []): int + public function getLimit(?Operation $operation = null, array $context = []): int { $graphql = (bool) ($context['graphql_operation_name'] ?? false); @@ -148,7 +148,7 @@ public function getLimit(Operation $operation = null, array $context = []): int * * @throws InvalidArgumentException */ - public function getPagination(Operation $operation = null, array $context = []): array + public function getPagination(?Operation $operation = null, array $context = []): array { $page = $this->getPage($context); $limit = $this->getLimit($operation, $context); @@ -163,7 +163,7 @@ public function getPagination(Operation $operation = null, array $context = []): /** * Is the pagination enabled? */ - public function isEnabled(Operation $operation = null, array $context = []): bool + public function isEnabled(?Operation $operation = null, array $context = []): bool { return $this->getEnabled($context, $operation); } @@ -171,7 +171,7 @@ public function isEnabled(Operation $operation = null, array $context = []): boo /** * Is the pagination enabled for GraphQL? */ - public function isGraphQlEnabled(Operation $operation = null, array $context = []): bool + public function isGraphQlEnabled(?Operation $operation = null, array $context = []): bool { return $this->getGraphQlEnabled($operation); } @@ -179,7 +179,7 @@ public function isGraphQlEnabled(Operation $operation = null, array $context = [ /** * Is the partial pagination enabled? */ - public function isPartialEnabled(Operation $operation = null, array $context = []): bool + public function isPartialEnabled(?Operation $operation = null, array $context = []): bool { return $this->getEnabled($context, $operation, true); } @@ -197,7 +197,7 @@ public function getGraphQlPaginationType(Operation $operation): string /** * Is the classic or partial pagination enabled? */ - private function getEnabled(array $context, Operation $operation = null, bool $partial = false): bool + private function getEnabled(array $context, ?Operation $operation = null, bool $partial = false): bool { $enabled = $this->options[$partial ? 'partial' : 'enabled']; $clientEnabled = $this->options[$partial ? 'client_partial' : 'client_enabled']; diff --git a/UriVariablesResolverTrait.php b/UriVariablesResolverTrait.php index b67695b..ffabb6d 100644 --- a/UriVariablesResolverTrait.php +++ b/UriVariablesResolverTrait.php @@ -21,12 +21,12 @@ trait UriVariablesResolverTrait { - private null|LegacyUriVariablesConverterInterface|UriVariablesConverterInterface $uriVariablesConverter = null; + private LegacyUriVariablesConverterInterface|UriVariablesConverterInterface|null $uriVariablesConverter = null; /** * Resolves an operation's UriVariables to their identifiers values. */ - private function getOperationUriVariables(HttpOperation $operation = null, array $parameters = [], string $resourceClass = null): array + private function getOperationUriVariables(?HttpOperation $operation = null, array $parameters = [], ?string $resourceClass = null): array { $identifiers = []; From c3e97250fb37f8ab992e0beb965ab715ec9dd49a Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Sun, 4 Feb 2024 14:20:47 +0100 Subject: [PATCH 031/132] chore: fix CI (#6144) * chore: disable keep_legacy_inflector in Doctrine tests * chore: fix Doctrine ORM enable_lazy_ghost_objects deprecation in tests configuration * chore: fix null value for CS * chore: fix CS post rebase from main * chore: remove useless legacy test services * chore: fix YAML with Symfony dev dependencies --- Provider/ContentNegotiationProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Provider/ContentNegotiationProvider.php b/Provider/ContentNegotiationProvider.php index 8f2b7e8..95d165b 100644 --- a/Provider/ContentNegotiationProvider.php +++ b/Provider/ContentNegotiationProvider.php @@ -30,7 +30,7 @@ final class ContentNegotiationProvider implements ProviderInterface * @param array $formats * @param array $errorFormats */ - public function __construct(private readonly ?ProviderInterface $decorated = null, Negotiator $negotiator = null, private readonly array $formats = [], private readonly array $errorFormats = []) + public function __construct(private readonly ?ProviderInterface $decorated = null, ?Negotiator $negotiator = null, private readonly array $formats = [], private readonly array $errorFormats = []) { $this->negotiator = $negotiator ?? new Negotiator(); } From a23f82b7ad7e69c8323046f39e3a7ea25cd921dc Mon Sep 17 00:00:00 2001 From: Xavier Leune Date: Tue, 20 Feb 2024 09:17:55 +0100 Subject: [PATCH 032/132] feat(graphql): partial pagination for page based pagination (#6120) Co-authored-by: Xavier Leune --- Pagination/ArrayPaginator.php | 10 ++++++++- Pagination/HasNextPagePaginatorInterface.php | 22 ++++++++++++++++++++ Pagination/TraversablePaginator.php | 10 ++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 Pagination/HasNextPagePaginatorInterface.php diff --git a/Pagination/ArrayPaginator.php b/Pagination/ArrayPaginator.php index ace94a9..3de9ab6 100644 --- a/Pagination/ArrayPaginator.php +++ b/Pagination/ArrayPaginator.php @@ -18,7 +18,7 @@ * * @author Alan Poulain */ -final class ArrayPaginator implements \IteratorAggregate, PaginatorInterface +final class ArrayPaginator implements \IteratorAggregate, PaginatorInterface, HasNextPagePaginatorInterface { private \Traversable $iterator; private readonly int $firstResult; @@ -92,4 +92,12 @@ public function getIterator(): \Traversable { return $this->iterator; } + + /** + * {@inheritdoc} + */ + public function hasNextPage(): bool + { + return $this->getCurrentPage() < $this->getLastPage(); + } } diff --git a/Pagination/HasNextPagePaginatorInterface.php b/Pagination/HasNextPagePaginatorInterface.php new file mode 100644 index 0000000..211e735 --- /dev/null +++ b/Pagination/HasNextPagePaginatorInterface.php @@ -0,0 +1,22 @@ + + * + * 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\Pagination; + +interface HasNextPagePaginatorInterface +{ + /** + * Does this collection offer a next page. + */ + public function hasNextPage(): bool; +} diff --git a/Pagination/TraversablePaginator.php b/Pagination/TraversablePaginator.php index 8749e3b..8a4624e 100644 --- a/Pagination/TraversablePaginator.php +++ b/Pagination/TraversablePaginator.php @@ -13,7 +13,7 @@ namespace ApiPlatform\State\Pagination; -final class TraversablePaginator implements \IteratorAggregate, PaginatorInterface +final class TraversablePaginator implements \IteratorAggregate, PaginatorInterface, HasNextPagePaginatorInterface { public function __construct(private readonly \Traversable $traversable, private readonly float $currentPage, private readonly float $itemsPerPage, private readonly float $totalItems) { @@ -78,4 +78,12 @@ public function getIterator(): \Traversable { return $this->traversable; } + + /** + * {@inheritdoc} + */ + public function hasNextPage(): bool + { + return $this->getCurrentPage() < $this->getLastPage(); + } } From 1d46ac4aba859a85fff82e53de341f1812c4dda9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 29 Feb 2024 18:53:32 +0100 Subject: [PATCH 033/132] fix: components split dependencies (#6186) --- ApiResource/Error.php | 3 +- Processor/SerializeProcessor.php | 4 +- ResourceList.php | 21 ++++++++++ SerializerContextBuilderInterface.php | 55 +++++++++++++++++++++++++++ Tests/ResourceListTest.php | 32 ++++++++++++++++ composer.json | 37 ++++++++---------- 6 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 ResourceList.php create mode 100644 SerializerContextBuilderInterface.php create mode 100644 Tests/ResourceListTest.php diff --git a/ApiResource/Error.php b/ApiResource/Error.php index 3141026..62970a0 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -13,7 +13,6 @@ namespace ApiPlatform\State\ApiResource; -use ApiPlatform\JsonLd\ContextBuilderInterface; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Error as Operation; use ApiPlatform\Metadata\ErrorResource; @@ -48,7 +47,7 @@ 'skip_null_values' => true, 'rfc_7807_compliant_errors' => true, ], - links: [new Link(rel: ContextBuilderInterface::JSONLD_NS.'error', href: 'http://www.w3.org/ns/hydra/error')], + links: [new Link(rel: 'http://www.w3.org/ns/json-ld#error', href: 'http://www.w3.org/ns/hydra/error')], ), new Operation( name: '_api_errors_jsonapi', diff --git a/Processor/SerializeProcessor.php b/Processor/SerializeProcessor.php index 63d3957..bb86f49 100644 --- a/Processor/SerializeProcessor.php +++ b/Processor/SerializeProcessor.php @@ -14,9 +14,9 @@ namespace ApiPlatform\State\Processor; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Serializer\ResourceList; -use ApiPlatform\Serializer\SerializerContextBuilderInterface; use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ResourceList; +use ApiPlatform\State\SerializerContextBuilderInterface; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\SerializerInterface; diff --git a/ResourceList.php b/ResourceList.php new file mode 100644 index 0000000..847939b --- /dev/null +++ b/ResourceList.php @@ -0,0 +1,21 @@ + + * + * 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; + +/** + * @internal + */ +class ResourceList extends \ArrayObject +{ +} diff --git a/SerializerContextBuilderInterface.php b/SerializerContextBuilderInterface.php new file mode 100644 index 0000000..35aaeb3 --- /dev/null +++ b/SerializerContextBuilderInterface.php @@ -0,0 +1,55 @@ + + * + * 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; + +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\HttpOperation; +use Symfony\Component\HttpFoundation\Request; + +/** + * Builds the context used by the Symfony Serializer. + * + * @author Kévin Dunglas + */ +interface SerializerContextBuilderInterface +{ + /** + * Creates a serialization context from a Request. + * + * @throws RuntimeException + * + * @return array&array{ + * groups?: string[]|string, + * operation_name?: string, + * operation?: HttpOperation, + * resource_class?: class-string, + * skip_null_values?: bool, + * iri_only?: bool, + * request_uri?: string, + * uri?: string, + * input?: array{class: class-string|null}, + * output?: array{class: class-string|null}, + * item_uri_template?: string, + * types?: string[], + * uri_variables?: array, + * force_resource_class?: class-string, + * api_allow_update?: bool, + * deep_object_to_populate?: bool, + * collect_denormalization_errors?: bool, + * exclude_from_cache_key?: string[], + * api_included?: bool + * } + */ + public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array; +} diff --git a/Tests/ResourceListTest.php b/Tests/ResourceListTest.php new file mode 100644 index 0000000..cd4f655 --- /dev/null +++ b/Tests/ResourceListTest.php @@ -0,0 +1,32 @@ + + * + * 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\Tests; + +use ApiPlatform\State\ResourceList; +use PHPUnit\Framework\TestCase; + +class ResourceListTest extends TestCase +{ + private ResourceList $resourceList; + + protected function setUp(): void + { + $this->resourceList = new ResourceList(); + } + + public function testImplementsArrayObject(): void + { + $this->assertInstanceOf(\ArrayObject::class, $this->resourceList); + } +} diff --git a/composer.json b/composer.json index e5526fa..4495bbe 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,17 @@ "name": "api-platform/state", "description": "API Platform State component ", "type": "library", - "keywords": ["REST", "GraphQL", "API", "JSON-LD", "Hydra", "JSONAPI", "OpenAPI", "HAL", "Swagger"], + "keywords": [ + "REST", + "GraphQL", + "API", + "JSON-LD", + "Hydra", + "JSONAPI", + "OpenAPI", + "HAL", + "Swagger" + ], "homepage": "https://api-platform.com", "license": "MIT", "authors": [ @@ -19,7 +29,6 @@ "require": { "php": ">=8.1", "api-platform/metadata": "*@dev || ^3.1", - "api-platform/jsonld": "*@dev || ^3.1", "psr/container": "^2.0" }, "require-dev": { @@ -27,7 +36,6 @@ "symfony/web-link": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || 7.0", "willdurand/negotiation": "^3.1", - "api-platform/serializer": "*@dev || ^3.1", "api-platform/validator": "*@dev || ^3.1" }, "autoload": { @@ -50,7 +58,7 @@ }, "extra": { "branch-alias": { - "dev-main": "3.2.x-dev" + "dev-main": "3.3.x-dev" }, "symfony": { "require": "^6.4" @@ -63,22 +71,7 @@ "api-platform/serializer": "To use API Platform serializer.", "api-platform/validator": "To use API Platform validation." }, - "repositories": [ - { - "type": "path", - "url": "../Metadata" - }, - { - "type": "path", - "url": "../Serializer" - }, - { - "type": "path", - "url": "../Validator" - }, - { - "type": "path", - "url": "../JsonLd" - } - ] + "scripts": { + "test": "./vendor/bin/phpunit" + } } From bb157834473af7f90707d59eff73026950c1227b Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 26 Mar 2024 19:09:21 +0100 Subject: [PATCH 034/132] feat(state): provide parameter values --- ParameterProviderInterface.php | 31 +++++++++ Provider/ParameterProvider.php | 114 ++++++++++++++++++++++++++++++++ Tests/ParameterProviderTest.php | 75 +++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 ParameterProviderInterface.php create mode 100644 Provider/ParameterProvider.php create mode 100644 Tests/ParameterProviderTest.php diff --git a/ParameterProviderInterface.php b/ParameterProviderInterface.php new file mode 100644 index 0000000..da8d6ef --- /dev/null +++ b/ParameterProviderInterface.php @@ -0,0 +1,31 @@ + + * + * 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; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; + +/** + * Optionnaly transforms request parameters and provides modification to the current Operation. + * + * @experimental + */ +interface ParameterProviderInterface +{ + /** + * @param array $parameters + * @param array|array{request?: Request, resource_class?: string, operation: Operation} $context + */ + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation; +} diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php new file mode 100644 index 0000000..9ce068a --- /dev/null +++ b/Provider/ParameterProvider.php @@ -0,0 +1,114 @@ + + * + * 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\Provider; + +use ApiPlatform\Metadata\HeaderParameterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\State\Exception\ProviderNotFoundException; +use ApiPlatform\State\ParameterProviderInterface; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\Util\RequestParser; +use Psr\Container\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * Loops over parameters to: + * - compute its values set as extra properties from the Parameter object (`_api_values`) + * - call the Parameter::provider if any and updates the Operation + * + * @experimental + */ +final class ParameterProvider implements ProviderInterface +{ + public function __construct(private readonly ?ProviderInterface $decorated = null, private readonly ?ContainerInterface $locator = null) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $request = $context['request'] ?? null; + + if ($request && null === $request->attributes->get('_api_query_parameters')) { + $queryString = RequestParser::getQueryString($request); + $request->attributes->set('_api_query_parameters', $queryString ? RequestParser::parseRequestParams($queryString) : []); + } + + if ($request && null === $request->attributes->get('_api_header_parameters')) { + $request->attributes->set('_api_header_parameters', $request->headers->all()); + } + + $context = ['operation' => $operation] + $context; + $parameters = $operation->getParameters() ?? []; + $operationParameters = $parameters instanceof Parameters ? iterator_to_array($parameters) : $parameters; + foreach ($operationParameters as $parameter) { + $key = $parameter->getKey(); + $parameters = $this->extractParameterValues($parameter, $request, $context); + $parsedKey = explode('[:property]', $key); + if (isset($parsedKey[0]) && isset($parameters[$parsedKey[0]])) { + $key = $parsedKey[0]; + } + + if (!isset($parameters[$key])) { + continue; + } + + $operationParameters[$parameter->getKey()] = $parameter = $parameter->withExtraProperties( + $parameter->getExtraProperties() + ['_api_values' => [$key => $parameters[$key]]] + ); + + if (null === ($provider = $parameter->getProvider())) { + continue; + } + + if (\is_callable($provider)) { + if (($op = $provider($parameter, $parameters, $context)) instanceof Operation) { + $operation = $op; + } + + continue; + } + + if (!\is_string($provider) || !$this->locator->has($provider)) { + throw new ProviderNotFoundException(sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); + } + + /** @var ParameterProviderInterface $providerInstance */ + $providerInstance = $this->locator->get($provider); + if (($op = $providerInstance->provide($parameter, $parameters, $context)) instanceof Operation) { + $operation = $op; + } + } + + $operation = $operation->withParameters(new Parameters($operationParameters)); + $request?->attributes->set('_api_operation', $operation); + $context['operation'] = $operation; + + return $this->decorated?->provide($operation, $uriVariables, $context); + } + + /** + * @param array $context + */ + private function extractParameterValues(Parameter $parameter, ?Request $request, array $context) + { + if ($request) { + return $parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters'); + } + + // GraphQl + return $context['args'] ?? []; + } +} diff --git a/Tests/ParameterProviderTest.php b/Tests/ParameterProviderTest.php new file mode 100644 index 0000000..44869e3 --- /dev/null +++ b/Tests/ParameterProviderTest.php @@ -0,0 +1,75 @@ + + * + * 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\Tests; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\State\ParameterProviderInterface; +use ApiPlatform\State\Provider\ParameterProvider; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; + +final class ParameterProviderTest extends TestCase +{ + public function testExtractValues(): void + { + $locator = new class() implements ContainerInterface { + public function get(string $id) + { + if ('test' === $id) { + return new class() implements ParameterProviderInterface { + public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation + { + return new Get(name: 'ok'); + } + }; + } + } + + public function has(string $id): bool + { + return 'test' === $id; + } + }; + + $operation = new Get(parameters: new Parameters([ + 'order' => new QueryParameter(key: 'order', provider: 'test'), + 'search[:property]' => new QueryParameter(key: 'search[:property]', provider: [self::class, 'provide']), + 'foo' => new QueryParameter(key: 'foo', provider: [self::class, 'shouldNotBeCalled']), + ])); + $parameterProvider = new ParameterProvider(null, $locator); + $request = new Request(server: ['QUERY_STRING' => 'order[foo]=asc&search[a]=bar']); + $context = ['request' => $request, 'operation' => $operation]; + $parameterProvider->provide($operation, [], $context); + $operation = $request->attributes->get('_api_operation'); + + $this->assertEquals('ok', $operation->getName()); + $this->assertEquals(['order' => ['foo' => 'asc']], $operation->getParameters()->get('order')->getExtraProperties()['_api_values']); + $this->assertEquals(['search' => ['a' => 'bar']], $operation->getParameters()->get('search[:property]')->getExtraProperties()['_api_values']); + } + + public static function provide(): void + { + static::assertTrue(true); + } + + public static function shouldNotBeCalled(): void + { + static::assertTrue(false); // @phpstan-ignore-line + } +} From 659eaf76944fb5fe3045b6f328d3566a1518f7f6 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 Mar 2024 16:02:24 +0100 Subject: [PATCH 035/132] fix: multiple error routes #6214 (#6263) --- ApiResource/Error.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index 3141026..9874021 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -33,6 +33,7 @@ operations: [ new Operation( name: '_api_errors_problem', + routeName: 'api_errors', outputFormats: ['json' => ['application/problem+json']], normalizationContext: [ 'groups' => ['jsonproblem'], @@ -42,6 +43,7 @@ ), new Operation( name: '_api_errors_hydra', + routeName: 'api_errors', outputFormats: ['jsonld' => ['application/problem+json']], normalizationContext: [ 'groups' => ['jsonld'], @@ -52,6 +54,7 @@ ), new Operation( name: '_api_errors_jsonapi', + routeName: 'api_errors', outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: [ 'groups' => ['jsonapi'], @@ -59,6 +62,10 @@ 'rfc_7807_compliant_errors' => true, ], ), + new Operation( + name: '_api_errors', + routeName: 'api_errors' + ), ], provider: 'api_platform.state.error_provider', graphQlOperations: [] @@ -120,12 +127,14 @@ public static function createFromException(\Exception|\Throwable $exception, int } #[Ignore] + #[ApiProperty(readable: false)] public function getHeaders(): array { return $this->headers; } #[Ignore] + #[ApiProperty(readable: false)] public function getStatusCode(): int { return $this->status; From e77957dd75c688ff6a55f6a5054b40cd9b0d6d36 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 29 Apr 2024 15:25:15 +0200 Subject: [PATCH 036/132] feat(parametervalidator): parameter validation (#6296) --- Provider/ParameterProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php index 9ce068a..f74348a 100644 --- a/Provider/ParameterProvider.php +++ b/Provider/ParameterProvider.php @@ -40,7 +40,6 @@ public function __construct(private readonly ?ProviderInterface $decorated = nul public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { $request = $context['request'] ?? null; - if ($request && null === $request->attributes->get('_api_query_parameters')) { $queryString = RequestParser::getQueryString($request); $request->attributes->set('_api_query_parameters', $queryString ? RequestParser::parseRequestParams($queryString) : []); @@ -57,6 +56,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $key = $parameter->getKey(); $parameters = $this->extractParameterValues($parameter, $request, $context); $parsedKey = explode('[:property]', $key); + if (isset($parsedKey[0]) && isset($parameters[$parsedKey[0]])) { $key = $parsedKey[0]; } From 440792d9588b5c43dd72caeeca693318c60ce62c Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 2 May 2024 15:15:46 +0200 Subject: [PATCH 037/132] fix(symfony): set normalization context in request attributes (#6345) --- Provider/ReadProvider.php | 3 ++- Tests/Provider/ReadProviderTest.php | 38 +++++++++++++++++++++++++++++ composer.json | 3 ++- 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 Tests/Provider/ReadProviderTest.php diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php index 11209c9..b4ef0b6 100644 --- a/Provider/ReadProvider.php +++ b/Provider/ReadProvider.php @@ -71,10 +71,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c if ($this->serializerContextBuilder && $request) { // Builtin data providers are able to use the serialization context to automatically add join clauses - $context += $this->serializerContextBuilder->createFromRequest($request, true, [ + $context += $normalizationContext = $this->serializerContextBuilder->createFromRequest($request, true, [ 'resource_class' => $operation->getClass(), 'operation' => $operation, ]); + $request->attributes->set('_api_normalization_context', $normalizationContext); } try { diff --git a/Tests/Provider/ReadProviderTest.php b/Tests/Provider/ReadProviderTest.php new file mode 100644 index 0000000..18fb3cd --- /dev/null +++ b/Tests/Provider/ReadProviderTest.php @@ -0,0 +1,38 @@ + + * + * 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\Tests\Provider; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\Provider\ReadProvider; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +class ReadProviderTest extends TestCase +{ + public function testSetsSerializerContext(): void + { + $data = new \stdClass(); + $operation = new Get(read: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($data); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->expects($this->once())->method('createFromRequest')->willReturn(['a']); + $provider = new ReadProvider($decorated, $serializerContextBuilder); + $request = new Request(); + $provider->provide($operation, ['id' => 1], ['request' => $request]); + $this->assertEquals(['a'], $request->attributes->get('_api_normalization_context')); + } +} diff --git a/composer.json b/composer.json index 4495bbe..64adefd 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,8 @@ "symfony/web-link": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || 7.0", "willdurand/negotiation": "^3.1", - "api-platform/validator": "*@dev || ^3.1" + "api-platform/validator": "*@dev || ^3.1", + "api-platform/serializer": "*@dev || ^3.1" }, "autoload": { "psr-4": { From 981d325f6832764474e92027883a5f50de11dab1 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 3 May 2024 09:41:50 +0200 Subject: [PATCH 038/132] fix(state): read without output (#6347) --- Provider/ReadProvider.php | 7 ------- Tests/Provider/ReadProviderTest.php | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php index b4ef0b6..d9c9279 100644 --- a/Provider/ReadProvider.php +++ b/Provider/ReadProvider.php @@ -53,13 +53,6 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return null; } - $output = $operation->getOutput() ?? []; - if (\array_key_exists('class', $output) && null === $output['class']) { - $request?->attributes->set('data', null); - - return null; - } - if (null === $filters = $request?->attributes->get('_api_filters')) { $queryString = RequestParser::getQueryString($request); $filters = $queryString ? RequestParser::parseRequestParams($queryString) : null; diff --git a/Tests/Provider/ReadProviderTest.php b/Tests/Provider/ReadProviderTest.php index 18fb3cd..0823819 100644 --- a/Tests/Provider/ReadProviderTest.php +++ b/Tests/Provider/ReadProviderTest.php @@ -35,4 +35,18 @@ public function testSetsSerializerContext(): void $provider->provide($operation, ['id' => 1], ['request' => $request]); $this->assertEquals(['a'], $request->attributes->get('_api_normalization_context')); } + + public function testShouldReadWithOutputFalse(): void + { + $data = new \stdClass(); + $operation = new Get(read: true, output: false); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($data); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->expects($this->once())->method('createFromRequest')->willReturn(['a']); + $provider = new ReadProvider($decorated, $serializerContextBuilder); + $request = new Request(); + $provider->provide($operation, ['id' => 1], ['request' => $request]); + $this->assertEquals($data, $request->attributes->get('data')); + } } From 490e7d021e897e78921b982300c8d9190aea463b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 10 May 2024 11:26:23 +0200 Subject: [PATCH 039/132] fix(state): no location header without output (#6356) fixes #6352 --- Processor/RespondProcessor.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index bd0eb89..1cf28f6 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -88,7 +88,11 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $method = $request->getMethod(); $originalData = $context['original_data'] ?? null; - if ($hasData = ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))) && $this->iriConverter) { + $outputMetadata = $operation->getOutput() ?? ['class' => $operation->getClass()]; + $hasOutput = \is_array($outputMetadata) && \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class']; + $hasData = !$hasOutput ? false : ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))); + + if ($hasData && $this->iriConverter) { if ( !isset($headers['Location']) && 300 <= $status && $status < 400 From 0f2216a6ce77ba29746230baad6f051bafaaaa46 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 10 May 2024 12:41:59 +0200 Subject: [PATCH 040/132] fix(symfony): no read should not throw on wrong uri variables (#6359) * fix(symfony): no read should not throw on wrong uri variables fixes #6358 * add tests --- Processor/SerializeProcessor.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Processor/SerializeProcessor.php b/Processor/SerializeProcessor.php index bb86f49..088eb3e 100644 --- a/Processor/SerializeProcessor.php +++ b/Processor/SerializeProcessor.php @@ -51,8 +51,14 @@ public function process(mixed $data, Operation $operation, array $uriVariables = // @see ApiPlatform\State\Processor\RespondProcessor $context['original_data'] = $data; + $class = $operation->getClass(); + if ($request->attributes->get('_api_resource_class') && $request->attributes->get('_api_resource_class') !== $operation->getClass()) { + $class = $request->attributes->get('_api_resource_class'); + trigger_deprecation('api-platform/core', '3.3', 'The resource class on the router is not the same as the operation\'s class which leads to wrong behaviors. Prefer using "stateOptions" if you need to change the entity class.'); + } + $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ - 'resource_class' => $operation->getClass(), + 'resource_class' => $class, 'operation' => $operation, ]); From b49d2d781d9195985a5cd2b2847be8f88d24df29 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 27 May 2024 11:47:26 +0200 Subject: [PATCH 041/132] fix(symfony): documentation request _format (#6390) fixes #6372 --- Provider/DeserializeProvider.php | 11 ++++++++--- Provider/ReadProvider.php | 5 +++-- Tests/Provider/ReadProviderTest.php | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index bea57d6..18db103 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -15,8 +15,9 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\Serializer\SerializerContextBuilderInterface as LegacySerializerContextBuilderInterface; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\SerializerContextBuilderInterface; use ApiPlatform\Validator\Exception\ValidationException; use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -32,8 +33,12 @@ final class DeserializeProvider implements ProviderInterface { - public function __construct(private readonly ?ProviderInterface $decorated, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, private ?TranslatorInterface $translator = null) - { + public function __construct( + private readonly ?ProviderInterface $decorated, + private readonly SerializerInterface $serializer, + private readonly LegacySerializerContextBuilderInterface|SerializerContextBuilderInterface $serializerContextBuilder, + private ?TranslatorInterface $translator = null + ) { if (null === $this->translator) { $this->translator = new class() implements TranslatorInterface, LocaleAwareInterface { use TranslatorTrait; diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php index d9c9279..c854dfc 100644 --- a/Provider/ReadProvider.php +++ b/Provider/ReadProvider.php @@ -17,9 +17,10 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Util\CloneTrait; -use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\Serializer\SerializerContextBuilderInterface as LegacySerializerContextBuilderInterface; use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\SerializerContextBuilderInterface; use ApiPlatform\State\UriVariablesResolverTrait; use ApiPlatform\State\Util\OperationRequestInitiatorTrait; use ApiPlatform\State\Util\RequestParser; @@ -38,7 +39,7 @@ final class ReadProvider implements ProviderInterface public function __construct( private readonly ProviderInterface $provider, - private readonly ?SerializerContextBuilderInterface $serializerContextBuilder = null, + private readonly LegacySerializerContextBuilderInterface|SerializerContextBuilderInterface|null $serializerContextBuilder = null, ) { } diff --git a/Tests/Provider/ReadProviderTest.php b/Tests/Provider/ReadProviderTest.php index 0823819..3299b0a 100644 --- a/Tests/Provider/ReadProviderTest.php +++ b/Tests/Provider/ReadProviderTest.php @@ -14,9 +14,9 @@ namespace ApiPlatform\State\Tests\Provider; use ApiPlatform\Metadata\Get; -use ApiPlatform\Serializer\SerializerContextBuilderInterface; use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\SerializerContextBuilderInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; From ad34b71cb2c626eb37ececa4b8b902b4713d1fa4 Mon Sep 17 00:00:00 2001 From: Guillaume Sainthillier Date: Mon, 3 Jun 2024 21:27:39 +0200 Subject: [PATCH 042/132] fix(state): handle empty request in read provider (#6403) --- Provider/ReadProvider.php | 2 +- Tests/ReadProviderTest.php | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 Tests/ReadProviderTest.php diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php index a8b6d6e..be7baba 100644 --- a/Provider/ReadProvider.php +++ b/Provider/ReadProvider.php @@ -54,7 +54,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return null; } - if (null === $filters = $request?->attributes->get('_api_filters')) { + if (null === ($filters = $request?->attributes->get('_api_filters')) && $request) { $queryString = RequestParser::getQueryString($request); $filters = $queryString ? RequestParser::parseRequestParams($queryString) : null; } diff --git a/Tests/ReadProviderTest.php b/Tests/ReadProviderTest.php new file mode 100644 index 0000000..d822e7c --- /dev/null +++ b/Tests/ReadProviderTest.php @@ -0,0 +1,34 @@ + + * + * 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\Tests; + +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Serializer\SerializerContextBuilderInterface; +use ApiPlatform\State\Provider\ReadProvider; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; + +class ReadProviderTest extends TestCase +{ + public function testWithoutRequest(): void + { + $operation = new GetCollection(read: true); + $provider = $this->createMock(ProviderInterface::class); + $provider->method('provide')->willReturn(['ok']); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + + $readProvider = new ReadProvider($provider, $serializerContextBuilder); + $this->assertEquals($readProvider->provide($operation), ['ok']); + } +} From 6341bc3366404031bd0d0bf82d083b0a6007607d Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 3 Jun 2024 21:32:26 +0200 Subject: [PATCH 043/132] chore: move test to existing file --- Tests/Provider/ReadProviderTest.php | 12 ++++++++++ Tests/ReadProviderTest.php | 34 ----------------------------- 2 files changed, 12 insertions(+), 34 deletions(-) delete mode 100644 Tests/ReadProviderTest.php diff --git a/Tests/Provider/ReadProviderTest.php b/Tests/Provider/ReadProviderTest.php index 3299b0a..92cfce5 100644 --- a/Tests/Provider/ReadProviderTest.php +++ b/Tests/Provider/ReadProviderTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\State\Tests\Provider; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; @@ -49,4 +50,15 @@ public function testShouldReadWithOutputFalse(): void $provider->provide($operation, ['id' => 1], ['request' => $request]); $this->assertEquals($data, $request->attributes->get('data')); } + + public function testWithoutRequest(): void + { + $operation = new GetCollection(read: true); + $provider = $this->createMock(ProviderInterface::class); + $provider->method('provide')->willReturn(['ok']); + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + + $readProvider = new ReadProvider($provider, $serializerContextBuilder); + $this->assertEquals($readProvider->provide($operation), ['ok']); + } } diff --git a/Tests/ReadProviderTest.php b/Tests/ReadProviderTest.php deleted file mode 100644 index d822e7c..0000000 --- a/Tests/ReadProviderTest.php +++ /dev/null @@ -1,34 +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\State\Tests; - -use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Serializer\SerializerContextBuilderInterface; -use ApiPlatform\State\Provider\ReadProvider; -use ApiPlatform\State\ProviderInterface; -use PHPUnit\Framework\TestCase; - -class ReadProviderTest extends TestCase -{ - public function testWithoutRequest(): void - { - $operation = new GetCollection(read: true); - $provider = $this->createMock(ProviderInterface::class); - $provider->method('provide')->willReturn(['ok']); - $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); - - $readProvider = new ReadProvider($provider, $serializerContextBuilder); - $this->assertEquals($readProvider->provide($operation), ['ok']); - } -} From 4caad1b4dec21fd5358a7a17cdbae42871140244 Mon Sep 17 00:00:00 2001 From: Gwendolen Lynch Date: Tue, 18 Jun 2024 11:20:35 +0200 Subject: [PATCH 044/132] feat: BackedEnum resources (#6309) * fix(metadata): Only add GET operations for enums when ApiResource doesn't specify operations * feat(state): backed enum provider * fix(metadata): enum resource identifier default to value * fix(metadata): get method metadata for BackedEnums * test: resource with enum properties schema * what I would like * test: backed enums --------- Co-authored-by: soyuka --- Provider/BackedEnumProvider.php | 69 +++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 Provider/BackedEnumProvider.php diff --git a/Provider/BackedEnumProvider.php b/Provider/BackedEnumProvider.php new file mode 100644 index 0000000..d5641fb --- /dev/null +++ b/Provider/BackedEnumProvider.php @@ -0,0 +1,69 @@ + + * + * 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\Provider; + +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +final class BackedEnumProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $resourceClass = $operation->getClass(); + if (!$resourceClass || !is_a($resourceClass, \BackedEnum::class, true)) { + throw new RuntimeException('This resource is not an enum'); + } + + if ($operation instanceof CollectionOperationInterface) { + return $resourceClass::cases(); + } + + $id = $uriVariables['id'] ?? null; + if (null === $id) { + throw new NotFoundHttpException('Not Found'); + } + + if ($enum = $this->resolveEnum($resourceClass, $id)) { + return $enum; + } + + throw new NotFoundHttpException('Not Found'); + } + + /** + * @param class-string $resourceClass + */ + private function resolveEnum(string $resourceClass, string|int $id): ?\BackedEnum + { + $reflectEnum = new \ReflectionEnum($resourceClass); + $type = (string) $reflectEnum->getBackingType(); + + if ('int' === $type) { + if (!is_numeric($id)) { + return null; + } + $enum = $resourceClass::tryFrom((int) $id); + } else { + $enum = $resourceClass::tryFrom($id); + } + + // @deprecated enums will be indexable only by value in 4.0 + $enum ??= array_reduce($resourceClass::cases(), static fn ($c, \BackedEnum $case) => $id === $case->name ? $case : $c, null); + + return $enum; + } +} From 5135450d00bfafbd6f83a7ba1137c9be1fdae57f Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 25 Jun 2024 16:01:23 +0200 Subject: [PATCH 045/132] feat(laravel): laravel component (#5882) * feat(laravel): laravel component * try to skip laravel * feat(jsonapi): component * feat(laravel): json api support (needs review) * work on relations * relations (needs toMany) + skolem + IRI to resource * links handler * ulid * validation * slug post * remove deprecations * move classes * fix tests * fix tests metadata * phpstan * missing class * fix laravel tests * fix stan --- CreateProvider.php | 2 +- ObjectProvider.php | 2 +- UriVariablesResolverTrait.php | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CreateProvider.php b/CreateProvider.php index 1ff9c6e..4cb0117 100644 --- a/CreateProvider.php +++ b/CreateProvider.php @@ -13,7 +13,7 @@ namespace ApiPlatform\State; -use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; diff --git a/ObjectProvider.php b/ObjectProvider.php index dd83754..740c310 100644 --- a/ObjectProvider.php +++ b/ObjectProvider.php @@ -13,7 +13,7 @@ namespace ApiPlatform\State; -use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Operation; /** diff --git a/UriVariablesResolverTrait.php b/UriVariablesResolverTrait.php index ffabb6d..4f5b7be 100644 --- a/UriVariablesResolverTrait.php +++ b/UriVariablesResolverTrait.php @@ -13,7 +13,6 @@ namespace ApiPlatform\State; -use ApiPlatform\Api\UriVariablesConverterInterface as LegacyUriVariablesConverterInterface; use ApiPlatform\Metadata\Exception\InvalidIdentifierException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\UriVariablesConverterInterface; @@ -21,7 +20,7 @@ trait UriVariablesResolverTrait { - private LegacyUriVariablesConverterInterface|UriVariablesConverterInterface|null $uriVariablesConverter = null; + private ?UriVariablesConverterInterface $uriVariablesConverter = null; /** * Resolves an operation's UriVariables to their identifiers values. From 0cc15350bcfd73f24a55dbe09afd6c84451b30e1 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 28 Jun 2024 09:57:54 +0200 Subject: [PATCH 046/132] fix(state): parameter decorates main chain (#6434) * fix(state): parameter decorates main chain * fix: property placeholder validation --- Provider/ParameterProvider.php | 44 +++++++++------------------- Util/ParameterParserTrait.php | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 30 deletions(-) create mode 100644 Util/ParameterParserTrait.php diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php index f74348a..ed8ee01 100644 --- a/Provider/ParameterProvider.php +++ b/Provider/ParameterProvider.php @@ -13,16 +13,15 @@ namespace ApiPlatform\State\Provider; -use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\Parameters; use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ParameterProviderInterface; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\Util\ParameterParserTrait; use ApiPlatform\State\Util\RequestParser; use Psr\Container\ContainerInterface; -use Symfony\Component\HttpFoundation\Request; /** * Loops over parameters to: @@ -33,6 +32,8 @@ */ final class ParameterProvider implements ProviderInterface { + use ParameterParserTrait; + public function __construct(private readonly ?ProviderInterface $decorated = null, private readonly ?ContainerInterface $locator = null) { } @@ -50,23 +51,19 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $context = ['operation' => $operation] + $context; - $parameters = $operation->getParameters() ?? []; - $operationParameters = $parameters instanceof Parameters ? iterator_to_array($parameters) : $parameters; - foreach ($operationParameters as $parameter) { + $p = $operation->getParameters() ?? []; + $parameters = $p instanceof Parameters ? iterator_to_array($p) : $p; + foreach ($parameters as $parameter) { $key = $parameter->getKey(); - $parameters = $this->extractParameterValues($parameter, $request, $context); - $parsedKey = explode('[:property]', $key); - - if (isset($parsedKey[0]) && isset($parameters[$parsedKey[0]])) { - $key = $parsedKey[0]; - } + $values = $this->extractParameterValues($parameter, $request, $context); + $key = $this->getParameterFlattenKey($key, $values); - if (!isset($parameters[$key])) { + if (!isset($values[$key])) { continue; } - $operationParameters[$parameter->getKey()] = $parameter = $parameter->withExtraProperties( - $parameter->getExtraProperties() + ['_api_values' => [$key => $parameters[$key]]] + $parameters[$parameter->getKey()] = $parameter = $parameter->withExtraProperties( + $parameter->getExtraProperties() + ['_api_values' => [$key => $values[$key]]] ); if (null === ($provider = $parameter->getProvider())) { @@ -74,7 +71,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } if (\is_callable($provider)) { - if (($op = $provider($parameter, $parameters, $context)) instanceof Operation) { + if (($op = $provider($parameter, $values, $context)) instanceof Operation) { $operation = $op; } @@ -87,28 +84,15 @@ public function provide(Operation $operation, array $uriVariables = [], array $c /** @var ParameterProviderInterface $providerInstance */ $providerInstance = $this->locator->get($provider); - if (($op = $providerInstance->provide($parameter, $parameters, $context)) instanceof Operation) { + if (($op = $providerInstance->provide($parameter, $values, $context)) instanceof Operation) { $operation = $op; } } - $operation = $operation->withParameters(new Parameters($operationParameters)); + $operation = $operation->withParameters(new Parameters($parameters)); $request?->attributes->set('_api_operation', $operation); $context['operation'] = $operation; return $this->decorated?->provide($operation, $uriVariables, $context); } - - /** - * @param array $context - */ - private function extractParameterValues(Parameter $parameter, ?Request $request, array $context) - { - if ($request) { - return $parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters'); - } - - // GraphQl - return $context['args'] ?? []; - } } diff --git a/Util/ParameterParserTrait.php b/Util/ParameterParserTrait.php new file mode 100644 index 0000000..e536266 --- /dev/null +++ b/Util/ParameterParserTrait.php @@ -0,0 +1,53 @@ + + * + * 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\Util; + +use ApiPlatform\Metadata\HeaderParameterInterface; +use ApiPlatform\Metadata\Parameter; +use Symfony\Component\HttpFoundation\Request; + +/** + * @internal + */ +trait ParameterParserTrait +{ + /** + * @param array $values + */ + private function getParameterFlattenKey(string $key, array $values): string + { + $parsedKey = explode('[:property]', $key); + + if (isset($parsedKey[0]) && isset($values[$parsedKey[0]])) { + return $parsedKey[0]; + } + + return $key; + } + + /** + * @param array $context + * + * @return array + */ + private function extractParameterValues(Parameter $parameter, ?Request $request, array $context): array + { + if ($request) { + return ($parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters')) ?? []; + } + + // GraphQl + return $context['args'] ?? []; + } +} From f2e39fd9d40592b773d4815a7480dd1f531bbcbd Mon Sep 17 00:00:00 2001 From: Emmanuel Averty Date: Thu, 4 Jul 2024 19:59:09 +0200 Subject: [PATCH 047/132] feat(state): add security to parameters (#6435) * fix(state): add security to parameters * chore(state): fix style --- Provider/SecurityParameterProvider.php | 65 ++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 Provider/SecurityParameterProvider.php diff --git a/Provider/SecurityParameterProvider.php b/Provider/SecurityParameterProvider.php new file mode 100644 index 0000000..13997c5 --- /dev/null +++ b/Provider/SecurityParameterProvider.php @@ -0,0 +1,65 @@ + + * + * 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\Provider; + +use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\Util\ParameterParserTrait; +use ApiPlatform\Symfony\Security\Exception\AccessDeniedException; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; + +/** + * Loops over parameters to check parameter security. + * Throws an exception if security is not granted. + */ +final class SecurityParameterProvider implements ProviderInterface +{ + use ParameterParserTrait; + + public function __construct(private readonly ?ProviderInterface $decorated = null, private readonly ?ResourceAccessCheckerInterface $resourceAccessChecker = null) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if (!($request = $context['request']) instanceof Request) { + return $this->decorated->provide($operation, $uriVariables, $context); + } + + /** @var Operation $apiOperation */ + $apiOperation = $request->attributes->get('_api_operation'); + + foreach ($apiOperation->getParameters() ?? [] as $parameter) { + if (null === $security = $parameter->getSecurity()) { + continue; + } + + $key = $this->getParameterFlattenKey($parameter->getKey(), $this->extractParameterValues($parameter, $request, $context)); + $apiValues = $parameter->getExtraProperties()['_api_values'] ?? []; + if (!isset($apiValues[$key])) { + continue; + } + $value = $apiValues[$key]; + + if (!$this->resourceAccessChecker->isGranted($context['resource_class'], $security, [$key => $value])) { + throw $operation instanceof GraphQlOperation ? new AccessDeniedHttpException($parameter->getSecurityMessage() ?? 'Access Denied.') : new AccessDeniedException($parameter->getSecurityMessage() ?? 'Access Denied.'); + } + } + + return $this->decorated->provide($operation, $uriVariables, $context); + } +} From 00655df66025d9f166407a3198d30f7acd455382 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 5 Jul 2024 17:48:09 +0200 Subject: [PATCH 048/132] fix(state): query and header parameter with the same name (#6453) --- ParameterNotFound.php | 23 +++++++++++++++ Provider/ParameterProvider.php | 31 +++++++++++--------- Tests/ParameterProviderTest.php | 4 +-- Util/ParameterParserTrait.php | 52 ++++++++++++++++++++++++--------- 4 files changed, 80 insertions(+), 30 deletions(-) create mode 100644 ParameterNotFound.php diff --git a/ParameterNotFound.php b/ParameterNotFound.php new file mode 100644 index 0000000..fa29081 --- /dev/null +++ b/ParameterNotFound.php @@ -0,0 +1,23 @@ + + * + * 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; + +/** + * @experimental + * + * @internal + */ +final class ParameterNotFound +{ +} diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php index ed8ee01..5b23ba0 100644 --- a/Provider/ParameterProvider.php +++ b/Provider/ParameterProvider.php @@ -14,9 +14,8 @@ namespace ApiPlatform\State\Provider; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\Parameter; -use ApiPlatform\Metadata\Parameters; use ApiPlatform\State\Exception\ProviderNotFoundException; +use ApiPlatform\State\ParameterNotFound; use ApiPlatform\State\ParameterProviderInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\ParameterParserTrait; @@ -51,20 +50,22 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $context = ['operation' => $operation] + $context; - $p = $operation->getParameters() ?? []; - $parameters = $p instanceof Parameters ? iterator_to_array($p) : $p; - foreach ($parameters as $parameter) { - $key = $parameter->getKey(); - $values = $this->extractParameterValues($parameter, $request, $context); - $key = $this->getParameterFlattenKey($key, $values); - - if (!isset($values[$key])) { + $parameters = $operation->getParameters(); + foreach ($parameters ?? [] as $parameter) { + $values = $this->getParameterValues($parameter, $request, $context); + $value = $this->extractParameterValues($parameter, $values); + + if ((!$value || $value instanceof ParameterNotFound) && ($default = $parameter->getSchema()['default'] ?? false)) { + $value = $default; + } + + if ($value instanceof ParameterNotFound) { continue; } - $parameters[$parameter->getKey()] = $parameter = $parameter->withExtraProperties( - $parameter->getExtraProperties() + ['_api_values' => [$key => $values[$key]]] - ); + $parameters->add($parameter->getKey(), $parameter = $parameter->withExtraProperties( + $parameter->getExtraProperties() + ['_api_values' => [$parameter->getKey() => $value]] + )); if (null === ($provider = $parameter->getProvider())) { continue; @@ -89,7 +90,9 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } } - $operation = $operation->withParameters(new Parameters($parameters)); + if ($parameters) { + $operation = $operation->withParameters($parameters); + } $request?->attributes->set('_api_operation', $operation); $context['operation'] = $operation; diff --git a/Tests/ParameterProviderTest.php b/Tests/ParameterProviderTest.php index 44869e3..b383772 100644 --- a/Tests/ParameterProviderTest.php +++ b/Tests/ParameterProviderTest.php @@ -59,8 +59,8 @@ public function has(string $id): bool $operation = $request->attributes->get('_api_operation'); $this->assertEquals('ok', $operation->getName()); - $this->assertEquals(['order' => ['foo' => 'asc']], $operation->getParameters()->get('order')->getExtraProperties()['_api_values']); - $this->assertEquals(['search' => ['a' => 'bar']], $operation->getParameters()->get('search[:property]')->getExtraProperties()['_api_values']); + $this->assertEquals(['order' => ['foo' => 'asc']], $operation->getParameters()->get('order', QueryParameter::class)->getValue()); + $this->assertEquals(['search[:property]' => ['a' => 'bar']], $operation->getParameters()->get('search[:property]', QueryParameter::class)->getValue()); } public static function provide(): void diff --git a/Util/ParameterParserTrait.php b/Util/ParameterParserTrait.php index e536266..cebc3d8 100644 --- a/Util/ParameterParserTrait.php +++ b/Util/ParameterParserTrait.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\State\ParameterNotFound; use Symfony\Component\HttpFoundation\Request; /** @@ -23,31 +24,54 @@ trait ParameterParserTrait { /** - * @param array $values + * @param array $context + * + * @return array */ - private function getParameterFlattenKey(string $key, array $values): string + private function getParameterValues(Parameter $parameter, ?Request $request, array $context): array { - $parsedKey = explode('[:property]', $key); - - if (isset($parsedKey[0]) && isset($values[$parsedKey[0]])) { - return $parsedKey[0]; + if ($request) { + return ($parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters')) ?? []; } - return $key; + return $context['args'] ?? []; } /** - * @param array $context + * @param array $values * - * @return array + * @return array|ParameterNotFound|array */ - private function extractParameterValues(Parameter $parameter, ?Request $request, array $context): array + private function extractParameterValues(Parameter $parameter, array $values): string|ParameterNotFound|array { - if ($request) { - return ($parameter instanceof HeaderParameterInterface ? $request->attributes->get('_api_header_parameters') : $request->attributes->get('_api_query_parameters')) ?? []; + $accessors = null; + $key = $parameter->getKey(); + $parsedKey = explode('[:property]', $key); + if (isset($parsedKey[0]) && isset($values[$parsedKey[0]])) { + $key = $parsedKey[0]; + } elseif (str_contains($key, '[')) { + preg_match_all('/[^\[\]]+/', $key, $matches); + if (isset($matches[0])) { + $key = array_shift($matches[0]); + $accessors = $matches[0]; + } } - // GraphQl - return $context['args'] ?? []; + if (!$accessors) { + return $values[$key] ?? new ParameterNotFound(); + } + + $value = $values[$key] ?? new ParameterNotFound(); + + foreach ($accessors as $accessor) { + if (\is_array($value) && isset($value[$accessor])) { + $value = $value[$accessor]; + } else { + $value = new ParameterNotFound(); + continue; + } + } + + return $value; } } From 5a1f1e593b05f689c210fda11d99c79ad99f3b54 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 7 Jul 2024 15:38:00 +0200 Subject: [PATCH 049/132] cs(state): code duplication --- ParameterNotFound.php | 2 -- Util/ParameterParserTrait.php | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ParameterNotFound.php b/ParameterNotFound.php index fa29081..618ea3e 100644 --- a/ParameterNotFound.php +++ b/ParameterNotFound.php @@ -15,8 +15,6 @@ /** * @experimental - * - * @internal */ final class ParameterNotFound { diff --git a/Util/ParameterParserTrait.php b/Util/ParameterParserTrait.php index cebc3d8..c35f6db 100644 --- a/Util/ParameterParserTrait.php +++ b/Util/ParameterParserTrait.php @@ -57,12 +57,11 @@ private function extractParameterValues(Parameter $parameter, array $values): st } } + $value = $values[$key] ?? new ParameterNotFound(); if (!$accessors) { - return $values[$key] ?? new ParameterNotFound(); + return $value; } - $value = $values[$key] ?? new ParameterNotFound(); - foreach ($accessors as $accessor) { if (\is_array($value) && isset($value[$accessor])) { $value = $value[$accessor]; From 897da359d355cbe210a90d3e81b94981495592f4 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 8 Jul 2024 09:41:59 +0200 Subject: [PATCH 050/132] fix(state): store parameter value without its key (#6456) --- Provider/ParameterProvider.php | 2 +- Tests/ParameterProviderTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php index 5b23ba0..f85b33f 100644 --- a/Provider/ParameterProvider.php +++ b/Provider/ParameterProvider.php @@ -64,7 +64,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $parameters->add($parameter->getKey(), $parameter = $parameter->withExtraProperties( - $parameter->getExtraProperties() + ['_api_values' => [$parameter->getKey() => $value]] + $parameter->getExtraProperties() + ['_api_values' => $value] )); if (null === ($provider = $parameter->getProvider())) { diff --git a/Tests/ParameterProviderTest.php b/Tests/ParameterProviderTest.php index b383772..a69b3f1 100644 --- a/Tests/ParameterProviderTest.php +++ b/Tests/ParameterProviderTest.php @@ -59,8 +59,8 @@ public function has(string $id): bool $operation = $request->attributes->get('_api_operation'); $this->assertEquals('ok', $operation->getName()); - $this->assertEquals(['order' => ['foo' => 'asc']], $operation->getParameters()->get('order', QueryParameter::class)->getValue()); - $this->assertEquals(['search[:property]' => ['a' => 'bar']], $operation->getParameters()->get('search[:property]', QueryParameter::class)->getValue()); + $this->assertEquals(['foo' => 'asc'], $operation->getParameters()->get('order', QueryParameter::class)->getValue()); + $this->assertEquals(['a' => 'bar'], $operation->getParameters()->get('search[:property]', QueryParameter::class)->getValue()); } public static function provide(): void From 24427b79736301d8f292b403fc58eb3c74bd08cb Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 8 Jul 2024 10:46:37 +0200 Subject: [PATCH 051/132] fix(state): security parameter with listeners (#6457) --- Provider/SecurityParameterProvider.php | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/Provider/SecurityParameterProvider.php b/Provider/SecurityParameterProvider.php index 13997c5..7f06bc7 100644 --- a/Provider/SecurityParameterProvider.php +++ b/Provider/SecurityParameterProvider.php @@ -16,10 +16,10 @@ use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\State\ParameterNotFound; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\ParameterParserTrait; use ApiPlatform\Symfony\Security\Exception\AccessDeniedException; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** @@ -30,36 +30,31 @@ final class SecurityParameterProvider implements ProviderInterface { use ParameterParserTrait; - public function __construct(private readonly ?ProviderInterface $decorated = null, private readonly ?ResourceAccessCheckerInterface $resourceAccessChecker = null) + public function __construct(private readonly ProviderInterface $decorated, private readonly ?ResourceAccessCheckerInterface $resourceAccessChecker = null) { } public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - if (!($request = $context['request']) instanceof Request) { - return $this->decorated->provide($operation, $uriVariables, $context); - } - - /** @var Operation $apiOperation */ - $apiOperation = $request->attributes->get('_api_operation'); + $body = $this->decorated->provide($operation, $uriVariables, $context); + $request = $context['request'] ?? null; - foreach ($apiOperation->getParameters() ?? [] as $parameter) { + $operation = $request?->attributes->get('_api_operation') ?? $operation; + foreach ($operation->getParameters() ?? [] as $parameter) { if (null === $security = $parameter->getSecurity()) { continue; } - $key = $this->getParameterFlattenKey($parameter->getKey(), $this->extractParameterValues($parameter, $request, $context)); - $apiValues = $parameter->getExtraProperties()['_api_values'] ?? []; - if (!isset($apiValues[$key])) { + if (($v = $parameter->getValue()) instanceof ParameterNotFound) { continue; } - $value = $apiValues[$key]; - if (!$this->resourceAccessChecker->isGranted($context['resource_class'], $security, [$key => $value])) { + $securityContext = [$parameter->getKey() => $v, 'object' => $body]; + 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.'); } } - return $this->decorated->provide($operation, $uriVariables, $context); + return $body; } } From 7c32b9a6d7bdf1fe28830516f250a282ccaa53c7 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 8 Jul 2024 13:06:27 +0200 Subject: [PATCH 052/132] feat: deprecate query parameter validator (#6454) --- Provider/ParameterProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php index f85b33f..28d7a16 100644 --- a/Provider/ParameterProvider.php +++ b/Provider/ParameterProvider.php @@ -55,7 +55,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $values = $this->getParameterValues($parameter, $request, $context); $value = $this->extractParameterValues($parameter, $values); - if ((!$value || $value instanceof ParameterNotFound) && ($default = $parameter->getSchema()['default'] ?? false)) { + if (($default = $parameter->getSchema()['default'] ?? false) && ($value instanceof ParameterNotFound || !$value)) { $value = $default; } From bd738120aeceb442c77e18b1a7b6e5a412c7522b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 15 Jul 2024 11:45:37 +0200 Subject: [PATCH 053/132] chore: symfony 7.1 dependency and branch alias (#6468) * chore: symfony 7.1 dependency and branch alias * chore: main_request --- composer.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 64adefd..aa18b09 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "require": { "php": ">=8.1", "api-platform/metadata": "*@dev || ^3.1", - "psr/container": "^2.0" + "psr/container": "^1.0 || ^2.0" }, "require-dev": { "phpunit/phpunit": "^10.3", @@ -59,10 +59,11 @@ }, "extra": { "branch-alias": { - "dev-main": "3.3.x-dev" + "dev-main": "4.0.x-dev", + "dev-3.4": "3.4.x-dev" }, "symfony": { - "require": "^6.4" + "require": "^6.4 || ^7.1" } }, "suggest": { @@ -75,4 +76,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} +} \ No newline at end of file From 4df8bf07f1aa7a6cb881e6aa9d60d9d1c96637e3 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 25 Jun 2024 22:24:35 +0200 Subject: [PATCH 054/132] chore: remove deprecations --- ApiResource/Error.php | 3 --- Provider/ReadProvider.php | 3 +-- Tests/Util/RequestParserTest.php | 4 +--- Util/OperationRequestInitiatorTrait.php | 1 + composer.json | 2 +- 5 files changed, 4 insertions(+), 9 deletions(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index 834a6f7..c115712 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -37,7 +37,6 @@ normalizationContext: [ 'groups' => ['jsonproblem'], 'skip_null_values' => true, - 'rfc_7807_compliant_errors' => true, ], ), new Operation( @@ -47,7 +46,6 @@ normalizationContext: [ 'groups' => ['jsonld'], 'skip_null_values' => true, - 'rfc_7807_compliant_errors' => true, ], links: [new Link(rel: 'http://www.w3.org/ns/json-ld#error', href: 'http://www.w3.org/ns/hydra/error')], ), @@ -58,7 +56,6 @@ normalizationContext: [ 'groups' => ['jsonapi'], 'skip_null_values' => true, - 'rfc_7807_compliant_errors' => true, ], ), new Operation( diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php index e5246f0..b2adb1c 100644 --- a/Provider/ReadProvider.php +++ b/Provider/ReadProvider.php @@ -17,7 +17,6 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Util\CloneTrait; -use ApiPlatform\Serializer\SerializerContextBuilderInterface as LegacySerializerContextBuilderInterface; use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; @@ -39,7 +38,7 @@ final class ReadProvider implements ProviderInterface public function __construct( private readonly ProviderInterface $provider, - private readonly LegacySerializerContextBuilderInterface|SerializerContextBuilderInterface|null $serializerContextBuilder = null, + private readonly ?SerializerContextBuilderInterface $serializerContextBuilder = null, ) { } diff --git a/Tests/Util/RequestParserTest.php b/Tests/Util/RequestParserTest.php index 831aac3..249f5b1 100644 --- a/Tests/Util/RequestParserTest.php +++ b/Tests/Util/RequestParserTest.php @@ -21,9 +21,7 @@ */ class RequestParserTest extends TestCase { - /** - * @dataProvider parseRequestParamsProvider - */ + #[\PHPUnit\Framework\Attributes\DataProvider('parseRequestParamsProvider')] public function testParseRequestParams(string $source, array $expected): void { $actual = RequestParser::parseRequestParams($source); diff --git a/Util/OperationRequestInitiatorTrait.php b/Util/OperationRequestInitiatorTrait.php index b70e9d2..4261ece 100644 --- a/Util/OperationRequestInitiatorTrait.php +++ b/Util/OperationRequestInitiatorTrait.php @@ -38,6 +38,7 @@ private function initializeOperation(Request $request): ?HttpOperation } $operationName = $request->attributes->get('_api_operation_name'); + /** @var HttpOperation $operation */ $operation = $this->resourceMetadataCollectionFactory->create($request->attributes->get('_api_resource_class'))->getOperation($operationName); $request->attributes->set('_api_operation', $operation); diff --git a/composer.json b/composer.json index aa18b09..d6aaf7e 100644 --- a/composer.json +++ b/composer.json @@ -76,4 +76,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} \ No newline at end of file +} From 29a3d93b2a0b6edb1d4841ab45ba35130ddcd8e7 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 9 Aug 2024 09:21:07 +0200 Subject: [PATCH 055/132] style: various cs fixes (#6504) * cs: fixes * chore: phpstan fixes --- CallableProcessor.php | 2 +- CallableProvider.php | 4 ++-- CreateProvider.php | 2 +- ObjectProvider.php | 2 +- Processor/RespondProcessor.php | 2 +- Provider/ContentNegotiationProvider.php | 2 +- Provider/DeserializeProvider.php | 2 +- Provider/ParameterProvider.php | 2 +- Tests/ParameterProviderTest.php | 4 ++-- UriVariablesResolverTrait.php | 4 ++-- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CallableProcessor.php b/CallableProcessor.php index e913283..b75f851 100644 --- a/CallableProcessor.php +++ b/CallableProcessor.php @@ -43,7 +43,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = } if (!$this->locator->has($processor)) { - throw new RuntimeException(sprintf('Processor "%s" not found on operation "%s"', $processor, $operation->getName())); + throw new RuntimeException(\sprintf('Processor "%s" not found on operation "%s"', $processor, $operation->getName())); } /** @var ProcessorInterface $processorInstance */ diff --git a/CallableProvider.php b/CallableProvider.php index f668447..fa48d86 100644 --- a/CallableProvider.php +++ b/CallableProvider.php @@ -34,7 +34,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c if ($this->locator && \is_string($provider)) { if (!$this->locator->has($provider)) { - throw new ProviderNotFoundException(sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); + throw new ProviderNotFoundException(\sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); } /** @var ProviderInterface $providerInstance */ @@ -43,6 +43,6 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $providerInstance->provide($operation, $uriVariables, $context); } - throw new ProviderNotFoundException(sprintf('Provider not found on operation "%s"', $operation->getName())); + throw new ProviderNotFoundException(\sprintf('Provider not found on operation "%s"', $operation->getName())); } } diff --git a/CreateProvider.php b/CreateProvider.php index 1ff9c6e..2ccb423 100644 --- a/CreateProvider.php +++ b/CreateProvider.php @@ -68,7 +68,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { $resource = new ($operation->getClass()); } catch (\Throwable $e) { - throw new RuntimeException(sprintf('An error occurred while trying to create an instance of the "%s" resource. Consider writing your own "%s" implementation and setting it as `provider` on your operation instead.', $operation->getClass(), ProviderInterface::class), 0, $e); + throw new RuntimeException(\sprintf('An error occurred while trying to create an instance of the "%s" resource. Consider writing your own "%s" implementation and setting it as `provider` on your operation instead.', $operation->getClass(), ProviderInterface::class), 0, $e); } $property = $operationUriVariables[$key]->getToProperty() ?? $key; diff --git a/ObjectProvider.php b/ObjectProvider.php index dd83754..3f931db 100644 --- a/ObjectProvider.php +++ b/ObjectProvider.php @@ -30,7 +30,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { return new ($operation->getClass()); } catch (\Throwable $e) { - throw new RuntimeException(sprintf('An error occurred while trying to create an instance of the "%s" resource. Consider writing your own "%s" implementation and setting it as `provider` on your operation instead.', $operation->getClass(), ProviderInterface::class), 0, $e); + throw new RuntimeException(\sprintf('An error occurred while trying to create an instance of the "%s" resource. Consider writing your own "%s" implementation and setting it as `provider` on your operation instead.', $operation->getClass(), ProviderInterface::class), 0, $e); } } } diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index 1cf28f6..15e9c03 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -60,7 +60,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = } $headers = [ - 'Content-Type' => sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())), + 'Content-Type' => \sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())), 'Vary' => 'Accept', 'X-Content-Type-Options' => 'nosniff', 'X-Frame-Options' => 'deny', diff --git a/Provider/ContentNegotiationProvider.php b/Provider/ContentNegotiationProvider.php index 95d165b..0ffd426 100644 --- a/Provider/ContentNegotiationProvider.php +++ b/Provider/ContentNegotiationProvider.php @@ -116,7 +116,7 @@ private function getInputFormat(HttpOperation $operation, Request $request): ?st } if (!$request->isMethodSafe() && 'DELETE' !== $request->getMethod()) { - throw new UnsupportedMediaTypeHttpException(sprintf('The content-type "%s" is not supported. Supported MIME types are "%s".', $contentType, implode('", "', $supportedMimeTypes))); + throw new UnsupportedMediaTypeHttpException(\sprintf('The content-type "%s" is not supported. Supported MIME types are "%s".', $contentType, implode('", "', $supportedMimeTypes))); } return null; diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index 18db103..0e4010c 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -40,7 +40,7 @@ public function __construct( private ?TranslatorInterface $translator = null ) { if (null === $this->translator) { - $this->translator = new class() implements TranslatorInterface, LocaleAwareInterface { + $this->translator = new class implements TranslatorInterface, LocaleAwareInterface { use TranslatorTrait; }; $this->translator->setLocale('en'); diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php index f85b33f..0451ed8 100644 --- a/Provider/ParameterProvider.php +++ b/Provider/ParameterProvider.php @@ -80,7 +80,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } if (!\is_string($provider) || !$this->locator->has($provider)) { - throw new ProviderNotFoundException(sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); + throw new ProviderNotFoundException(\sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); } /** @var ParameterProviderInterface $providerInstance */ diff --git a/Tests/ParameterProviderTest.php b/Tests/ParameterProviderTest.php index a69b3f1..66d5d7a 100644 --- a/Tests/ParameterProviderTest.php +++ b/Tests/ParameterProviderTest.php @@ -28,11 +28,11 @@ final class ParameterProviderTest extends TestCase { public function testExtractValues(): void { - $locator = new class() implements ContainerInterface { + $locator = new class implements ContainerInterface { public function get(string $id) { if ('test' === $id) { - return new class() implements ParameterProviderInterface { + return new class implements ParameterProviderInterface { public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation { return new Get(name: 'ok'); diff --git a/UriVariablesResolverTrait.php b/UriVariablesResolverTrait.php index ffabb6d..68b2d06 100644 --- a/UriVariablesResolverTrait.php +++ b/UriVariablesResolverTrait.php @@ -38,7 +38,7 @@ private function getOperationUriVariables(?HttpOperation $operation = null, arra foreach ($operation->getUriVariables() ?? [] as $parameterName => $uriVariableDefinition) { if (!isset($parameters[$parameterName])) { if (!isset($parameters['id'])) { - throw new InvalidIdentifierException(sprintf('Parameter "%s" not found, check the identifiers configuration.', $parameterName)); + throw new InvalidIdentifierException(\sprintf('Parameter "%s" not found, check the identifiers configuration.', $parameterName)); } $parameterName = 'id'; @@ -48,7 +48,7 @@ private function getOperationUriVariables(?HttpOperation $operation = null, arra $currentIdentifiers = CompositeIdentifierParser::parse($parameters[$parameterName]); if (($foundNumIdentifiers = \count($currentIdentifiers)) !== $numIdentifiers) { - throw new InvalidIdentifierException(sprintf('We expected "%s" identifiers and got "%s".', $numIdentifiers, $foundNumIdentifiers)); + throw new InvalidIdentifierException(\sprintf('We expected "%s" identifiers and got "%s".', $numIdentifiers, $foundNumIdentifiers)); } foreach ($currentIdentifiers as $key => $value) { From 737796582522096643a491eee508d705422a07ff Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 9 Aug 2024 09:49:50 +0200 Subject: [PATCH 056/132] style: cs fixes --- CallableProcessor.php | 2 +- CallableProvider.php | 4 ++-- CreateProvider.php | 2 +- ObjectProvider.php | 2 +- Processor/RespondProcessor.php | 2 +- Provider/ContentNegotiationProvider.php | 2 +- Provider/DeserializeProvider.php | 2 +- Provider/ParameterProvider.php | 2 +- Tests/ParameterProviderTest.php | 4 ++-- UriVariablesResolverTrait.php | 4 ++-- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CallableProcessor.php b/CallableProcessor.php index e913283..b75f851 100644 --- a/CallableProcessor.php +++ b/CallableProcessor.php @@ -43,7 +43,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = } if (!$this->locator->has($processor)) { - throw new RuntimeException(sprintf('Processor "%s" not found on operation "%s"', $processor, $operation->getName())); + throw new RuntimeException(\sprintf('Processor "%s" not found on operation "%s"', $processor, $operation->getName())); } /** @var ProcessorInterface $processorInstance */ diff --git a/CallableProvider.php b/CallableProvider.php index f668447..fa48d86 100644 --- a/CallableProvider.php +++ b/CallableProvider.php @@ -34,7 +34,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c if ($this->locator && \is_string($provider)) { if (!$this->locator->has($provider)) { - throw new ProviderNotFoundException(sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); + throw new ProviderNotFoundException(\sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); } /** @var ProviderInterface $providerInstance */ @@ -43,6 +43,6 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $providerInstance->provide($operation, $uriVariables, $context); } - throw new ProviderNotFoundException(sprintf('Provider not found on operation "%s"', $operation->getName())); + throw new ProviderNotFoundException(\sprintf('Provider not found on operation "%s"', $operation->getName())); } } diff --git a/CreateProvider.php b/CreateProvider.php index 4cb0117..d10a1d6 100644 --- a/CreateProvider.php +++ b/CreateProvider.php @@ -68,7 +68,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { $resource = new ($operation->getClass()); } catch (\Throwable $e) { - throw new RuntimeException(sprintf('An error occurred while trying to create an instance of the "%s" resource. Consider writing your own "%s" implementation and setting it as `provider` on your operation instead.', $operation->getClass(), ProviderInterface::class), 0, $e); + throw new RuntimeException(\sprintf('An error occurred while trying to create an instance of the "%s" resource. Consider writing your own "%s" implementation and setting it as `provider` on your operation instead.', $operation->getClass(), ProviderInterface::class), 0, $e); } $property = $operationUriVariables[$key]->getToProperty() ?? $key; diff --git a/ObjectProvider.php b/ObjectProvider.php index 740c310..308dbda 100644 --- a/ObjectProvider.php +++ b/ObjectProvider.php @@ -30,7 +30,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { return new ($operation->getClass()); } catch (\Throwable $e) { - throw new RuntimeException(sprintf('An error occurred while trying to create an instance of the "%s" resource. Consider writing your own "%s" implementation and setting it as `provider` on your operation instead.', $operation->getClass(), ProviderInterface::class), 0, $e); + throw new RuntimeException(\sprintf('An error occurred while trying to create an instance of the "%s" resource. Consider writing your own "%s" implementation and setting it as `provider` on your operation instead.', $operation->getClass(), ProviderInterface::class), 0, $e); } } } diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index 1cf28f6..15e9c03 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -60,7 +60,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = } $headers = [ - 'Content-Type' => sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())), + 'Content-Type' => \sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())), 'Vary' => 'Accept', 'X-Content-Type-Options' => 'nosniff', 'X-Frame-Options' => 'deny', diff --git a/Provider/ContentNegotiationProvider.php b/Provider/ContentNegotiationProvider.php index 95d165b..0ffd426 100644 --- a/Provider/ContentNegotiationProvider.php +++ b/Provider/ContentNegotiationProvider.php @@ -116,7 +116,7 @@ private function getInputFormat(HttpOperation $operation, Request $request): ?st } if (!$request->isMethodSafe() && 'DELETE' !== $request->getMethod()) { - throw new UnsupportedMediaTypeHttpException(sprintf('The content-type "%s" is not supported. Supported MIME types are "%s".', $contentType, implode('", "', $supportedMimeTypes))); + throw new UnsupportedMediaTypeHttpException(\sprintf('The content-type "%s" is not supported. Supported MIME types are "%s".', $contentType, implode('", "', $supportedMimeTypes))); } return null; diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index 18db103..0e4010c 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -40,7 +40,7 @@ public function __construct( private ?TranslatorInterface $translator = null ) { if (null === $this->translator) { - $this->translator = new class() implements TranslatorInterface, LocaleAwareInterface { + $this->translator = new class implements TranslatorInterface, LocaleAwareInterface { use TranslatorTrait; }; $this->translator->setLocale('en'); diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php index 28d7a16..9a905d9 100644 --- a/Provider/ParameterProvider.php +++ b/Provider/ParameterProvider.php @@ -80,7 +80,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } if (!\is_string($provider) || !$this->locator->has($provider)) { - throw new ProviderNotFoundException(sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); + throw new ProviderNotFoundException(\sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); } /** @var ParameterProviderInterface $providerInstance */ diff --git a/Tests/ParameterProviderTest.php b/Tests/ParameterProviderTest.php index a69b3f1..66d5d7a 100644 --- a/Tests/ParameterProviderTest.php +++ b/Tests/ParameterProviderTest.php @@ -28,11 +28,11 @@ final class ParameterProviderTest extends TestCase { public function testExtractValues(): void { - $locator = new class() implements ContainerInterface { + $locator = new class implements ContainerInterface { public function get(string $id) { if ('test' === $id) { - return new class() implements ParameterProviderInterface { + return new class implements ParameterProviderInterface { public function provide(Parameter $parameter, array $parameters = [], array $context = []): ?Operation { return new Get(name: 'ok'); diff --git a/UriVariablesResolverTrait.php b/UriVariablesResolverTrait.php index 4f5b7be..0463041 100644 --- a/UriVariablesResolverTrait.php +++ b/UriVariablesResolverTrait.php @@ -37,7 +37,7 @@ private function getOperationUriVariables(?HttpOperation $operation = null, arra foreach ($operation->getUriVariables() ?? [] as $parameterName => $uriVariableDefinition) { if (!isset($parameters[$parameterName])) { if (!isset($parameters['id'])) { - throw new InvalidIdentifierException(sprintf('Parameter "%s" not found, check the identifiers configuration.', $parameterName)); + throw new InvalidIdentifierException(\sprintf('Parameter "%s" not found, check the identifiers configuration.', $parameterName)); } $parameterName = 'id'; @@ -47,7 +47,7 @@ private function getOperationUriVariables(?HttpOperation $operation = null, arra $currentIdentifiers = CompositeIdentifierParser::parse($parameters[$parameterName]); if (($foundNumIdentifiers = \count($currentIdentifiers)) !== $numIdentifiers) { - throw new InvalidIdentifierException(sprintf('We expected "%s" identifiers and got "%s".', $numIdentifiers, $foundNumIdentifiers)); + throw new InvalidIdentifierException(\sprintf('We expected "%s" identifiers and got "%s".', $numIdentifiers, $foundNumIdentifiers)); } foreach ($currentIdentifiers as $key => $value) { From a8bc429578f2c506862a611722fa172c4498dcd7 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Aug 2024 09:26:44 +0200 Subject: [PATCH 057/132] chore: bump api-platform dependencies --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d6aaf7e..e2d4ca5 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ ], "require": { "php": ">=8.1", - "api-platform/metadata": "*@dev || ^3.1", + "api-platform/metadata": "*@dev || ^3.2 || ^4.0", "psr/container": "^1.0 || ^2.0" }, "require-dev": { From 9c0e292e1956a3a21d1f5a2552ab42d25add1548 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 12 Aug 2024 12:17:47 +0200 Subject: [PATCH 058/132] chore: update branch aliases with 3.4 and 4.0 (#6509) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e2d4ca5..e77aa2e 100644 --- a/composer.json +++ b/composer.json @@ -76,4 +76,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} +} \ No newline at end of file From 3000651c5dfbba94c169b6d051f490fdcdb8657e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Mon, 12 Aug 2024 12:19:38 +0200 Subject: [PATCH 059/132] fix: add missing v4 version constraints (#6510) --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index e77aa2e..3d25ed9 100644 --- a/composer.json +++ b/composer.json @@ -36,8 +36,8 @@ "symfony/web-link": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || 7.0", "willdurand/negotiation": "^3.1", - "api-platform/validator": "*@dev || ^3.1", - "api-platform/serializer": "*@dev || ^3.1" + "api-platform/validator": "*@dev || ^3.1 || ^4.0", + "api-platform/serializer": "*@dev || ^3.1 || ^4.0" }, "autoload": { "psr-4": { From f15d6caa145c367f7b62325090baa1f38c1d75ce Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Aug 2024 16:36:55 +0200 Subject: [PATCH 060/132] cs: newline ending --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3d25ed9..9945c74 100644 --- a/composer.json +++ b/composer.json @@ -76,4 +76,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} \ No newline at end of file +} From c603bb95cf63707c9564f9cd4fefb60e590a0ece Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Aug 2024 16:44:43 +0200 Subject: [PATCH 061/132] chore: align dependencies across components --- composer.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 9945c74..bee5577 100644 --- a/composer.json +++ b/composer.json @@ -28,16 +28,16 @@ ], "require": { "php": ">=8.1", - "api-platform/metadata": "*@dev || ^3.2 || ^4.0", + "api-platform/metadata": "@dev || ^3.2 || ^4.0", "psr/container": "^1.0 || ^2.0" }, "require-dev": { - "phpunit/phpunit": "^10.3", + "phpunit/phpunit": "^11.2", "symfony/web-link": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || 7.0", "willdurand/negotiation": "^3.1", - "api-platform/validator": "*@dev || ^3.1 || ^4.0", - "api-platform/serializer": "*@dev || ^3.1 || ^4.0" + "api-platform/validator": "@dev || ^3.2 || ^4.0", + "api-platform/serializer": "@dev || ^3.2 || ^4.0" }, "autoload": { "psr-4": { From d93b7ea4ced4562c2336464224a84bd903d150cd Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 19 Aug 2024 14:51:02 +0200 Subject: [PATCH 062/132] chore: remove @dev constraint (#6513) --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index bee5577..17ac2b5 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ ], "require": { "php": ">=8.1", - "api-platform/metadata": "@dev || ^3.2 || ^4.0", + "api-platform/metadata": "^3.2 || ^4.0", "psr/container": "^1.0 || ^2.0" }, "require-dev": { @@ -36,8 +36,8 @@ "symfony/web-link": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || 7.0", "willdurand/negotiation": "^3.1", - "api-platform/validator": "@dev || ^3.2 || ^4.0", - "api-platform/serializer": "@dev || ^3.2 || ^4.0" + "api-platform/validator": "^3.2 || ^4.0", + "api-platform/serializer": "^3.2 || ^4.0" }, "autoload": { "psr-4": { From a11c7f162893af821d42ec6edf5f57f9edd957d1 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 21 Aug 2024 12:12:38 +0200 Subject: [PATCH 063/132] feat(laravel): policy, auth and gate (#6523) --- Provider/SecurityParameterProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Provider/SecurityParameterProvider.php b/Provider/SecurityParameterProvider.php index 7f06bc7..9e9c649 100644 --- a/Provider/SecurityParameterProvider.php +++ b/Provider/SecurityParameterProvider.php @@ -49,7 +49,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c continue; } - $securityContext = [$parameter->getKey() => $v, 'object' => $body]; + $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.'); } From d6e2f79ca9876fa97b43c610b6ff804ca1cff548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Wed, 21 Aug 2024 14:09:35 +0200 Subject: [PATCH 064/132] chore(laravel): code cleanup (#6526) * chore(laravel): code cleanup * fix ci * fix phpdoc * fix review * fix review --- ProcessorInterface.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ProcessorInterface.php b/ProcessorInterface.php index b88f77f..07bb906 100644 --- a/ProcessorInterface.php +++ b/ProcessorInterface.php @@ -29,9 +29,9 @@ interface ProcessorInterface /** * Handles the state. * - * @param T1 $data - * @param array $uriVariables - * @param array&array{request?: Request, previous_data?: mixed, resource_class?: string, original_data?: mixed} $context + * @param T1 $data + * @param array $uriVariables + * @param array&array{request?: Request|\Illuminate\Http\Request, previous_data?: mixed, resource_class?: string|null, original_data?: mixed} $context * * @return T2 */ From 2ccf83cd416dc16810562516eadf5b7f5bd70cc5 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 28 Aug 2024 11:10:23 +0200 Subject: [PATCH 065/132] feat(laravel): enable graphQl support (#6550) --- Util/ParameterParserTrait.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Util/ParameterParserTrait.php b/Util/ParameterParserTrait.php index c35f6db..6db86bf 100644 --- a/Util/ParameterParserTrait.php +++ b/Util/ParameterParserTrait.php @@ -51,10 +51,8 @@ private function extractParameterValues(Parameter $parameter, array $values): st $key = $parsedKey[0]; } elseif (str_contains($key, '[')) { preg_match_all('/[^\[\]]+/', $key, $matches); - if (isset($matches[0])) { - $key = array_shift($matches[0]); - $accessors = $matches[0]; - } + $key = array_shift($matches[0]); + $accessors = $matches[0]; } $value = $values[$key] ?? new ParameterNotFound(); From 4ef395a76560e0b140e6338c4b88f1e8fa9406d9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 29 Aug 2024 21:54:33 +0200 Subject: [PATCH 066/132] fix: isset not needed --- Util/ParameterParserTrait.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Util/ParameterParserTrait.php b/Util/ParameterParserTrait.php index c35f6db..6db86bf 100644 --- a/Util/ParameterParserTrait.php +++ b/Util/ParameterParserTrait.php @@ -51,10 +51,8 @@ private function extractParameterValues(Parameter $parameter, array $values): st $key = $parsedKey[0]; } elseif (str_contains($key, '[')) { preg_match_all('/[^\[\]]+/', $key, $matches); - if (isset($matches[0])) { - $key = array_shift($matches[0]); - $accessors = $matches[0]; - } + $key = array_shift($matches[0]); + $accessors = $matches[0]; } $value = $values[$key] ?? new ParameterNotFound(); From 36a0f47d6414745b2ebf2196a6f64d1d755f2fe7 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 30 Aug 2024 22:19:12 +0200 Subject: [PATCH 067/132] chore: deprecations (#6563) * chore: deprecations * chore: deprecations --- Tests/Util/RequestAttributesExtractorTest.php | 200 ++++++++++++++++++ composer.json | 8 +- 2 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 Tests/Util/RequestAttributesExtractorTest.php diff --git a/Tests/Util/RequestAttributesExtractorTest.php b/Tests/Util/RequestAttributesExtractorTest.php new file mode 100644 index 0000000..93ed96e --- /dev/null +++ b/Tests/Util/RequestAttributesExtractorTest.php @@ -0,0 +1,200 @@ + + * + * 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\Tests\Util; + +use ApiPlatform\State\Util\RequestAttributesExtractor; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Kévin Dunglas + */ +class RequestAttributesExtractorTest extends TestCase +{ + public function testExtractCollectionAttributes(): void + { + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'post']); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'post', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'has_composite_identifier' => false, + ], RequestAttributesExtractor::extractAttributes($request)); + } + + public function testExtractItemAttributes(): void + { + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'get']); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'has_composite_identifier' => false, + ], RequestAttributesExtractor::extractAttributes($request)); + } + + public function testExtractReceive(): void + { + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_receive' => '0']); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'get', + 'receive' => false, + 'respond' => true, + 'persist' => true, + 'has_composite_identifier' => false, + ], RequestAttributesExtractor::extractAttributes($request)); + + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_receive' => '1']); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'has_composite_identifier' => false, + ], RequestAttributesExtractor::extractAttributes($request)); + + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'get']); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'has_composite_identifier' => false, + ], RequestAttributesExtractor::extractAttributes($request)); + } + + public function testExtractRespond(): void + { + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_respond' => '0']); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'get', + 'receive' => true, + 'respond' => false, + 'persist' => true, + 'has_composite_identifier' => false, + ], RequestAttributesExtractor::extractAttributes($request)); + + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_respond' => '1']); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'has_composite_identifier' => false, + ], RequestAttributesExtractor::extractAttributes($request)); + + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'get']); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'has_composite_identifier' => false, + ], RequestAttributesExtractor::extractAttributes($request)); + } + + public function testExtractPersist(): void + { + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_persist' => '0']); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => false, + 'has_composite_identifier' => false, + ], RequestAttributesExtractor::extractAttributes($request)); + + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_persist' => '1']); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'has_composite_identifier' => false, + ], RequestAttributesExtractor::extractAttributes($request)); + + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'get']); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'has_composite_identifier' => false, + ], RequestAttributesExtractor::extractAttributes($request)); + } + + public function testResourceClassNotSet(): void + { + $this->assertEmpty(RequestAttributesExtractor::extractAttributes(new Request([], [], ['_api_operation_name' => 'get']))); + } + + public function testOperationNotSet(): void + { + $this->assertEmpty(RequestAttributesExtractor::extractAttributes(new Request([], [], ['_api_resource_class' => 'Foo']))); + } + + public function testExtractPreviousDataAttributes(): void + { + $object = new \stdClass(); + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', 'previous_data' => $object]); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'previous_data' => $object, + 'has_composite_identifier' => false, + ], RequestAttributesExtractor::extractAttributes($request)); + } + + public function testExtractIdentifiers(): void + { + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_operation_name' => 'get', '_api_identifiers' => ['test'], '_api_has_composite_identifier' => true]); + + $this->assertEquals([ + 'resource_class' => 'Foo', + 'operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'has_composite_identifier' => true, + ], RequestAttributesExtractor::extractAttributes($request)); + } +} diff --git a/composer.json b/composer.json index aa18b09..cec364c 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ ], "require": { "php": ">=8.1", - "api-platform/metadata": "*@dev || ^3.1", + "api-platform/metadata": "^3.1", "psr/container": "^1.0 || ^2.0" }, "require-dev": { @@ -36,8 +36,8 @@ "symfony/web-link": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || 7.0", "willdurand/negotiation": "^3.1", - "api-platform/validator": "*@dev || ^3.1", - "api-platform/serializer": "*@dev || ^3.1" + "api-platform/validator": "^3.1", + "api-platform/serializer": "^3.1" }, "autoload": { "psr-4": { @@ -76,4 +76,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} \ No newline at end of file +} From 2cb4fd325f067e3bd4f9cc56ac2d2fb396b4f92a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 31 Aug 2024 09:57:10 +0200 Subject: [PATCH 068/132] style: cs-fixer update (#6568) --- ApiResource/Error.php | 2 +- Provider/DeserializeProvider.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index 834a6f7..43f65a2 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -79,7 +79,7 @@ public function __construct( private ?string $instance = null, private string $type = 'about:blank', private array $headers = [], - ?\Throwable $previous = null + ?\Throwable $previous = null, ) { parent::__construct($title, $status, $previous); diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index 0e4010c..da7133c 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -37,7 +37,7 @@ public function __construct( private readonly ?ProviderInterface $decorated, private readonly SerializerInterface $serializer, private readonly LegacySerializerContextBuilderInterface|SerializerContextBuilderInterface $serializerContextBuilder, - private ?TranslatorInterface $translator = null + private ?TranslatorInterface $translator = null, ) { if (null === $this->translator) { $this->translator = new class implements TranslatorInterface, LocaleAwareInterface { From 81d710efca671d39d9c66990246243800366993d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 31 Aug 2024 10:36:15 +0200 Subject: [PATCH 069/132] Merge 3.4 (#6569) * style: cs-fixer update (#6568) * style: cs-fixer update --- ApiResource/Error.php | 2 +- Provider/DeserializeProvider.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index c115712..babc3a2 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -76,7 +76,7 @@ public function __construct( private ?string $instance = null, private string $type = 'about:blank', private array $headers = [], - ?\Throwable $previous = null + ?\Throwable $previous = null, ) { parent::__construct($title, $status, $previous); diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index 0e4010c..da7133c 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -37,7 +37,7 @@ public function __construct( private readonly ?ProviderInterface $decorated, private readonly SerializerInterface $serializer, private readonly LegacySerializerContextBuilderInterface|SerializerContextBuilderInterface $serializerContextBuilder, - private ?TranslatorInterface $translator = null + private ?TranslatorInterface $translator = null, ) { if (null === $this->translator) { $this->translator = new class implements TranslatorInterface, LocaleAwareInterface { From 46d0b89ff6d76960f7df252da1a55bcfc356c3ae Mon Sep 17 00:00:00 2001 From: Manuel Rossard <95523073+mrossard@users.noreply.github.com> Date: Sun, 1 Sep 2024 10:23:58 +0200 Subject: [PATCH 070/132] fix(state): log on missing provider (#6519) --- Provider/ReadProvider.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php index e5246f0..b3450dc 100644 --- a/Provider/ReadProvider.php +++ b/Provider/ReadProvider.php @@ -24,6 +24,7 @@ use ApiPlatform\State\UriVariablesResolverTrait; use ApiPlatform\State\Util\OperationRequestInitiatorTrait; use ApiPlatform\State\Util\RequestParser; +use Psr\Log\LoggerInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** @@ -40,6 +41,7 @@ final class ReadProvider implements ProviderInterface public function __construct( private readonly ProviderInterface $provider, private readonly LegacySerializerContextBuilderInterface|SerializerContextBuilderInterface|null $serializerContextBuilder = null, + private readonly ?LoggerInterface $logger = null, ) { } @@ -63,10 +65,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $context['filters'] = $filters; } + $resourceClass = $operation->getClass(); + if ($this->serializerContextBuilder && $request) { // Builtin data providers are able to use the serialization context to automatically add join clauses $context += $normalizationContext = $this->serializerContextBuilder->createFromRequest($request, true, [ - 'resource_class' => $operation->getClass(), + 'resource_class' => $resourceClass, 'operation' => $operation, ]); $request->attributes->set('_api_normalization_context', $normalizationContext); @@ -75,6 +79,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { $data = $this->provider->provide($operation, $uriVariables, $context); } catch (ProviderNotFoundException $e) { + // In case the dev just forgot to implement it + $this->logger?->debug('No provider registered for {resource_class}', ['resource_class' => $resourceClass]); $data = null; } @@ -85,7 +91,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) ) ) { - throw new NotFoundHttpException('Not Found'); + throw new NotFoundHttpException('Not Found', $e ?? null); } $request?->attributes->set('data', $data); From d9ffe58fd84dabb19daa806771e13e73f30013b9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 3 Sep 2024 09:00:23 +0200 Subject: [PATCH 071/132] chore: bump dependencies to ^3.4 || ^4.0 (#6576) * chore: bump dependencies to ^3.4 || ^4.0 * fix: use baseline for serializer context builder * ci: use pmu 0.12 --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index cec364c..db02a3a 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ ], "require": { "php": ">=8.1", - "api-platform/metadata": "^3.1", + "api-platform/metadata": "^3.4 || ^4.0", "psr/container": "^1.0 || ^2.0" }, "require-dev": { @@ -36,8 +36,8 @@ "symfony/web-link": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || 7.0", "willdurand/negotiation": "^3.1", - "api-platform/validator": "^3.1", - "api-platform/serializer": "^3.1" + "api-platform/validator": "^3.4 || ^4.0", + "api-platform/serializer": "^3.4 || ^4.0" }, "autoload": { "psr-4": { From e7c2d7c0db28eeab69832aab579b8cd390697ca4 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Sep 2024 16:47:50 +0200 Subject: [PATCH 072/132] fix(laravel): docs _format and open swagger ui (#6595) --- Provider/ContentNegotiationProvider.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Provider/ContentNegotiationProvider.php b/Provider/ContentNegotiationProvider.php index 0ffd426..f01b3e2 100644 --- a/Provider/ContentNegotiationProvider.php +++ b/Provider/ContentNegotiationProvider.php @@ -46,12 +46,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $formats = $operation->getOutputFormats() ?? ($isErrorOperation ? $this->errorFormats : $this->formats); $this->addRequestFormats($request, $formats); $request->attributes->set('input_format', $this->getInputFormat($operation, $request)); - - if (!$isErrorOperation) { - $request->setRequestFormat($this->getRequestFormat($request, $formats)); - } else { - $request->setRequestFormat($this->getRequestFormat($request, $formats, false)); - } + $request->setRequestFormat($this->getRequestFormat($request, $formats, !$isErrorOperation)); return $this->decorated?->provide($operation, $uriVariables, $context); } From 9b085f2dfbb7cfa3dbe655384b1093d26415725a Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Sat, 7 Sep 2024 08:31:56 +0200 Subject: [PATCH 073/132] fix: replace ApiPlatform\Exception use by ApiPlatform\Metadata\Exception (#6597) --- CreateProvider.php | 2 +- ObjectProvider.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CreateProvider.php b/CreateProvider.php index 2ccb423..d10a1d6 100644 --- a/CreateProvider.php +++ b/CreateProvider.php @@ -13,7 +13,7 @@ namespace ApiPlatform\State; -use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; diff --git a/ObjectProvider.php b/ObjectProvider.php index 3f931db..308dbda 100644 --- a/ObjectProvider.php +++ b/ObjectProvider.php @@ -13,7 +13,7 @@ namespace ApiPlatform\State; -use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Operation; /** From 32c85ff43ceabe8a3bdd92208d8ca1331be08ca8 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 10 Sep 2024 16:06:25 +0200 Subject: [PATCH 074/132] fix(laravel): parameter fixes and tests --- SerializerContextBuilderInterface.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SerializerContextBuilderInterface.php b/SerializerContextBuilderInterface.php index 35aaeb3..2de4b1d 100644 --- a/SerializerContextBuilderInterface.php +++ b/SerializerContextBuilderInterface.php @@ -48,7 +48,8 @@ interface SerializerContextBuilderInterface * deep_object_to_populate?: bool, * collect_denormalization_errors?: bool, * exclude_from_cache_key?: string[], - * api_included?: bool + * api_included?: bool, + * attributes?: string[], * } */ public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array; From 3d58a0f7215ded020841db842230331f4875a0da Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 11 Sep 2024 17:11:18 +0200 Subject: [PATCH 075/132] fix(state): remove resource_class change (#6607) --- Processor/SerializeProcessor.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Processor/SerializeProcessor.php b/Processor/SerializeProcessor.php index 088eb3e..b56bd33 100644 --- a/Processor/SerializeProcessor.php +++ b/Processor/SerializeProcessor.php @@ -52,11 +52,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $context['original_data'] = $data; $class = $operation->getClass(); - if ($request->attributes->get('_api_resource_class') && $request->attributes->get('_api_resource_class') !== $operation->getClass()) { - $class = $request->attributes->get('_api_resource_class'); - trigger_deprecation('api-platform/core', '3.3', 'The resource class on the router is not the same as the operation\'s class which leads to wrong behaviors. Prefer using "stateOptions" if you need to change the entity class.'); - } - $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ 'resource_class' => $class, 'operation' => $operation, From 9d9fcdfc94e6f5ee13a9336b6c96cb0b20255300 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 12 Sep 2024 10:10:49 +0200 Subject: [PATCH 076/132] feat(state): "deserializer_type" context (#6429) --- Provider/DeserializeProvider.php | 2 +- SerializerContextBuilderInterface.php | 3 +- Tests/Provider/DeserializeProviderTest.php | 131 +++++++++++++++++++++ composer.json | 3 +- 4 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 Tests/Provider/DeserializeProviderTest.php diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index da7133c..1e8a683 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -90,7 +90,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } try { - return $this->serializer->deserialize((string) $request->getContent(), $operation->getClass(), $format, $serializerContext); + return $this->serializer->deserialize((string) $request->getContent(), $serializerContext['deserializer_type'] ?? $operation->getClass(), $format, $serializerContext); } catch (PartialDenormalizationException $e) { if (!class_exists(ConstraintViolationList::class)) { throw $e; diff --git a/SerializerContextBuilderInterface.php b/SerializerContextBuilderInterface.php index 35aaeb3..225d165 100644 --- a/SerializerContextBuilderInterface.php +++ b/SerializerContextBuilderInterface.php @@ -48,7 +48,8 @@ interface SerializerContextBuilderInterface * deep_object_to_populate?: bool, * collect_denormalization_errors?: bool, * exclude_from_cache_key?: string[], - * api_included?: bool + * api_included?: bool, + * deserializer_type?: bool, * } */ public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array; diff --git a/Tests/Provider/DeserializeProviderTest.php b/Tests/Provider/DeserializeProviderTest.php new file mode 100644 index 0000000..4671058 --- /dev/null +++ b/Tests/Provider/DeserializeProviderTest.php @@ -0,0 +1,131 @@ + + * + * 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\Tests\Provider; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use ApiPlatform\State\Provider\DeserializeProvider; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\SerializerContextBuilderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\SerializerInterface; + +class DeserializeProviderTest extends TestCase +{ + public function testDeserialize(): void + { + $objectToPopulate = new \stdClass(); + $serializerContext = []; + $operation = new Post(deserialize: true, class: 'Test'); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($objectToPopulate); + + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->expects($this->once())->method('createFromRequest')->willReturn($serializerContext); + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->once())->method('deserialize')->with('test', 'Test', 'format', ['uri_variables' => ['id' => 1], AbstractNormalizer::OBJECT_TO_POPULATE => $objectToPopulate] + $serializerContext)->willReturn(new \stdClass()); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); + $request = new Request(content: 'test'); + $request->headers->set('CONTENT_TYPE', 'ok'); + $request->attributes->set('input_format', 'format'); + $provider->provide($operation, ['id' => 1], ['request' => $request]); + } + + public function testDeserializeNoContentType(): void + { + $this->expectException(UnsupportedMediaTypeHttpException::class); + $operation = new Get(deserialize: true, class: 'Test'); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializer = $this->createMock(SerializerInterface::class); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); + $request = new Request(content: 'test'); + $request->attributes->set('input_format', 'format'); + $provider->provide($operation, ['id' => 1], ['request' => $request]); + } + + public function testDeserializeNoInput(): void + { + $this->expectException(UnsupportedMediaTypeHttpException::class); + $operation = new Get(deserialize: true, class: 'Test'); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializer = $this->createMock(SerializerInterface::class); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); + $request = new Request(content: 'test'); + $request->headers->set('CONTENT_TYPE', 'ok'); + $provider->provide($operation, ['id' => 1], ['request' => $request]); + } + + public function testDeserializeWithContextClass(): void + { + $serializerContext = ['deserializer_type' => 'Test']; + $operation = new Get(deserialize: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->expects($this->once())->method('createFromRequest')->willReturn($serializerContext); + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->once())->method('deserialize')->with('test', 'Test', 'format', ['uri_variables' => ['id' => 1]] + $serializerContext)->willReturn(new \stdClass()); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); + $request = new Request(content: 'test'); + $request->headers->set('CONTENT_TYPE', 'ok'); + $request->attributes->set('input_format', 'format'); + $provider->provide($operation, ['id' => 1], ['request' => $request]); + } + + public function testRequestWithEmptyContentType(): void + { + $expectedResult = new \stdClass(); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($expectedResult); + + $serializer = $this->createStub(SerializerInterface::class); + $serializerContextBuilder = $this->createStub(SerializerContextBuilderInterface::class); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); + + // in Symfony (at least up to 7.0.2, 6.4.2, 6.3.11, 5.4.34), a request + // without a content-type and content-length header will result in the + // variables set to an empty string, not null + + $request = new Request( + server: [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/', + 'CONTENT_TYPE' => '', + 'CONTENT_LENGTH' => '', + ], + content: '' + ); + + $operation = new Post(deserialize: true); + $context = ['request' => $request]; + + $this->expectException(UnsupportedMediaTypeHttpException::class); + $provider->provide($operation, [], $context); + } +} diff --git a/composer.json b/composer.json index db02a3a..e10a6d9 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ "require": { "php": ">=8.1", "api-platform/metadata": "^3.4 || ^4.0", - "psr/container": "^1.0 || ^2.0" + "psr/container": "^1.0 || ^2.0", + "symfony/http-kernel": "^6.4 || 7.0" }, "require-dev": { "phpunit/phpunit": "^10.3", From a56aea091cbf9784645ee2195438928a07b747e9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 12 Sep 2024 16:08:43 +0200 Subject: [PATCH 077/132] fix(state): correct deserializer_type type --- SerializerContextBuilderInterface.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SerializerContextBuilderInterface.php b/SerializerContextBuilderInterface.php index 225d165..3bb88bf 100644 --- a/SerializerContextBuilderInterface.php +++ b/SerializerContextBuilderInterface.php @@ -49,7 +49,7 @@ interface SerializerContextBuilderInterface * collect_denormalization_errors?: bool, * exclude_from_cache_key?: string[], * api_included?: bool, - * deserializer_type?: bool, + * deserializer_type?: string, * } */ public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array; From e03dce44a0825be812b1a3617be8490b454cc933 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 13 Sep 2024 16:47:55 +0200 Subject: [PATCH 078/132] chore: http-kernel dependency --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b99eaaf..d1fd6a0 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "php": ">=8.1", "api-platform/metadata": "^3.4 || ^4.0", "psr/container": "^1.0 || ^2.0", - "symfony/http-kernel": "^6.4 || 7.0" + "symfony/http-kernel": "^6.4 || ^7.0" }, "require-dev": { "phpunit/phpunit": "^11.2", From 0166754ae95faa0d0a0bece56a9faa6a5feb4ee8 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Mon, 16 Sep 2024 10:31:06 +0200 Subject: [PATCH 079/132] fix: count TraversablePaginator (#6611) --- Pagination/TraversablePaginator.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Pagination/TraversablePaginator.php b/Pagination/TraversablePaginator.php index 8749e3b..87de541 100644 --- a/Pagination/TraversablePaginator.php +++ b/Pagination/TraversablePaginator.php @@ -68,6 +68,10 @@ public function count(): int return (int) ceil($this->totalItems); } + if ($this->totalItems === $this->itemsPerPage) { + return (int) ceil($this->totalItems); + } + return $this->totalItems % $this->itemsPerPage; } From 6a186ac9fe44f31ebeb1b8d0f6b67bf5435bc9ab Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 16 Sep 2024 14:57:46 +0200 Subject: [PATCH 080/132] chore: missing caret version constraints --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index e10a6d9..46b80f6 100644 --- a/composer.json +++ b/composer.json @@ -30,12 +30,12 @@ "php": ">=8.1", "api-platform/metadata": "^3.4 || ^4.0", "psr/container": "^1.0 || ^2.0", - "symfony/http-kernel": "^6.4 || 7.0" + "symfony/http-kernel": "^6.4 || ^7.0" }, "require-dev": { "phpunit/phpunit": "^10.3", "symfony/web-link": "^6.4 || ^7.0", - "symfony/http-foundation": "^6.4 || 7.0", + "symfony/http-foundation": "^6.4 || ^7.0", "willdurand/negotiation": "^3.1", "api-platform/validator": "^3.4 || ^4.0", "api-platform/serializer": "^3.4 || ^4.0" From 60425f223171cf7de38779bfbeef48b9bd6fb449 Mon Sep 17 00:00:00 2001 From: Nathan Pesneau <129308244+NathanPesneau@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:31:50 +0200 Subject: [PATCH 081/132] feat(laravel): filter validations rules * refactor(metadata): move parameter validation to the validator component * feat(laravel): validations rules filters * cs fixes * fix(laravel): eloquent filters validation * fix(laravel): eloquent filters * fixes * fix --------- Co-authored-by: soyuka Co-authored-by: Nathan --- Util/ParameterParserTrait.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Util/ParameterParserTrait.php b/Util/ParameterParserTrait.php index 6db86bf..cadbbcb 100644 --- a/Util/ParameterParserTrait.php +++ b/Util/ParameterParserTrait.php @@ -46,6 +46,10 @@ private function extractParameterValues(Parameter $parameter, array $values): st { $accessors = null; $key = $parameter->getKey(); + if (null === $key) { + throw new \RuntimeException('A Parameter should have a key.'); + } + $parsedKey = explode('[:property]', $key); if (isset($parsedKey[0]) && isset($values[$parsedKey[0]])) { $key = $parsedKey[0]; From 1c6b0054cb2bf5bc5bb08ae27388b87dd95b92ac Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Sep 2024 15:43:26 +0200 Subject: [PATCH 082/132] fix!(state): precise format on content-location --- Processor/RespondProcessor.php | 36 ++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index 15e9c03..e5a35b4 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -14,6 +14,9 @@ namespace ApiPlatform\State\Processor; use ApiPlatform\Metadata\Exception\HttpExceptionInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; @@ -92,18 +95,23 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $hasOutput = \is_array($outputMetadata) && \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class']; $hasData = !$hasOutput ? false : ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))); - if ($hasData && $this->iriConverter) { + if ($hasData) { + $isAlternateResourceMetadata = $operation->getExtraProperties()['is_alternate_resource_metadata'] ?? false; + $canonicalUriTemplate = $operation->getExtraProperties()['canonical_uri_template'] ?? null; + if ( !isset($headers['Location']) && 300 <= $status && $status < 400 - && (($operation->getExtraProperties()['is_alternate_resource_metadata'] ?? false) || ($operation->getExtraProperties()['canonical_uri_template'] ?? null)) + && ($isAlternateResourceMetadata || $canonicalUriTemplate) ) { $canonicalOperation = $operation; - if ($this->operationMetadataFactory && null !== ($operation->getExtraProperties()['canonical_uri_template'] ?? null)) { - $canonicalOperation = $this->operationMetadataFactory->create($operation->getExtraProperties()['canonical_uri_template'], $context); + if ($this->operationMetadataFactory && null !== ($canonicalUriTemplate)) { + $canonicalOperation = $this->operationMetadataFactory->create($canonicalUriTemplate, $context); } - $headers['Location'] = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, $canonicalOperation); + if ($this->iriConverter) { + $headers['Location'] = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, $canonicalOperation); + } } elseif ('PUT' === $method && !$request->attributes->get('previous_data') && null === $status && ($operation instanceof Put && ($operation->getAllowCreate() ?? false))) { $status = 201; } @@ -111,12 +119,20 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $status ??= self::METHOD_TO_CODE[$method] ?? 200; - if ($hasData && $this->iriConverter && !isset($headers['Content-Location'])) { - $iri = $this->iriConverter->getIriFromResource($originalData); - $headers['Content-Location'] = $iri; + if ($this->iriConverter && !isset($headers['Content-Location'])) { + try { + if ($hasData) { + $iri = $this->iriConverter->getIriFromResource($originalData); + } else { + $iri = $this->iriConverter->getIriFromResource($operation->getClass(), UrlGeneratorInterface::ABS_PATH, $operation); + } - if ((201 === $status || (300 <= $status && $status < 400)) && 'POST' === $method && !isset($headers['Location'])) { - $headers['Location'] = $iri; + $headers['Content-Location'] = sprintf('%s.%s', $iri, $request->getRequestFormat()); + + if ((201 === $status || (300 <= $status && $status < 400)) && 'POST' === $method && !isset($headers['Location'])) { + $headers['Location'] = $iri; + } + } catch (InvalidArgumentException|ItemNotFoundException|RuntimeException) { } } From 86c9342f66881bdd263a55dbe0c95aa9d3390d36 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 19 Sep 2024 17:27:30 +0200 Subject: [PATCH 083/132] fix!(state): precise format on content-location (#6627) --- Processor/RespondProcessor.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index e5a35b4..2bb9b13 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -105,7 +105,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = && ($isAlternateResourceMetadata || $canonicalUriTemplate) ) { $canonicalOperation = $operation; - if ($this->operationMetadataFactory && null !== ($canonicalUriTemplate)) { + if ($this->operationMetadataFactory && null !== $canonicalUriTemplate) { $canonicalOperation = $this->operationMetadataFactory->create($canonicalUriTemplate, $context); } @@ -119,6 +119,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $status ??= self::METHOD_TO_CODE[$method] ?? 200; + $requestParts = parse_url($request->getRequestUri()); if ($this->iriConverter && !isset($headers['Content-Location'])) { try { if ($hasData) { @@ -127,10 +128,16 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $iri = $this->iriConverter->getIriFromResource($operation->getClass(), UrlGeneratorInterface::ABS_PATH, $operation); } - $headers['Content-Location'] = sprintf('%s.%s', $iri, $request->getRequestFormat()); + if ($iri) { + $location = \sprintf('%s.%s', $iri, $request->getRequestFormat()); + if (isset($requestParts['query'])) { + $location .= '?'.$requestParts['query']; + } - if ((201 === $status || (300 <= $status && $status < 400)) && 'POST' === $method && !isset($headers['Location'])) { - $headers['Location'] = $iri; + $headers['Content-Location'] = $location; + if ((201 === $status || (300 <= $status && $status < 400)) && 'POST' === $method && !isset($headers['Location'])) { + $headers['Location'] = $iri; + } } } catch (InvalidArgumentException|ItemNotFoundException|RuntimeException) { } From 4fab0bd9a7983d3971936386832de06f55e4d0ca Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Sep 2024 17:32:43 +0200 Subject: [PATCH 084/132] test: missing condition --- Processor/RespondProcessor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index 2bb9b13..3d02507 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -124,7 +124,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = try { if ($hasData) { $iri = $this->iriConverter->getIriFromResource($originalData); - } else { + } elseif ($operation->getClass()) { $iri = $this->iriConverter->getIriFromResource($operation->getClass(), UrlGeneratorInterface::ABS_PATH, $operation); } From 4c602e5c3051c2cee700c564431361e6dd1a0e9c Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Sep 2024 17:33:27 +0200 Subject: [PATCH 085/132] cs: missing variable declaration --- Processor/RespondProcessor.php | 1 + 1 file changed, 1 insertion(+) diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index 3d02507..64b0730 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -122,6 +122,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $requestParts = parse_url($request->getRequestUri()); if ($this->iriConverter && !isset($headers['Content-Location'])) { try { + $iri = null; if ($hasData) { $iri = $this->iriConverter->getIriFromResource($originalData); } elseif ($operation->getClass()) { From 6495a66578eeb581430f6d0c104bf398d8f78fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Thu, 19 Sep 2024 17:56:07 +0200 Subject: [PATCH 086/132] chore: setup star forwarding (#6630) --- composer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/composer.json b/composer.json index 46b80f6..c813028 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,10 @@ }, "symfony": { "require": "^6.4 || ^7.1" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" } }, "suggest": { From 0afc502afb86584e71d4031e10c3d13f94bb97b5 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 19 Sep 2024 17:56:20 +0200 Subject: [PATCH 087/132] chore: php version >= 8.2 (#6628) * chore: php version >= 8.2 * down to sf 7.0 * symfony extra require * missing hal constraint --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index b6968b9..c2a3528 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.2", "api-platform/metadata": "^3.4 || ^4.0", "psr/container": "^1.0 || ^2.0", "symfony/http-kernel": "^6.4 || ^7.0" @@ -64,7 +64,7 @@ "dev-3.4": "3.4.x-dev" }, "symfony": { - "require": "^6.4 || ^7.1" + "require": "^6.4 || ^7.0" } }, "suggest": { From 518815818ce3bd380bd225c0db8f0cc71ccbacc4 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Sep 2024 17:58:51 +0200 Subject: [PATCH 088/132] chore: fix thanks url --- composer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/composer.json b/composer.json index c2a3528..7266439 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,10 @@ }, "symfony": { "require": "^6.4 || ^7.0" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" } }, "suggest": { From abcd4dd6df23272d6b9dec42c65488f65adc1c7a Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 20 Sep 2024 10:03:15 +0200 Subject: [PATCH 089/132] fix: default format and standard_put values --- Provider/DeserializeProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index 1e8a683..db735e1 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -83,7 +83,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c && ( 'POST' === $method || 'PATCH' === $method - || ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? false)) + || ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true)) ) ) { $serializerContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; From ab4ab5c76ed62257c89e8d4cc3f995e961a593d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 20 Sep 2024 15:47:23 +0200 Subject: [PATCH 090/132] chore: add GitHub Action to automatically close PRs on subtree splits (#6648) --- .gitattributes | 3 +++ .github/workflows/close_pr.yml | 13 +++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 .github/workflows/close_pr.yml diff --git a/.gitattributes b/.gitattributes index ae3c2e1..801f208 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ +/.github export-ignore +/.gitattributes export-ignore /.gitignore export-ignore /Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/workflows/close_pr.yml b/.github/workflows/close_pr.yml new file mode 100644 index 0000000..72a8ab4 --- /dev/null +++ b/.github/workflows/close_pr.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "Thank you for your pull request. However, you have submitted this PR on a read-only sub split of `api-platform/core`. Please submit your PR on the https://github.com/api-platform/core repository.

Thanks!" From 39a283f1ef720c91e99d7204c52b5f415580dcff Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 20 Sep 2024 11:50:56 +0200 Subject: [PATCH 091/132] fix(serializer): remove serializer context builder interface --- Provider/DeserializeProvider.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index db735e1..be399f4 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -15,7 +15,6 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Serializer\SerializerContextBuilderInterface as LegacySerializerContextBuilderInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use ApiPlatform\Validator\Exception\ValidationException; @@ -36,7 +35,7 @@ final class DeserializeProvider implements ProviderInterface public function __construct( private readonly ?ProviderInterface $decorated, private readonly SerializerInterface $serializer, - private readonly LegacySerializerContextBuilderInterface|SerializerContextBuilderInterface $serializerContextBuilder, + private readonly SerializerContextBuilderInterface $serializerContextBuilder, private ?TranslatorInterface $translator = null, ) { if (null === $this->translator) { From 7e981d2f12a1eeec64d3e755a9728945f91c9749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 21 Sep 2024 12:54:56 +0200 Subject: [PATCH 092/132] doc: add README files for components (#6653) * docs: add README for components * Update README.md Co-authored-by: Antoine Bluchet * Update README.md Co-authored-by: Antoine Bluchet * Update README.md Co-authored-by: Antoine Bluchet * remove trailing spaces * typo * better title --------- Co-authored-by: Antoine Bluchet --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0e4b1b --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# API Platform - Serializer + +The State component of the [API Platform](https://api-platform.com) framework. + +Provides and processes API states. + +Documentation: + +* [State providers](https://api-platform.com/docs/core/state-providers/) +* [State processors](https://api-platform.com/docs/core/state-processors/) + +> [!CAUTION] +> +> This is a read-only sub split of `api-platform/core`, please +> [report issues](https://github.com/api-platform/core/issues) and +> [send Pull Requests](https://github.com/api-platform/core/pulls) +> in the [core API Platform repository](https://github.com/api-platform/core). From af94f0079942a866524066890a59de51d006f781 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:55:59 +0200 Subject: [PATCH 093/132] fix: remove hydra prefix on errors (#6624) Co-authored-by: soyuka --- ApiResource/Error.php | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index 43f65a2..2ad7ebf 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -25,7 +25,6 @@ use Symfony\Component\WebLink\Link; #[ErrorResource( - types: ['hydra:Error'], openapi: false, uriVariables: ['status'], uriTemplate: '/errors/{status}', @@ -98,21 +97,7 @@ public function __construct( #[Groups(['trace'])] public ?array $originalTrace = null; - #[SerializedName('hydra:title')] #[Groups(['jsonld'])] - public function getHydraTitle(): ?string - { - return $this->title; - } - - #[SerializedName('hydra:description')] - #[Groups(['jsonld'])] - public function getHydraDescription(): ?string - { - return $this->detail; - } - - #[SerializedName('description')] public function getDescription(): ?string { return $this->detail; From f6fe07c7d884b9b190e275cf1b04d469df2ec19f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rokas=20Mikalk=C4=97nas?= <14221532+rmikalkenas@users.noreply.github.com> Date: Tue, 1 Oct 2024 10:53:53 +0300 Subject: [PATCH 094/132] fix: parameter provider in a long running http worker (#6683) --- Provider/ParameterProvider.php | 4 ++++ Tests/ParameterProviderTest.php | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php index 9a905d9..7ffa0c1 100644 --- a/Provider/ParameterProvider.php +++ b/Provider/ParameterProvider.php @@ -52,6 +52,10 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $context = ['operation' => $operation] + $context; $parameters = $operation->getParameters(); foreach ($parameters ?? [] as $parameter) { + $extraProperties = $parameter->getExtraProperties(); + unset($extraProperties['_api_values']); + $parameters->add($parameter->getKey(), $parameter = $parameter->withExtraProperties($extraProperties)); + $values = $this->getParameterValues($parameter, $request, $context); $value = $this->extractParameterValues($parameter, $values); diff --git a/Tests/ParameterProviderTest.php b/Tests/ParameterProviderTest.php index 66d5d7a..97791d9 100644 --- a/Tests/ParameterProviderTest.php +++ b/Tests/ParameterProviderTest.php @@ -18,6 +18,7 @@ use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\State\ParameterNotFound; use ApiPlatform\State\ParameterProviderInterface; use ApiPlatform\State\Provider\ParameterProvider; use PHPUnit\Framework\TestCase; @@ -51,9 +52,11 @@ public function has(string $id): bool 'order' => new QueryParameter(key: 'order', provider: 'test'), 'search[:property]' => new QueryParameter(key: 'search[:property]', provider: [self::class, 'provide']), 'foo' => new QueryParameter(key: 'foo', provider: [self::class, 'shouldNotBeCalled']), + 'baz' => (new QueryParameter(key: 'baz'))->withExtraProperties(['_api_values' => 'test1']), + 'fas' => (new QueryParameter(key: 'fas'))->withExtraProperties(['_api_values' => '42']), ])); $parameterProvider = new ParameterProvider(null, $locator); - $request = new Request(server: ['QUERY_STRING' => 'order[foo]=asc&search[a]=bar']); + $request = new Request(server: ['QUERY_STRING' => 'order[foo]=asc&search[a]=bar&baz=t42']); $context = ['request' => $request, 'operation' => $operation]; $parameterProvider->provide($operation, [], $context); $operation = $request->attributes->get('_api_operation'); @@ -61,6 +64,8 @@ public function has(string $id): bool $this->assertEquals('ok', $operation->getName()); $this->assertEquals(['foo' => 'asc'], $operation->getParameters()->get('order', QueryParameter::class)->getValue()); $this->assertEquals(['a' => 'bar'], $operation->getParameters()->get('search[:property]', QueryParameter::class)->getValue()); + $this->assertEquals('t42', $operation->getParameters()->get('baz', QueryParameter::class)->getValue()); + $this->assertEquals(new ParameterNotFound(), $operation->getParameters()->get('fas', QueryParameter::class)->getValue()); } public static function provide(): void From 693c9b4e7c81450e7804179969717c78f13a1baa Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 17 Oct 2024 12:05:01 +0200 Subject: [PATCH 095/132] fix: multiple parameter provider #6673 (#6732) --- Provider/ParameterProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php index 7ffa0c1..548d364 100644 --- a/Provider/ParameterProvider.php +++ b/Provider/ParameterProvider.php @@ -49,13 +49,13 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $request->attributes->set('_api_header_parameters', $request->headers->all()); } - $context = ['operation' => $operation] + $context; $parameters = $operation->getParameters(); foreach ($parameters ?? [] as $parameter) { $extraProperties = $parameter->getExtraProperties(); unset($extraProperties['_api_values']); $parameters->add($parameter->getKey(), $parameter = $parameter->withExtraProperties($extraProperties)); + $context = ['operation' => $operation] + $context; $values = $this->getParameterValues($parameter, $request, $context); $value = $this->extractParameterValues($parameter, $values); From c3f91ce3158729305f88654d2ca3d20912c67520 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 25 Oct 2024 15:28:30 +0200 Subject: [PATCH 096/132] fix(laravel): jsonapi error serialization (#6755) --- ApiResource/Error.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index 4a5061a..ec7e816 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -90,6 +90,12 @@ public function __construct( } } + #[Groups(['jsonapi'])] + public function getId(): string + { + return (string) $this->status; + } + #[SerializedName('trace')] #[Groups(['trace'])] public ?array $originalTrace = null; @@ -129,7 +135,7 @@ public function setHeaders(array $headers): void $this->headers = $headers; } - #[Groups(['jsonld', 'jsonproblem'])] + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] public function getType(): string { return $this->type; From 3d3a96c18f04ff63efec6ee175b09436cbe87c0d Mon Sep 17 00:00:00 2001 From: GeLoLabs <149005863+GeLoLabs@users.noreply.github.com> Date: Thu, 14 Nov 2024 15:23:06 +0100 Subject: [PATCH 097/132] fix(state): empty result when the array paginator is out of bound (#6785) When using Doctrine or Elastic paginator, it behaves by giving an empty array when the page is out of bounds but when using the Array paginator, it throws an OutOfBoundsException. This PR align this behavior accross all paginators. Co-authored-by: Eric GELOEN --- Pagination/ArrayPaginator.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Pagination/ArrayPaginator.php b/Pagination/ArrayPaginator.php index 3de9ab6..8c50186 100644 --- a/Pagination/ArrayPaginator.php +++ b/Pagination/ArrayPaginator.php @@ -27,14 +27,15 @@ final class ArrayPaginator implements \IteratorAggregate, PaginatorInterface, Ha public function __construct(array $results, int $firstResult, int $maxResults) { - if ($maxResults > 0) { + $this->firstResult = $firstResult; + $this->maxResults = $maxResults; + $this->totalItems = \count($results); + + if ($maxResults > 0 && $firstResult < $this->totalItems) { $this->iterator = new \LimitIterator(new \ArrayIterator($results), $firstResult, $maxResults); } else { $this->iterator = new \EmptyIterator(); } - $this->firstResult = $firstResult; - $this->maxResults = $maxResults; - $this->totalItems = \count($results); } /** From 5c1443d2230d85b20754adbae7b1dcd03781757f Mon Sep 17 00:00:00 2001 From: Aleksey Polyvanyi Date: Fri, 15 Nov 2024 10:20:26 +0100 Subject: [PATCH 098/132] fix(state): do not check content type if no input (#6794) --- Provider/ContentNegotiationProvider.php | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Provider/ContentNegotiationProvider.php b/Provider/ContentNegotiationProvider.php index 0ffd426..d9f242c 100644 --- a/Provider/ContentNegotiationProvider.php +++ b/Provider/ContentNegotiationProvider.php @@ -97,6 +97,14 @@ private function flattenMimeTypes(array $formats): array */ private function getInputFormat(HttpOperation $operation, Request $request): ?string { + if ( + false === ($input = $operation->getInput()) + || (\is_array($input) && null === $input['class']) + || false === $operation->canDeserialize() + ) { + return null; + } + $contentType = $request->headers->get('CONTENT_TYPE'); if (null === $contentType || '' === $contentType) { return null; @@ -108,14 +116,14 @@ private function getInputFormat(HttpOperation $operation, Request $request): ?st return $format; } - $supportedMimeTypes = []; - foreach ($formats as $mimeTypes) { - foreach ($mimeTypes as $mimeType) { - $supportedMimeTypes[] = $mimeType; + if (!$request->isMethodSafe() && 'DELETE' !== $request->getMethod()) { + $supportedMimeTypes = []; + foreach ($formats as $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $supportedMimeTypes[] = $mimeType; + } } - } - if (!$request->isMethodSafe() && 'DELETE' !== $request->getMethod()) { throw new UnsupportedMediaTypeHttpException(\sprintf('The content-type "%s" is not supported. Supported MIME types are "%s".', $contentType, implode('", "', $supportedMimeTypes))); } From 33dabb91857e2458d390f79352235f3726e95888 Mon Sep 17 00:00:00 2001 From: dwgebler Date: Fri, 29 Nov 2024 10:24:58 +0000 Subject: [PATCH 099/132] fix(symfony): retain existing associations of formats to MIME types when adding new MIME types (#6833) --- Provider/ContentNegotiationProvider.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Provider/ContentNegotiationProvider.php b/Provider/ContentNegotiationProvider.php index d9f242c..2f842fd 100644 --- a/Provider/ContentNegotiationProvider.php +++ b/Provider/ContentNegotiationProvider.php @@ -67,7 +67,9 @@ public function provide(Operation $operation, array $uriVariables = [], array $c private function addRequestFormats(Request $request, array $formats): void { foreach ($formats as $format => $mimeTypes) { - $request->setFormat($format, (array) $mimeTypes); + $existingMimeTypes = $request->getMimeTypes($format); + $newMimeTypes = array_unique(array_merge((array) $mimeTypes, $existingMimeTypes)); + $request->setFormat($format, $newMimeTypes); } } From b4a3e6f5e6e4bf1c9301b05cc2a08ddf5a6de94a Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Dec 2024 11:05:08 +0100 Subject: [PATCH 100/132] Revert "fix(symfony): retain existing associations of formats to MIME types when adding new MIME types (#6833)" This reverts commit 7c9cca6759da617429691e92aab218a5de28b622. --- Provider/ContentNegotiationProvider.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Provider/ContentNegotiationProvider.php b/Provider/ContentNegotiationProvider.php index 2f842fd..d9f242c 100644 --- a/Provider/ContentNegotiationProvider.php +++ b/Provider/ContentNegotiationProvider.php @@ -67,9 +67,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c private function addRequestFormats(Request $request, array $formats): void { foreach ($formats as $format => $mimeTypes) { - $existingMimeTypes = $request->getMimeTypes($format); - $newMimeTypes = array_unique(array_merge((array) $mimeTypes, $existingMimeTypes)); - $request->setFormat($format, $newMimeTypes); + $request->setFormat($format, (array) $mimeTypes); } } From 7e035175d90d7431bb3e29ce8c05d4d9e53ff116 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 16 Dec 2024 14:31:11 +0100 Subject: [PATCH 101/132] feat(state): strict query parameters (#6399) * feat(state): strict query parameters * fixes --- Exception/ParameterNotSupportedException.php | 50 ++++++++++++++++++++ Provider/ParameterProvider.php | 16 +++++++ 2 files changed, 66 insertions(+) create mode 100644 Exception/ParameterNotSupportedException.php diff --git a/Exception/ParameterNotSupportedException.php b/Exception/ParameterNotSupportedException.php new file mode 100644 index 0000000..1f0dcd5 --- /dev/null +++ b/Exception/ParameterNotSupportedException.php @@ -0,0 +1,50 @@ + + * + * 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\Exception; + +use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; +use ApiPlatform\Metadata\Exception\RuntimeException; + +final class ParameterNotSupportedException extends RuntimeException implements ProblemExceptionInterface +{ + public function __construct(private readonly string $parameter, string $message = 'Parameter not supported', int $code = 0, ?\Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } + + public function getType(): string + { + return '/error/400'; + } + + public function getTitle(): ?string + { + return $this->message; + } + + public function getStatus(): ?int + { + return 400; + } + + public function getDetail(): ?string + { + return \sprintf('Parameter "%s" not supported', $this->parameter); + } + + public function getInstance(): ?string + { + return $this->parameter; + } +} diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php index 548d364..35521f5 100644 --- a/Provider/ParameterProvider.php +++ b/Provider/ParameterProvider.php @@ -13,7 +13,9 @@ namespace ApiPlatform\State\Provider; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Exception\ParameterNotSupportedException; use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ParameterNotFound; use ApiPlatform\State\ParameterProviderInterface; @@ -50,6 +52,20 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $parameters = $operation->getParameters(); + + if ($operation instanceof HttpOperation && true === $operation->getStrictQueryParameterValidation()) { + $keys = []; + foreach ($parameters as $parameter) { + $keys[] = $parameter->getKey(); + } + + foreach (array_keys($request->attributes->get('_api_query_parameters')) as $key) { + if (!\in_array($key, $keys, true)) { + throw new ParameterNotSupportedException($key); + } + } + } + foreach ($parameters ?? [] as $parameter) { $extraProperties = $parameter->getExtraProperties(); unset($extraProperties['_api_values']); From 9c7a197dc44dfe63df9b9b5ea49125a4ce30fbc8 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 9 Jan 2025 11:54:20 +0100 Subject: [PATCH 102/132] fix: remove laravel specific type fixes #6890 --- ProcessorInterface.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ProcessorInterface.php b/ProcessorInterface.php index 07bb906..8bfa0c4 100644 --- a/ProcessorInterface.php +++ b/ProcessorInterface.php @@ -31,7 +31,7 @@ interface ProcessorInterface * * @param T1 $data * @param array $uriVariables - * @param array&array{request?: Request|\Illuminate\Http\Request, previous_data?: mixed, resource_class?: string|null, original_data?: mixed} $context + * @param array&array{request?: Request, previous_data?: mixed, resource_class?: string|null, original_data?: mixed} $context * * @return T2 */ From 31b88af446a462f1572dc8f6b34229658183da96 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Thu, 9 Jan 2025 16:16:11 +0100 Subject: [PATCH 103/132] chore: lint for php-cs-fixer 3.66.0 compatibility (#6902) --- ProcessorInterface.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ProcessorInterface.php b/ProcessorInterface.php index 8bfa0c4..5d676e1 100644 --- a/ProcessorInterface.php +++ b/ProcessorInterface.php @@ -29,8 +29,8 @@ interface ProcessorInterface /** * Handles the state. * - * @param T1 $data - * @param array $uriVariables + * @param T1 $data + * @param array $uriVariables * @param array&array{request?: Request, previous_data?: mixed, resource_class?: string|null, original_data?: mixed} $context * * @return T2 From 07310ba69b1f402155a6d214835625325748d150 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Mon, 13 Jan 2025 11:20:09 +0100 Subject: [PATCH 104/132] fix(state): skip Content-Location header for GET requests (#6901) --- Processor/RespondProcessor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php index 64b0730..58941e4 100644 --- a/Processor/RespondProcessor.php +++ b/Processor/RespondProcessor.php @@ -129,7 +129,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $iri = $this->iriConverter->getIriFromResource($operation->getClass(), UrlGeneratorInterface::ABS_PATH, $operation); } - if ($iri) { + if ($iri && 'GET' !== $method) { $location = \sprintf('%s.%s', $iri, $request->getRequestFormat()); if (isset($requestParts['query'])) { $location .= '?'.$requestParts['query']; From ccaa65ba13cb748ea25b34177afd14aca3c194c5 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 16 Jan 2025 09:18:06 +0100 Subject: [PATCH 105/132] fix(hydra): rdfs:label should not duplicate title (#6748) --- ApiResource/Error.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index ec7e816..a69c7e6 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -33,6 +33,7 @@ name: '_api_errors_problem', routeName: 'api_errors', outputFormats: ['json' => ['application/problem+json']], + hideHydraOperation: true, normalizationContext: [ 'groups' => ['jsonproblem'], 'skip_null_values' => true, @@ -51,6 +52,7 @@ new Operation( name: '_api_errors_jsonapi', routeName: 'api_errors', + hideHydraOperation: true, outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: [ 'groups' => ['jsonapi'], @@ -59,18 +61,21 @@ ), new Operation( name: '_api_errors', - routeName: 'api_errors' + routeName: 'api_errors', + hideHydraOperation: true, ), ], provider: 'api_platform.state.error_provider', graphQlOperations: [] )] +#[ApiProperty(property: 'traceAsString', hydra: false)] +#[ApiProperty(property: 'string', hydra: false)] class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface { public function __construct( private string $title, private string $detail, - #[ApiProperty(identifier: true)] private int $status, + #[ApiProperty(identifier: true, writable: false, initializable: false)] private int $status, ?array $originalTrace = null, private ?string $instance = null, private string $type = 'about:blank', @@ -98,9 +103,11 @@ public function getId(): string #[SerializedName('trace')] #[Groups(['trace'])] + #[ApiProperty(writable: false, initializable: false)] public ?array $originalTrace = null; #[Groups(['jsonld'])] + #[ApiProperty(writable: false, initializable: false)] public function getDescription(): ?string { return $this->detail; @@ -121,7 +128,6 @@ public function getHeaders(): array } #[Ignore] - #[ApiProperty(readable: false)] public function getStatusCode(): int { return $this->status; @@ -136,6 +142,7 @@ public function setHeaders(array $headers): void } #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] + #[ApiProperty(writable: false, initializable: false)] public function getType(): string { return $this->type; @@ -147,6 +154,7 @@ public function setType(string $type): void } #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] + #[ApiProperty(writable: false, initializable: false)] public function getTitle(): ?string { return $this->title; @@ -169,6 +177,7 @@ public function setStatus(int $status): void } #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] + #[ApiProperty(writable: false, initializable: false)] public function getDetail(): ?string { return $this->detail; @@ -180,6 +189,7 @@ public function setDetail(?string $detail = null): void } #[Groups(['jsonld', 'jsonproblem'])] + #[ApiProperty(writable: false, initializable: false)] public function getInstance(): ?string { return $this->instance; From afc3844b3ce708dd6f44d0264fff656452326e38 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 27 Jan 2025 17:16:57 +0100 Subject: [PATCH 106/132] feat(openapi): document error outputs using json-schemas (#6923) --- ApiResource/Error.php | 71 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index a69c7e6..2f9cbeb 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -25,7 +25,6 @@ use Symfony\Component\WebLink\Link; #[ErrorResource( - openapi: false, uriVariables: ['status'], uriTemplate: '/errors/{status}', operations: [ @@ -37,15 +36,17 @@ normalizationContext: [ 'groups' => ['jsonproblem'], 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], ), new Operation( name: '_api_errors_hydra', routeName: 'api_errors', - outputFormats: ['jsonld' => ['application/problem+json']], + outputFormats: ['jsonld' => ['application/problem+json', 'application/ld+json']], normalizationContext: [ 'groups' => ['jsonld'], 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], links: [new Link(rel: 'http://www.w3.org/ns/json-ld#error', href: 'http://www.w3.org/ns/hydra/error')], ), @@ -55,32 +56,46 @@ hideHydraOperation: true, outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: [ + 'disable_json_schema_serializer_groups' => false, 'groups' => ['jsonapi'], 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], ), new Operation( name: '_api_errors', routeName: 'api_errors', hideHydraOperation: true, + openapi: false ), ], provider: 'api_platform.state.error_provider', - graphQlOperations: [] + graphQlOperations: [], + description: 'A representation of common errors.' )] #[ApiProperty(property: 'traceAsString', hydra: false)] #[ApiProperty(property: 'string', hydra: false)] class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface { + private ?string $id = null; + public function __construct( private string $title, private string $detail, - #[ApiProperty(identifier: true, writable: false, initializable: false)] private int $status, + #[ApiProperty( + description: 'The HTTP status code applicable to this problem.', + identifier: true, + writable: false, + initializable: false, + schema: ['type' => 'number', 'example' => 404, 'default' => 400] + )] private int $status, ?array $originalTrace = null, private ?string $instance = null, private string $type = 'about:blank', private array $headers = [], ?\Throwable $previous = null, + private ?array $meta = null, + private ?array $source = null, ) { parent::__construct($title, $status, $previous); @@ -98,7 +113,28 @@ public function __construct( #[Groups(['jsonapi'])] public function getId(): string { - return (string) $this->status; + return $this->id ?? ((string) $this->status); + } + + #[Groups(['jsonapi'])] + #[ApiProperty(schema: ['type' => 'object'])] + public function getMeta(): ?array + { + return $this->meta; + } + + #[Groups(['jsonapi'])] + #[ApiProperty(schema: [ + 'type' => 'object', + 'properties' => [ + 'pointer' => ['type' => 'string'], + 'parameter' => ['type' => 'string'], + 'header' => ['type' => 'string'], + ], + ])] + public function getSource(): ?array + { + return $this->source; } #[SerializedName('trace')] @@ -142,7 +178,7 @@ public function setHeaders(array $headers): void } #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] - #[ApiProperty(writable: false, initializable: false)] + #[ApiProperty(writable: false, initializable: false, description: 'A URI reference that identifies the problem type')] public function getType(): string { return $this->type; @@ -154,7 +190,7 @@ public function setType(string $type): void } #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] - #[ApiProperty(writable: false, initializable: false)] + #[ApiProperty(writable: false, initializable: false, description: 'A short, human-readable summary of the problem.')] public function getTitle(): ?string { return $this->title; @@ -177,7 +213,7 @@ public function setStatus(int $status): void } #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] - #[ApiProperty(writable: false, initializable: false)] + #[ApiProperty(writable: false, initializable: false, description: 'A human-readable explanation specific to this occurrence of the problem.')] public function getDetail(): ?string { return $this->detail; @@ -188,8 +224,8 @@ public function setDetail(?string $detail = null): void $this->detail = $detail; } - #[Groups(['jsonld', 'jsonproblem'])] - #[ApiProperty(writable: false, initializable: false)] + #[Groups(['jsonld', 'jsonproblem', 'jsonapi'])] + #[ApiProperty(writable: false, initializable: false, description: 'A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.')] public function getInstance(): ?string { return $this->instance; @@ -199,4 +235,19 @@ public function setInstance(?string $instance = null): void { $this->instance = $instance; } + + public function setId(?string $id = null): void + { + $this->id = $id; + } + + public function setMeta(?array $meta = null): void + { + $this->meta = $meta; + } + + public function setSource(?array $source = null): void + { + $this->source = $source; + } } From b04d36f4f97cc1c810cf37f85e024bec0f735b00 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 7 Feb 2025 15:44:27 +0100 Subject: [PATCH 107/132] fix: errors retrieval and documentation (#6952) --- ApiResource/Error.php | 39 ++++++++++++++++++++--------- ErrorProvider.php | 57 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index 2f9cbeb..0593719 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -13,9 +13,11 @@ namespace ApiPlatform\State\ApiResource; +use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Error as Operation; use ApiPlatform\Metadata\ErrorResource; +use ApiPlatform\Metadata\ErrorResourceInterface; use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; @@ -26,24 +28,30 @@ #[ErrorResource( uriVariables: ['status'], - uriTemplate: '/errors/{status}', + requirements: ['status' => '\d+'], + uriTemplate: '/errors/{status}{._format}', + openapi: false, operations: [ new Operation( + errors: [], name: '_api_errors_problem', - routeName: 'api_errors', - outputFormats: ['json' => ['application/problem+json']], + routeName: '_api_errors', + outputFormats: ['json' => ['application/problem+json', 'application/json']], hideHydraOperation: true, normalizationContext: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', 'groups' => ['jsonproblem'], 'skip_null_values' => true, 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], ), new Operation( + errors: [], name: '_api_errors_hydra', - routeName: 'api_errors', + routeName: '_api_errors', outputFormats: ['jsonld' => ['application/problem+json', 'application/ld+json']], normalizationContext: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', 'groups' => ['jsonld'], 'skip_null_values' => true, 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], @@ -51,11 +59,13 @@ links: [new Link(rel: 'http://www.w3.org/ns/json-ld#error', href: 'http://www.w3.org/ns/hydra/error')], ), new Operation( + errors: [], name: '_api_errors_jsonapi', - routeName: 'api_errors', + routeName: '_api_errors', hideHydraOperation: true, outputFormats: ['jsonapi' => ['application/vnd.api+json']], normalizationContext: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', 'disable_json_schema_serializer_groups' => false, 'groups' => ['jsonapi'], 'skip_null_values' => true, @@ -64,18 +74,25 @@ ), new Operation( name: '_api_errors', - routeName: 'api_errors', hideHydraOperation: true, - openapi: false + extraProperties: ['_api_disable_swagger_provider' => true], + outputFormats: [ + 'html' => ['text/html'], + 'jsonapi' => ['application/vnd.api+json'], + 'jsonld' => ['application/ld+json'], + 'json' => ['application/problem+json', 'application/json'], + ], ), ], + outputFormats: ['jsonapi' => ['application/vnd.api+json'], 'jsonld' => ['application/ld+json'], 'json' => ['application/problem+json', 'application/json']], provider: 'api_platform.state.error_provider', graphQlOperations: [], - description: 'A representation of common errors.' + description: 'A representation of common errors.', )] -#[ApiProperty(property: 'traceAsString', hydra: false)] -#[ApiProperty(property: 'string', hydra: false)] -class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface +#[ApiProperty(property: 'previous', hydra: false, readable: false)] +#[ApiProperty(property: 'traceAsString', hydra: false, readable: false)] +#[ApiProperty(property: 'string', hydra: false, readable: false)] +class Error extends \Exception implements ProblemExceptionInterface, HttpExceptionInterface, ErrorResourceInterface { private ?string $id = null; diff --git a/ErrorProvider.php b/ErrorProvider.php index e85a7f3..a6ebb62 100644 --- a/ErrorProvider.php +++ b/ErrorProvider.php @@ -13,36 +13,85 @@ namespace ApiPlatform\State; +use ApiPlatform\Metadata\ErrorResourceInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\State\ApiResource\Error; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * @internal */ final class ErrorProvider implements ProviderInterface { - public function __construct(private readonly bool $debug = false, private ?ResourceClassResolverInterface $resourceClassResolver = null) + public function __construct(private readonly bool $debug = false, private ?ResourceClassResolverInterface $resourceClassResolver = null, private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null) { } + /** + * @param array{status?: int} $uriVariables + */ public function provide(Operation $operation, array $uriVariables = [], array $context = []): object { - if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation || null === ($exception = $request->attributes->get('exception'))) { + if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation) { throw new \RuntimeException('Not an HTTP request'); } + if (!($exception = $request->attributes->get('exception'))) { + $status = $uriVariables['status'] ?? null; + + // We change the operation to get our normalization context according to the format + if ($this->resourceMetadataCollectionFactory) { + $resourceCollection = $this->resourceMetadataCollectionFactory->create($operation->getClass()); + foreach ($resourceCollection as $resource) { + foreach ($resource->getOperations() as $name => $operation) { + if (isset($operation->getOutputFormats()[$request->getRequestFormat()])) { + $request->attributes->set('_api_operation', $operation); + $request->attributes->set('_api_operation_nme', $name); + break 2; + } + } + } + } + + $text = Response::$statusTexts[$status] ?? throw new NotFoundHttpException(); + + $cl = $operation->getClass(); + + return match ($request->getRequestFormat()) { + 'html' => $this->renderError((int) $status, $text), + default => new $cl("Error $status", $text, (int) $status), + }; + } + if ($this->resourceClassResolver?->isResourceClass($exception::class)) { return $exception; } $status = $operation->getStatus() ?? 500; - $error = Error::createFromException($exception, $status); - if (!$this->debug && $status >= 500) { + $cl = is_a($operation->getClass(), ErrorResourceInterface::class, true) ? $operation->getClass() : Error::class; + $error = $cl::createFromException($exception, $status); + if (!$this->debug && $status >= 500 && method_exists($error, 'setDetail')) { $error->setDetail('Internal Server Error'); } return $error; } + + private function renderError(int $status, string $text): Response + { + return new Response(<< + + + + Error $status + +

Error $status

$text + +HTML); + } } From 4f1b56fc30e20a644edce1278f07fa76d73a7e48 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 28 Feb 2025 11:08:08 +0100 Subject: [PATCH 108/132] chore: dependency constraints (#6988) --- composer.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 7266439..45c76a8 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/metadata": "^4.1", "psr/container": "^1.0 || ^2.0", "symfony/http-kernel": "^6.4 || ^7.0" }, @@ -37,8 +37,8 @@ "symfony/web-link": "^6.4 || ^7.0", "symfony/http-foundation": "^6.4 || ^7.0", "willdurand/negotiation": "^3.1", - "api-platform/validator": "^3.4 || ^4.0", - "api-platform/serializer": "^3.4 || ^4.0" + "api-platform/validator": "^4.1", + "api-platform/serializer": "^4.1" }, "autoload": { "psr-4": { @@ -60,7 +60,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.0.x-dev", + "dev-main": "4.2.x-dev", "dev-3.4": "3.4.x-dev" }, "symfony": { From 6b310a206d290a1b402902ba297f1df501fe80b8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 28 Feb 2025 11:39:00 +0100 Subject: [PATCH 109/132] chore: dependency constraints for laravel 12 (#6989) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7266439..7d59fb5 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ }, "require-dev": { "phpunit/phpunit": "^11.2", - "symfony/web-link": "^6.4 || ^7.0", + "symfony/web-link": "^6.4 || ^7.1", "symfony/http-foundation": "^6.4 || ^7.0", "willdurand/negotiation": "^3.1", "api-platform/validator": "^3.4 || ^4.0", From d37e6b3511f7a44235e9e005625f889ca666a297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20de=20Guillebon?= Date: Tue, 18 Mar 2025 15:01:19 +0100 Subject: [PATCH 110/132] fix: allow parameter provider as object (#7032) * fix: allow parameter provider as object * remove useless phpdoc --------- Co-authored-by: soyuka --- Provider/ParameterProvider.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php index 548d364..47b325d 100644 --- a/Provider/ParameterProvider.php +++ b/Provider/ParameterProvider.php @@ -16,7 +16,6 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ParameterNotFound; -use ApiPlatform\State\ParameterProviderInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\ParameterParserTrait; use ApiPlatform\State\Util\RequestParser; @@ -83,13 +82,15 @@ public function provide(Operation $operation, array $uriVariables = [], array $c continue; } - if (!\is_string($provider) || !$this->locator->has($provider)) { - throw new ProviderNotFoundException(\sprintf('Provider "%s" not found on operation "%s"', $provider, $operation->getName())); + 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); } - /** @var ParameterProviderInterface $providerInstance */ - $providerInstance = $this->locator->get($provider); - if (($op = $providerInstance->provide($parameter, $values, $context)) instanceof Operation) { + if (($op = $provider->provide($parameter, $values, $context)) instanceof Operation) { $operation = $op; } } From d2cfc5abf4bb4b5ce99ad1daba82a549caf88f59 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 18 Mar 2025 16:36:17 +0100 Subject: [PATCH 111/132] fix: header parameter should be case insensitive (#7031) fixes #7022 --- Util/ParameterParserTrait.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Util/ParameterParserTrait.php b/Util/ParameterParserTrait.php index 6db86bf..4d67397 100644 --- a/Util/ParameterParserTrait.php +++ b/Util/ParameterParserTrait.php @@ -46,6 +46,14 @@ private function extractParameterValues(Parameter $parameter, array $values): st { $accessors = null; $key = $parameter->getKey(); + if (null === $key) { + throw new \RuntimeException('A Parameter should have a key.'); + } + + if ($parameter instanceof HeaderParameterInterface) { + $key = strtolower($key); + } + $parsedKey = explode('[:property]', $key); if (isset($parsedKey[0]) && isset($values[$parsedKey[0]])) { $key = $parsedKey[0]; From ddae2e24bdfc83b69460662581aaa9f095523a0d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 11 Apr 2025 11:32:56 +0200 Subject: [PATCH 112/132] chore: phpunit missing deprecation triggers (#7059) --- phpunit.xml.dist | 3 +++ 1 file changed, 3 insertions(+) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ab2f6b4..3a67530 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,6 +10,9 @@ + + trigger_deprecation + ./ From 0997428a4962264dbc1ce2d01be41f40f51fc9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Davaillaud?= <548656+rdavaillaud@users.noreply.github.com> Date: Mon, 14 Apr 2025 14:10:46 +0200 Subject: [PATCH 113/132] fix(state): update generic template type variable in ProviderInterface (#7083) --- ProviderInterface.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ProviderInterface.php b/ProviderInterface.php index eadcaa0..8884db5 100644 --- a/ProviderInterface.php +++ b/ProviderInterface.php @@ -20,7 +20,7 @@ /** * Retrieves data from a persistence layer. * - * @template T of object + * @template T of object|array * * @author Antoine Bluchet */ From 3f36983e1a1ec9104f6487aec8bdf58ead422a99 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 18 Apr 2025 10:39:51 +0200 Subject: [PATCH 114/132] ci: patch phpunit deprecations inside component (#7103) --- composer.json | 10 ++++++++-- phpunit.xml.dist | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index bb7466e..bdbc1aa 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "symfony/http-kernel": "^6.4 || ^7.0" }, "require-dev": { - "phpunit/phpunit": "^11.2", + "phpunit/phpunit": "11.5.x-dev", "symfony/web-link": "^6.4 || ^7.1", "symfony/http-foundation": "^6.4 || ^7.0", "willdurand/negotiation": "^3.1", @@ -80,5 +80,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/soyuka/phpunit" + } + ] } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3a67530..cf7ff9d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,7 +9,7 @@ - + trigger_deprecation From a3ba6a10443ea4e29b89eb36f0550e1a2516cc21 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 23 Apr 2025 10:40:08 +0200 Subject: [PATCH 115/132] refactor(state): state options code duplication (#7109) --- Util/StateOptionsTrait.php | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 Util/StateOptionsTrait.php diff --git a/Util/StateOptionsTrait.php b/Util/StateOptionsTrait.php new file mode 100644 index 0000000..1b27c55 --- /dev/null +++ b/Util/StateOptionsTrait.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\Util; + +use ApiPlatform\Doctrine\Odm\State\Options as ODMOptions; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Laravel\Eloquent\State\Options as EloquentOptions; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\OptionsInterface; + +/** + * @internal + */ +trait StateOptionsTrait +{ + /** + * @param Operation $operation the operation + * @param string|null $defaultClass the default class to return if no state options class is found + * @param string $optionsType an option type to test against (defaults to ApiPlatform\State\OptionsInterface) + * + * @return class-string|null + */ + public function getStateOptionsClass(Operation $operation, ?string $defaultClass = null, string $optionsType = OptionsInterface::class): ?string + { + if (!$options = $operation->getStateOptions()) { + return $defaultClass; + } + + if (!$options instanceof $optionsType) { + return $defaultClass; + } + + if (class_exists(Options::class) && $options instanceof Options && ($e = $options->getEntityClass())) { + return $e; + } + + if (class_exists(ODMOptions::class) && $options instanceof ODMOptions && ($e = $options->getDocumentClass())) { + return $e; + } + + if (class_exists(EloquentOptions::class) && $options instanceof EloquentOptions && ($e = $options->getModelClass())) { + return $e; + } + + return $defaultClass; + } +} From 4b960b0b8149d5378535c0f0bfd09eccb93d44ab Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 6 May 2025 14:34:22 +0200 Subject: [PATCH 116/132] =?UTF-8?q?Revert=20"fix(state):=20update=20generi?= =?UTF-8?q?c=20template=20type=20variable=20in=20ProviderInterfac=E2=80=A6?= =?UTF-8?q?"=20(#7094)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ProviderInterface.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ProviderInterface.php b/ProviderInterface.php index 8884db5..eadcaa0 100644 --- a/ProviderInterface.php +++ b/ProviderInterface.php @@ -20,7 +20,7 @@ /** * Retrieves data from a persistence layer. * - * @template T of object|array + * @template T of object * * @author Antoine Bluchet */ From 851ac107fd9ab5a0e4c069d4f1bc39cb79b22def Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 7 May 2025 12:11:37 +0200 Subject: [PATCH 117/132] fix(doctrine): filters schema for dates and numbers (#7131) --- ApiResource/Error.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index 0593719..d2c017d 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -104,7 +104,7 @@ public function __construct( identifier: true, writable: false, initializable: false, - schema: ['type' => 'number', 'example' => 404, 'default' => 400] + schema: ['type' => 'number', 'examples' => [404], 'default' => 400] )] private int $status, ?array $originalTrace = null, private ?string $instance = null, From 0c47360a3b6885fe02055ebeb767d3a27d3381db Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 16 May 2025 09:56:36 +0200 Subject: [PATCH 118/132] refactor(metadata): type parameters to list|string (#7134) --- Util/ParameterParserTrait.php | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/Util/ParameterParserTrait.php b/Util/ParameterParserTrait.php index 4d67397..0528231 100644 --- a/Util/ParameterParserTrait.php +++ b/Util/ParameterParserTrait.php @@ -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 @@ -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 { @@ -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]; + } + return $value; } } From 1d24edea6e46bbe93eaff21eba032589d04a240b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 20 May 2025 11:29:57 +0200 Subject: [PATCH 119/132] fix(metadata): parameter cast to array flag (#7160) --- Util/ParameterParserTrait.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Util/ParameterParserTrait.php b/Util/ParameterParserTrait.php index 0528231..26bc263 100644 --- a/Util/ParameterParserTrait.php +++ b/Util/ParameterParserTrait.php @@ -92,9 +92,11 @@ private function extractParameterValues(Parameter $parameter, array $values): st } } - if ($isCollection) { - $value = \is_array($value) ? $value : [$value]; - } elseif ($parameter instanceof HeaderParameter && \is_array($value) && array_is_list($value) && 1 === \count($value)) { + if ($isCollection && true === $parameter->getCastToArray() && !\is_array($value)) { + $value = [$value]; + } + + if (!$isCollection && $parameter instanceof HeaderParameter && \is_array($value) && array_is_list($value) && 1 === \count($value)) { $value = $value[0]; } From 694be5a11e2819279836a273d5f0ba15bf61bb43 Mon Sep 17 00:00:00 2001 From: Aleksey Polyvanyi Date: Thu, 22 May 2025 10:56:51 +0200 Subject: [PATCH 120/132] fix(state): do not expose FQCN in DeserializeProvider on PartialDenormalizationException (#7158) Co-authored-by: soyuka --- Provider/DeserializeProvider.php | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Provider/DeserializeProvider.php b/Provider/DeserializeProvider.php index be399f4..e7ad02c 100644 --- a/Provider/DeserializeProvider.php +++ b/Provider/DeserializeProvider.php @@ -100,12 +100,13 @@ public function provide(Operation $operation, array $uriVariables = [], array $c if (!$exception instanceof NotNormalizableValueException) { continue; } - $message = (new Type($exception->getExpectedTypes() ?? []))->message; + $expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes()); + $message = (new Type($expectedTypes))->message; $parameters = []; if ($exception->canUseMessageForUser()) { $parameters['hint'] = $exception->getMessage(); } - $violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $exception->getExpectedTypes() ?? [])], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR)); + $violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR)); } if (0 !== \count($violations)) { throw new ValidationException($violations); @@ -114,4 +115,22 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $data; } + + private function normalizeExpectedTypes(?array $expectedTypes = null): array + { + $normalizedTypes = []; + + foreach ($expectedTypes ?? [] as $expectedType) { + $normalizedType = $expectedType; + + if (class_exists($expectedType) || interface_exists($expectedType)) { + $classReflection = new \ReflectionClass($expectedType); + $normalizedType = $classReflection->getShortName(); + } + + $normalizedTypes[] = $normalizedType; + } + + return $normalizedTypes; + } } From 752a72e039f86b09d5f48e6595dd3282c01559ce Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 15:13:30 +0200 Subject: [PATCH 121/132] chore: bump patch dependencies --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index bdbc1aa..692759d 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.1", + "api-platform/metadata": "^4.1.11", "psr/container": "^1.0 || ^2.0", "symfony/http-kernel": "^6.4 || ^7.0" }, From 6c061d9d65e705fbcba6d5741d0e26c2ec2bc3a8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 28 May 2025 10:03:08 +0200 Subject: [PATCH 122/132] ci: prefer-lowest to avoid bumping inter components dependencies (#7169) --- composer.json | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 692759d..f1b172b 100644 --- a/composer.json +++ b/composer.json @@ -30,15 +30,16 @@ "php": ">=8.2", "api-platform/metadata": "^4.1.11", "psr/container": "^1.0 || ^2.0", - "symfony/http-kernel": "^6.4 || ^7.0" + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/serializer": "^6.4 || ^7.0", + "symfony/contracts": "^3.0" }, "require-dev": { + "api-platform/validator": "^4.1", "phpunit/phpunit": "11.5.x-dev", - "symfony/web-link": "^6.4 || ^7.1", "symfony/http-foundation": "^6.4 || ^7.0", - "willdurand/negotiation": "^3.1", - "api-platform/validator": "^4.1", - "api-platform/serializer": "^4.1" + "symfony/web-link": "^6.4 || ^7.1", + "willdurand/negotiation": "^3.1" }, "autoload": { "psr-4": { @@ -61,7 +62,8 @@ "extra": { "branch-alias": { "dev-main": "4.2.x-dev", - "dev-3.4": "3.4.x-dev" + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev" }, "symfony": { "require": "^6.4 || ^7.0" @@ -86,5 +88,6 @@ "type": "vcs", "url": "https://github.com/soyuka/phpunit" } - ] + ], + "version": "4.1.12" } From b8a832c049db2ceff5c1d1c741322dd05d5a7cb3 Mon Sep 17 00:00:00 2001 From: Maxime Helias Date: Mon, 2 Jun 2025 16:15:04 +0200 Subject: [PATCH 123/132] chore: remove 3.4 deprecation (#7188) --- Provider/BackedEnumProvider.php | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Provider/BackedEnumProvider.php b/Provider/BackedEnumProvider.php index d5641fb..e973be6 100644 --- a/Provider/BackedEnumProvider.php +++ b/Provider/BackedEnumProvider.php @@ -56,14 +56,10 @@ private function resolveEnum(string $resourceClass, string|int $id): ?\BackedEnu if (!is_numeric($id)) { return null; } - $enum = $resourceClass::tryFrom((int) $id); - } else { - $enum = $resourceClass::tryFrom($id); - } - // @deprecated enums will be indexable only by value in 4.0 - $enum ??= array_reduce($resourceClass::cases(), static fn ($c, \BackedEnum $case) => $id === $case->name ? $case : $c, null); + return $resourceClass::tryFrom((int) $id); + } - return $enum; + return $resourceClass::tryFrom($id); } } From 70de36a8c2aa6785a98f8acb12aa228de03a433e Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Jun 2025 16:02:15 +0200 Subject: [PATCH 124/132] fix: bump composer.json version nodes --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f1b172b..e922b9c 100644 --- a/composer.json +++ b/composer.json @@ -89,5 +89,5 @@ "url": "https://github.com/soyuka/phpunit" } ], - "version": "4.1.12" + "version": "4.1.14" } From 98f9c198d39f8b1d16ec1ae63200411981c52b11 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Jun 2025 16:18:03 +0200 Subject: [PATCH 125/132] chore: missing "v" prefix in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e922b9c..950d69d 100644 --- a/composer.json +++ b/composer.json @@ -89,5 +89,5 @@ "url": "https://github.com/soyuka/phpunit" } ], - "version": "4.1.14" + "version": "v4.1.15" } From 21236878c43bba4958c51e4bce669fc13d08e653 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Jun 2025 16:56:47 +0200 Subject: [PATCH 126/132] ci: remove version from composer to avoid release side effects (#7196) --- composer.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 950d69d..4e836fe 100644 --- a/composer.json +++ b/composer.json @@ -88,6 +88,5 @@ "type": "vcs", "url": "https://github.com/soyuka/phpunit" } - ], - "version": "v4.1.15" + ] } From b6c9510a974bda5a89af43266b7a7d829e0de549 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 13 Jun 2025 14:04:07 +0200 Subject: [PATCH 127/132] refactor(state): merge parameter and link security (#7200) --- .../IriConverterParameterProvider.php | 58 ++++++++ .../ReadLinkParameterProvider.php | 130 ++++++++++++++++++ Provider/ParameterProvider.php | 110 +++++++++++---- Provider/SecurityParameterProvider.php | 69 +++++++++- .../SecurityParameterProviderTest.php | 89 ++++++++++++ 5 files changed, 423 insertions(+), 33 deletions(-) create mode 100644 ParameterProvider/IriConverterParameterProvider.php create mode 100644 ParameterProvider/ReadLinkParameterProvider.php create mode 100644 Tests/Provider/SecurityParameterProviderTest.php diff --git a/ParameterProvider/IriConverterParameterProvider.php b/ParameterProvider/IriConverterParameterProvider.php new file mode 100644 index 0000000..2f817f8 --- /dev/null +++ b/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; + } + + $iriConverterContext = ['fetch_data' => $parameter->getExtraProperties()['fetch_data'] ?? false]; + + if (\is_array($value)) { + $entities = []; + foreach ($value as $v) { + $entities[] = $this->iriConverter->getResourceFromIri($v, $iriConverterContext); + } + + $parameter->setValue($entities); + + return $operation; + } + + $parameter->setValue($this->iriConverter->getResourceFromIri($value, $iriConverterContext)); + + return $operation; + } +} diff --git a/ParameterProvider/ReadLinkParameterProvider.php b/ParameterProvider/ReadLinkParameterProvider.php new file mode 100644 index 0000000..e96c40f --- /dev/null +++ b/ParameterProvider/ReadLinkParameterProvider.php @@ -0,0 +1,130 @@ + + * + * 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)) { + $uriVariables = []; + + foreach ($links as $key => $link) { + if (!\is_string($key)) { + $key = $link->getParameterName() ?? $extraProperties['uri_variable'] ?? $link->getFromProperty(); + } + + if (!$key || !\is_string($key)) { + continue; + } + + $uriVariables[$key] = $value; + } + + return $uriVariables; + } + + return $value; + } +} diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php index bc4fd6f..bdabbaa 100644 --- a/Provider/ParameterProvider.php +++ b/Provider/ParameterProvider.php @@ -15,9 +15,11 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; 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 +67,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 = $this->handlePathParameters($operation, $uriVariables, $context); + } + + $request?->attributes->set('_api_operation', $operation); + $context['operation'] = $operation; + + return $this->decorated?->provide($operation, $uriVariables, $context); + } + + /** + * TODO: uriVariables could be a Parameters instance, it'd make things easier. + * + * @param array $uriVariables + * @param array $context + */ + private function handlePathParameters(HttpOperation $operation, array $uriVariables, array $context): HttpOperation + { + foreach ($operation->getUriVariables() ?? [] as $key => $uriVariable) { + $uriVariable = $uriVariable->withKey($key); + 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 (!\array_key_exists($key, $uriVariables)) { 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/Provider/SecurityParameterProvider.php b/Provider/SecurityParameterProvider.php index 9e9c649..238908b 100644 --- a/Provider/SecurityParameterProvider.php +++ b/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/Provider/SecurityParameterProviderTest.php b/Tests/Provider/SecurityParameterProviderTest.php new file mode 100644 index 0000000..80797ef --- /dev/null +++ b/Tests/Provider/SecurityParameterProviderTest.php @@ -0,0 +1,89 @@ + + * + * 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\Tests\Provider; + +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\State\Provider\SecurityParameterProvider; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; + +final class SecurityParameterProviderTest extends TestCase +{ + public function testIsGrantedLink(): void + { + $obj = new \stdClass(); + $barObj = new \stdClass(); + $operation = new GetCollection(uriVariables: [ + 'barId' => new Link(toProperty: 'bar', fromClass: 'Bar', security: 'is_granted("some_voter", "bar")'), + ], class: 'Foo'); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($obj); + $request = $this->createMock(Request::class); + $parameterBag = new ParameterBag(); + $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, 'barId' => 1, 'operation' => $operation])->willReturn(true); + $accessChecker = new SecurityParameterProvider($decorated, $resourceAccessChecker); + $accessChecker->provide($operation, ['barId' => 1], ['request' => $request]); + } + + public function testIsNotGrantedLink(): void + { + $this->expectException(AccessDeniedHttpException::class); + + $obj = new \stdClass(); + $barObj = new \stdClass(); + $operation = new GetCollection(uriVariables: [ + 'barId' => new Link(toProperty: 'bar', fromClass: 'Bar', security: 'is_granted("some_voter", "bar")'), + ], class: 'Foo'); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($obj); + $request = $this->createMock(Request::class); + $parameterBag = new ParameterBag(); + $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, 'barId' => 1, 'operation' => $operation])->willReturn(false); + $accessChecker = new SecurityParameterProvider($decorated, $resourceAccessChecker); + $accessChecker->provide($operation, ['barId' => 1], ['request' => $request]); + } + + public function testSecurityMessageLink(): void + { + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not admin.'); + + $obj = new \stdClass(); + $barObj = new \stdClass(); + $operation = new GetCollection(uriVariables: [ + 'barId' => new Link(toProperty: 'bar', fromClass: 'Bar', security: 'is_granted("some_voter", "bar")', securityMessage: 'You are not admin.'), + ], class: 'Foo'); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->method('provide')->willReturn($obj); + $request = $this->createMock(Request::class); + $parameterBag = new ParameterBag(); + $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, 'barId' => 1, 'operation' => $operation])->willReturn(false); + $accessChecker = new SecurityParameterProvider($decorated, $resourceAccessChecker); + $accessChecker->provide($operation, ['barId' => 1], ['request' => $request]); + } +} From aa36eabb70be8574750f6e5ecfc9073f06b4baea Mon Sep 17 00:00:00 2001 From: Gregor Harlan Date: Mon, 30 Jun 2025 18:31:44 +0200 Subject: [PATCH 128/132] fix(state): depend only on translation contracts (#7262) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4e836fe..6fb4675 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "psr/container": "^1.0 || ^2.0", "symfony/http-kernel": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", - "symfony/contracts": "^3.0" + "symfony/translation-contracts": "^3.0" }, "require-dev": { "api-platform/validator": "^4.1", From 20135ccd48e95dfa4c0d7b274854b7454979e1c2 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 3 Jul 2025 11:36:47 +0200 Subject: [PATCH 129/132] fix(state): error xml format output (#7273) --- ApiResource/Error.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index d2c017d..82b2054 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -36,7 +36,10 @@ errors: [], name: '_api_errors_problem', routeName: '_api_errors', - outputFormats: ['json' => ['application/problem+json', 'application/json']], + outputFormats: [ + 'json' => ['application/problem+json', 'application/json'], + 'xml' => ['application/xml', 'text/xml'], + ], hideHydraOperation: true, normalizationContext: [ SchemaFactory::OPENAPI_DEFINITION_NAME => '', From 8ad63f3f7423a8c9b78191a456e9d422e30f6d02 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 3 Jul 2025 11:40:53 +0200 Subject: [PATCH 130/132] fix(validator): error xml format output --- ApiResource/Error.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index 82b2054..0cddd0c 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -84,10 +84,16 @@ 'jsonapi' => ['application/vnd.api+json'], 'jsonld' => ['application/ld+json'], 'json' => ['application/problem+json', 'application/json'], + 'xml' => ['application/xml', 'text/xml'], ], ), ], - outputFormats: ['jsonapi' => ['application/vnd.api+json'], 'jsonld' => ['application/ld+json'], 'json' => ['application/problem+json', 'application/json']], + outputFormats: [ + 'jsonapi' => ['application/vnd.api+json'], + 'jsonld' => ['application/ld+json'], + 'json' => ['application/problem+json', 'application/json'], + 'xml' => ['application/xml', 'text/xml'], + ], provider: 'api_platform.state.error_provider', graphQlOperations: [], description: 'A representation of common errors.', From 8e6860ca1c581a36541538079c7c411bdcc09d32 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:06:55 +0200 Subject: [PATCH 131/132] feat(state): cast parameter values to validate with the Type constraint (#7240) --- Parameter/ValueCaster.php | 59 +++++++++++++++++++++++++++++++++++ Util/ParameterParserTrait.php | 13 +++++--- composer.json | 2 +- 3 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 Parameter/ValueCaster.php diff --git a/Parameter/ValueCaster.php b/Parameter/ValueCaster.php new file mode 100644 index 0000000..d876f44 --- /dev/null +++ b/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/Util/ParameterParserTrait.php b/Util/ParameterParserTrait.php index 26bc263..a23ecbe 100644 --- a/Util/ParameterParserTrait.php +++ b/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/composer.json b/composer.json index 6fb4675..f788152 100644 --- a/composer.json +++ b/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", From 056b07285cdc904984fb44c2614f7df8f4620a95 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 16 Jul 2025 16:01:52 +0200 Subject: [PATCH 132/132] fix: json formatted resource should not get xml errors #7287 (#7297) --- ApiResource/Error.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/ApiResource/Error.php b/ApiResource/Error.php index 0cddd0c..68047e5 100644 --- a/ApiResource/Error.php +++ b/ApiResource/Error.php @@ -38,7 +38,6 @@ routeName: '_api_errors', outputFormats: [ 'json' => ['application/problem+json', 'application/json'], - 'xml' => ['application/xml', 'text/xml'], ], hideHydraOperation: true, normalizationContext: [ @@ -75,6 +74,21 @@ 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], ], ), + new Operation( + errors: [], + name: '_api_errors_xml', + routeName: '_api_errors', + outputFormats: [ + 'xml' => ['application/xml', 'text/xml'], + ], + hideHydraOperation: true, + normalizationContext: [ + SchemaFactory::OPENAPI_DEFINITION_NAME => '', + 'groups' => ['jsonproblem'], + 'skip_null_values' => true, + 'ignored_attributes' => ['trace', 'file', 'line', 'code', 'message', 'traceAsString', 'previous'], + ], + ), new Operation( name: '_api_errors', hideHydraOperation: true,