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
+
+
-