diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..801f208 --- /dev/null +++ b/.gitattributes @@ -0,0 +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!" diff --git a/ApiResource/Error.php b/ApiResource/Error.php new file mode 100644 index 0000000..43f65a2 --- /dev/null +++ b/ApiResource/Error.php @@ -0,0 +1,204 @@ + + * + * 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\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', + routeName: 'api_errors', + outputFormats: ['json' => ['application/problem+json']], + normalizationContext: [ + 'groups' => ['jsonproblem'], + 'skip_null_values' => true, + 'rfc_7807_compliant_errors' => true, + ], + ), + new Operation( + name: '_api_errors_hydra', + routeName: 'api_errors', + outputFormats: ['jsonld' => ['application/problem+json']], + 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')], + ), + new Operation( + name: '_api_errors_jsonapi', + routeName: 'api_errors', + outputFormats: ['jsonapi' => ['application/vnd.api+json']], + normalizationContext: [ + 'groups' => ['jsonapi'], + 'skip_null_values' => true, + 'rfc_7807_compliant_errors' => true, + ], + ), + new Operation( + name: '_api_errors', + routeName: 'api_errors' + ), + ], + 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] + #[ApiProperty(readable: false)] + public function getHeaders(): array + { + return $this->headers; + } + + #[Ignore] + #[ApiProperty(readable: false)] + 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/CallableProcessor.php b/CallableProcessor.php index eff8f56..b75f851 100644 --- a/CallableProcessor.php +++ b/CallableProcessor.php @@ -13,13 +13,19 @@ namespace ApiPlatform\State; -use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\Exception\RuntimeException; 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) + public function __construct(private readonly ?ContainerInterface $locator = null) { } @@ -29,7 +35,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)) { @@ -37,10 +43,10 @@ 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 */ + /** @var ProcessorInterface $processorInstance */ $processorInstance = $this->locator->get($processor); return $processorInstance->process($data, $operation, $uriVariables, $context); diff --git a/CallableProvider.php b/CallableProvider.php index 80c548b..fa48d86 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,9 +32,9 @@ 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())); + 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 f8e3bbe..d10a1d6 100644 --- a/CreateProvider.php +++ b/CreateProvider.php @@ -13,12 +13,11 @@ namespace ApiPlatform\State; -use ApiPlatform\Exception\RuntimeException; -use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HttpOperation; -use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; -use ApiPlatform\Metadata\Post; +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; @@ -31,35 +30,37 @@ * @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(); } 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); } $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, $context); + } catch (ProviderNotFoundException) { + $relation = null; } - - $relation = $this->decorated->provide(new Get(uriVariables: $relationUriVariables, class: $relationClass), $uriVariables); if (!$relation) { throw new NotFoundHttpException('Not Found'); } @@ -67,8 +68,9 @@ 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; $this->propertyAccessor->setValue($resource, $property, $relation); 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/ObjectProvider.php b/ObjectProvider.php index dd83754..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; /** @@ -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/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/Pagination.php b/Pagination/Pagination.php index e680a47..9d07063 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; /** @@ -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/Pagination/TraversablePaginator.php b/Pagination/TraversablePaginator.php index 8749e3b..abad26a 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) { @@ -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; } @@ -78,4 +82,12 @@ public function getIterator(): \Traversable { return $this->traversable; } + + /** + * {@inheritdoc} + */ + public function hasNextPage(): bool + { + return $this->getCurrentPage() < $this->getLastPage(); + } } diff --git a/ParameterNotFound.php b/ParameterNotFound.php new file mode 100644 index 0000000..618ea3e --- /dev/null +++ b/ParameterNotFound.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; + +/** + * @experimental + */ +final class ParameterNotFound +{ +} 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/Processor/AddLinkHeaderProcessor.php b/Processor/AddLinkHeaderProcessor.php new file mode 100644 index 0000000..2f58e92 --- /dev/null +++ b/Processor/AddLinkHeaderProcessor.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\Processor; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +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()) + { + } + + 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('_api_platform_links'); + if ($this->serializer && ($links = $linksProvider?->getLinks())) { + $response->headers->set('Link', $this->serializer->serialize($links)); + } + + return $response; + } +} diff --git a/Processor/RespondProcessor.php b/Processor/RespondProcessor.php new file mode 100644 index 0000000..15e9c03 --- /dev/null +++ b/Processor/RespondProcessor.php @@ -0,0 +1,129 @@ + + * + * 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\Exception\HttpExceptionInterface; +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; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Metadata\Util\CloneTrait; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; + +/** + * 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, + private readonly ?OperationMetadataFactoryInterface $operationMetadataFactory = 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', + ]; + + $exception = $request->attributes->get('exception'); + if (($exception instanceof HttpExceptionInterface || $exception instanceof SymfonyHttpExceptionInterface) && $exceptionHeaders = $exception->getHeaders()) { + $headers = array_merge($headers, $exceptionHeaders); + } + + if ($operationHeaders = $operation->getHeaders()) { + $headers = array_merge($headers, $operationHeaders); + } + + $status = $operation->getStatus(); + + if ($sunset = $operation->getSunset()) { + $headers['Sunset'] = (new \DateTimeImmutable($sunset))->format(\DateTimeInterface::RFC1123); + } + + if ($acceptPatch = $operation->getAcceptPatch()) { + $headers['Accept-Patch'] = $acceptPatch; + } + + $method = $request->getMethod(); + $originalData = $context['original_data'] ?? null; + + $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 + && (($operation->getExtraProperties()['is_alternate_resource_metadata'] ?? false) || ($operation->getExtraProperties()['canonical_uri_template'] ?? null)) + ) { + $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; + } + } + + $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 ((201 === $status || (300 <= $status && $status < 400)) && 'POST' === $method && !isset($headers['Location'])) { + $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..088eb3e --- /dev/null +++ b/Processor/SerializeProcessor.php @@ -0,0 +1,91 @@ + + * + * 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 ApiPlatform\State\ResourceList; +use ApiPlatform\State\SerializerContextBuilderInterface; +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. + * + * @template T1 + * @template T2 + * + * @implements ProcessorInterface + * + * @author Kévin Dunglas + */ +final class SerializeProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface|null $processor + */ + 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 ? $this->processor->process($data, $operation, $uriVariables, $context) : $data; + } + + // @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' => $class, + 'operation' => $operation, + ]); + + $serializerContext['uri_variables'] = $uriVariables; + + if (isset($serializerContext['output']) && \array_key_exists('class', $serializerContext['output']) && null === $serializerContext['output']['class']) { + return $this->processor ? $this->processor->process(null, $operation, $uriVariables, $context) : null; + } + + $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 ? $this->processor->process($serialized, $operation, $uriVariables, $context) : $serialized; + } +} diff --git a/Processor/WriteProcessor.php b/Processor/WriteProcessor.php new file mode 100644 index 0000000..ed37837 --- /dev/null +++ b/Processor/WriteProcessor.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\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. + * + * @template T1 + * @template T2 + * + * @implements ProcessorInterface + * + * @author Kévin Dunglas + * @author Baptiste Meyer + */ +final class WriteProcessor implements ProcessorInterface +{ + use ClassInfoTrait; + + /** + * @param ProcessorInterface $processor + * @param ProcessorInterface $callableProcessor + */ + 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 ? $this->processor->process($data, $operation, $uriVariables, $context) : $data; + } + + $data = $this->callableProcessor->process($data, $operation, $uriVariables, $context); + + return $this->processor ? $this->processor->process($data, $operation, $uriVariables, $context) : $data; + } +} diff --git a/ProcessorInterface.php b/ProcessorInterface.php index 123f970..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 { /** - * Processes the state. + * Handles the state. * - * @param array $uriVariables - * @param array $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/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; + } +} diff --git a/Provider/ContentNegotiationProvider.php b/Provider/ContentNegotiationProvider.php new file mode 100644 index 0000000..0ffd426 --- /dev/null +++ b/Provider/ContentNegotiationProvider.php @@ -0,0 +1,124 @@ + + * + * 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 = null, ?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 + { + $contentType = $request->headers->get('CONTENT_TYPE'); + if (null === $contentType || '' === $contentType) { + 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..1e8a683 --- /dev/null +++ b/Provider/DeserializeProvider.php @@ -0,0 +1,118 @@ + + * + * 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 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; +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 LegacySerializerContextBuilderInterface|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 + { + // We need request content + if (!$operation instanceof HttpOperation || !($request = $context['request'] ?? null)) { + 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; + } + + $contentType = $request->headers->get('CONTENT_TYPE'); + if (null === $contentType || '' === $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.'); + } + + $method = $operation->getMethod(); + + 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(), $serializerContext['deserializer_type'] ?? $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) { + 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) Type::INVALID_TYPE_ERROR)); + } + if (0 !== \count($violations)) { + throw new ValidationException($violations); + } + } + + return $data; + } +} diff --git a/Provider/ParameterProvider.php b/Provider/ParameterProvider.php new file mode 100644 index 0000000..9a905d9 --- /dev/null +++ b/Provider/ParameterProvider.php @@ -0,0 +1,101 @@ + + * + * 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\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; +use Psr\Container\ContainerInterface; + +/** + * 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 +{ + use ParameterParserTrait; + + 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(); + foreach ($parameters ?? [] as $parameter) { + $values = $this->getParameterValues($parameter, $request, $context); + $value = $this->extractParameterValues($parameter, $values); + + if (($default = $parameter->getSchema()['default'] ?? false) && ($value instanceof ParameterNotFound || !$value)) { + $value = $default; + } + + if ($value instanceof ParameterNotFound) { + continue; + } + + $parameters->add($parameter->getKey(), $parameter = $parameter->withExtraProperties( + $parameter->getExtraProperties() + ['_api_values' => $value] + )); + + if (null === ($provider = $parameter->getProvider())) { + continue; + } + + if (\is_callable($provider)) { + if (($op = $provider($parameter, $values, $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, $values, $context)) instanceof Operation) { + $operation = $op; + } + } + + if ($parameters) { + $operation = $operation->withParameters($parameters); + } + $request?->attributes->set('_api_operation', $operation); + $context['operation'] = $operation; + + return $this->decorated?->provide($operation, $uriVariables, $context); + } +} diff --git a/Provider/ReadProvider.php b/Provider/ReadProvider.php new file mode 100644 index 0000000..b3450dc --- /dev/null +++ b/Provider/ReadProvider.php @@ -0,0 +1,102 @@ + + * + * 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\Metadata\Util\CloneTrait; +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; +use Psr\Log\LoggerInterface; +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 LegacySerializerContextBuilderInterface|SerializerContextBuilderInterface|null $serializerContextBuilder = null, + private readonly ?LoggerInterface $logger = 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()) { + return null; + } + + if (null === ($filters = $request?->attributes->get('_api_filters')) && $request) { + $queryString = RequestParser::getQueryString($request); + $filters = $queryString ? RequestParser::parseRequestParams($queryString) : null; + } + + if ($filters) { + $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' => $resourceClass, + 'operation' => $operation, + ]); + $request->attributes->set('_api_normalization_context', $normalizationContext); + } + + 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; + } + + if ( + null === $data + && 'POST' !== $operation->getMethod() + && ('PUT' !== $operation->getMethod() + || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) + ) + ) { + throw new NotFoundHttpException('Not Found', $e ?? null); + } + + $request?->attributes->set('data', $data); + $request?->attributes->set('previous_data', $this->clone($data)); + + return $data; + } +} diff --git a/Provider/SecurityParameterProvider.php b/Provider/SecurityParameterProvider.php new file mode 100644 index 0000000..7f06bc7 --- /dev/null +++ b/Provider/SecurityParameterProvider.php @@ -0,0 +1,60 @@ + + * + * 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\ParameterNotFound; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\Util\ParameterParserTrait; +use ApiPlatform\Symfony\Security\Exception\AccessDeniedException; +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, private readonly ?ResourceAccessCheckerInterface $resourceAccessChecker = null) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $body = $this->decorated->provide($operation, $uriVariables, $context); + $request = $context['request'] ?? null; + + $operation = $request?->attributes->get('_api_operation') ?? $operation; + foreach ($operation->getParameters() ?? [] as $parameter) { + if (null === $security = $parameter->getSecurity()) { + continue; + } + + if (($v = $parameter->getValue()) instanceof ParameterNotFound) { + continue; + } + + $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 $body; + } +} diff --git a/ProviderInterface.php b/ProviderInterface.php index 41f2241..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 $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; } 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). 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..3bb88bf --- /dev/null +++ b/SerializerContextBuilderInterface.php @@ -0,0 +1,56 @@ + + * + * 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, + * deserializer_type?: string, + * } + */ + public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array; +} diff --git a/Tests/CallableProcessorTest.php b/Tests/CallableProcessorTest.php new file mode 100644 index 0000000..43733ff --- /dev/null +++ b/Tests/CallableProcessorTest.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\Tests; + +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\CallableProcessor; +use ApiPlatform\State\ProcessorInterface; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +class CallableProcessorTest extends TestCase +{ + public function testNoProcessor(): void + { + $operation = new Get(name: 'hello'); + $data = new \stdClass(); + $this->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..5b51c60 --- /dev/null +++ b/Tests/CallableProviderTest.php @@ -0,0 +1,49 @@ + + * + * 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\CallableProvider; +use ApiPlatform\State\Exception\ProviderNotFoundException; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +class CallableProviderTest extends TestCase +{ + public function testNoProvider(): void + { + $operation = new Get(name: 'hello'); + $this->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/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()); + } +} diff --git a/Tests/ParameterProviderTest.php b/Tests/ParameterProviderTest.php new file mode 100644 index 0000000..66d5d7a --- /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(['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 + { + static::assertTrue(true); + } + + public static function shouldNotBeCalled(): void + { + static::assertTrue(false); // @phpstan-ignore-line + } +} 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)); + } +} 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/Tests/Provider/ReadProviderTest.php b/Tests/Provider/ReadProviderTest.php new file mode 100644 index 0000000..92cfce5 --- /dev/null +++ b/Tests/Provider/ReadProviderTest.php @@ -0,0 +1,64 @@ + + * + * 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\GetCollection; +use ApiPlatform\State\Provider\ReadProvider; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\SerializerContextBuilderInterface; +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')); + } + + 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')); + } + + 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/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/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/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/UriVariablesResolverTrait.php b/UriVariablesResolverTrait.php index 0e618f6..68b2d06 100644 --- a/UriVariablesResolverTrait.php +++ b/UriVariablesResolverTrait.php @@ -13,19 +13,20 @@ namespace ApiPlatform\State; -use ApiPlatform\Api\CompositeIdentifierParser; -use ApiPlatform\Api\UriVariablesConverterInterface; -use ApiPlatform\Exception\InvalidIdentifierException; +use ApiPlatform\Api\UriVariablesConverterInterface as LegacyUriVariablesConverterInterface; +use ApiPlatform\Metadata\Exception\InvalidIdentifierException; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\UriVariablesConverterInterface; +use ApiPlatform\Metadata\Util\CompositeIdentifierParser; trait UriVariablesResolverTrait { - private ?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 = []; @@ -37,7 +38,7 @@ private function getOperationUriVariables(HttpOperation $operation = null, array 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 +48,7 @@ private function getOperationUriVariables(HttpOperation $operation = null, array $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) { 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/ParameterParserTrait.php b/Util/ParameterParserTrait.php new file mode 100644 index 0000000..6db86bf --- /dev/null +++ b/Util/ParameterParserTrait.php @@ -0,0 +1,74 @@ + + * + * 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 ApiPlatform\State\ParameterNotFound; +use Symfony\Component\HttpFoundation\Request; + +/** + * @internal + */ +trait ParameterParserTrait +{ + /** + * @param array $context + * + * @return array + */ + private function getParameterValues(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')) ?? []; + } + + return $context['args'] ?? []; + } + + /** + * @param array $values + * + * @return array|ParameterNotFound|array + */ + private function extractParameterValues(Parameter $parameter, array $values): string|ParameterNotFound|array + { + $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); + $key = array_shift($matches[0]); + $accessors = $matches[0]; + } + + $value = $values[$key] ?? new ParameterNotFound(); + if (!$accessors) { + return $value; + } + + foreach ($accessors as $accessor) { + if (\is_array($value) && isset($value[$accessor])) { + $value = $value[$accessor]; + } else { + $value = new ParameterNotFound(); + continue; + } + } + + return $value; + } +} 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/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 271c9b9..c813028 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", @@ -27,12 +27,18 @@ } ], "require": { - "php": ">=8.1" + "php": ">=8.1", + "api-platform/metadata": "^3.4 || ^4.0", + "psr/container": "^1.0 || ^2.0", + "symfony/http-kernel": "^6.4 || ^7.0" }, "require-dev": { - "phpspec/prophecy-phpunit": "^2.0", - "symfony/phpunit-bridge": "^6.1", - "sebastian/comparator": "<5.0" + "phpunit/phpunit": "^10.3", + "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" }, "autoload": { "psr-4": { @@ -54,10 +60,25 @@ }, "extra": { "branch-alias": { - "dev-main": "3.2.x-dev" + "dev-main": "4.0.x-dev", + "dev-3.4": "3.4.x-dev" }, "symfony": { - "require": "^6.1" + "require": "^6.4 || ^7.1" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" } + }, + "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.", + "api-platform/serializer": "To use API Platform serializer.", + "api-platform/validator": "To use API Platform validation." + }, + "scripts": { + "test": "./vendor/bin/phpunit" } } 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 + + -