From 9df9018920a9aa1560b2ebf276c304eb67f4927c Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 25 Jul 2025 21:25:13 +0200 Subject: [PATCH 1/2] feat: json streamer --- composer.json | 6 +- src/Hydra/Collection.php | 43 +++ src/Hydra/IriTemplate.php | 32 ++ src/Hydra/IriTemplateMapping.php | 31 ++ src/Hydra/PartialCollectionView.php | 36 +++ .../CollectionFiltersNormalizer.php | 103 ++---- .../PartialCollectionViewNormalizer.php | 61 ++-- src/Hydra/State/JsonStreamerProcessor.php | 120 +++++++ src/Hydra/State/JsonStreamerProvider.php | 47 +++ .../State/Util/PaginationHelperTrait.php | 89 ++++++ src/Hydra/State/Util/SearchHelperTrait.php | 126 ++++++++ .../PartialCollectionViewNormalizerTest.php | 5 +- .../ContextValueTransformer.php | 41 +++ .../ValueTransformer/IriValueTransformer.php | 52 +++ .../ValueTransformer/TypeValueTransformer.php | 40 +++ .../WritePropertyMetadataLoader.php | 74 +++++ src/Metadata/ApiResource.php | 2 + src/Metadata/Delete.php | 2 + src/Metadata/Error.php | 2 + src/Metadata/ErrorResource.php | 2 + .../Extractor/XmlResourceExtractor.php | 1 + .../Extractor/YamlResourceExtractor.php | 1 + src/Metadata/Extractor/schema/resources.xsd | 1 + src/Metadata/Get.php | 2 + src/Metadata/GetCollection.php | 2 + src/Metadata/HttpOperation.php | 2 + src/Metadata/Metadata.php | 14 + src/Metadata/Operation.php | 2 + src/Metadata/Patch.php | 2 + src/Metadata/Post.php | 2 + src/Metadata/Put.php | 2 + .../Extractor/Adapter/XmlResourceAdapter.php | 6 + .../Tests/Extractor/Adapter/resources.xml | 2 +- .../Tests/Extractor/Adapter/resources.yaml | 1 + .../ResourceMetadataCompatibilityTest.php | 7 + .../Tests/Extractor/XmlExtractorTest.php | 4 + .../Tests/Extractor/YamlExtractorTest.php | 6 + .../State/JsonStreamerProcessor.php | 91 ++++++ src/Serializer/State/JsonStreamerProvider.php | 47 +++ src/State/Processor/RespondProcessor.php | 112 +------ src/State/Processor/SerializeProcessor.php | 7 +- src/State/Provider/DeserializeProvider.php | 2 +- src/State/Util/HttpResponseHeadersTrait.php | 127 ++++++++ src/State/Util/HttpResponseStatusTrait.php | 56 ++++ .../ApiPlatformExtension.php | 25 +- .../DependencyInjection/Configuration.php | 2 + .../Resources/config/json_streamer/hydra.xml | 54 ++++ .../Resources/config/json_streamer/json.xml | 19 ++ .../ApiPlatformExtensionTest.php | 1 + .../TestBundle/Entity/JsonStreamResource.php | 53 +++ tests/Functional/JsonLdTest.php | 4 +- tests/Functional/JsonStreamerTest.php | 302 ++++++++++++++++++ .../DependencyInjection/ConfigurationTest.php | 2 + 53 files changed, 1652 insertions(+), 223 deletions(-) create mode 100644 src/Hydra/Collection.php create mode 100644 src/Hydra/IriTemplate.php create mode 100644 src/Hydra/IriTemplateMapping.php create mode 100644 src/Hydra/PartialCollectionView.php create mode 100644 src/Hydra/State/JsonStreamerProcessor.php create mode 100644 src/Hydra/State/JsonStreamerProvider.php create mode 100644 src/Hydra/State/Util/PaginationHelperTrait.php create mode 100644 src/Hydra/State/Util/SearchHelperTrait.php create mode 100644 src/JsonLd/JsonStreamer/ValueTransformer/ContextValueTransformer.php create mode 100644 src/JsonLd/JsonStreamer/ValueTransformer/IriValueTransformer.php create mode 100644 src/JsonLd/JsonStreamer/ValueTransformer/TypeValueTransformer.php create mode 100644 src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php create mode 100644 src/Serializer/State/JsonStreamerProcessor.php create mode 100644 src/Serializer/State/JsonStreamerProvider.php create mode 100644 src/State/Util/HttpResponseHeadersTrait.php create mode 100644 src/State/Util/HttpResponseStatusTrait.php create mode 100644 src/Symfony/Bundle/Resources/config/json_streamer/hydra.xml create mode 100644 src/Symfony/Bundle/Resources/config/json_streamer/json.xml create mode 100644 tests/Fixtures/TestBundle/Entity/JsonStreamResource.php create mode 100644 tests/Functional/JsonStreamerTest.php diff --git a/composer.json b/composer.json index 60e63911a5f..cc07d4b3720 100644 --- a/composer.json +++ b/composer.json @@ -116,7 +116,7 @@ "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/translation-contracts": "^3.3", - "symfony/type-info": "^7.3", + "symfony/type-info": "7.4.x-dev", "symfony/validator": "^6.4 || ^7.1", "symfony/web-link": "^6.4 || ^7.1", "willdurand/negotiation": "^3.1" @@ -176,9 +176,10 @@ "symfony/expression-language": "^6.4 || ^7.0", "symfony/finder": "^6.4 || ^7.0", "symfony/form": "^6.4 || ^7.0", - "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/framework-bundle": "7.4.x-dev", "symfony/http-client": "^6.4 || ^7.0", "symfony/intl": "^6.4 || ^7.0", + "symfony/json-streamer": "7.4.x-dev", "symfony/maker-bundle": "^1.24", "symfony/mercure-bundle": "*", "symfony/messenger": "^6.4 || ^7.0", @@ -212,6 +213,7 @@ "symfony/uid": "To support Symfony UUID/ULID identifiers.", "symfony/messenger": "To support messenger integration.", "symfony/web-profiler-bundle": "To use the data collector.", + "symfony/json-streamer": "To use the JSON Streamer component.", "webonyx/graphql-php": "To support GraphQL." }, "type": "library", diff --git a/src/Hydra/Collection.php b/src/Hydra/Collection.php new file mode 100644 index 00000000000..98fb833b912 --- /dev/null +++ b/src/Hydra/Collection.php @@ -0,0 +1,43 @@ + + * + * 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\Hydra; + +use Symfony\Component\JsonStreamer\Attribute\StreamedName; + +/** + * @template T + * + * @internal + */ +class Collection +{ + #[StreamedName('@context')] + public string $context = 'VIRTUAL'; + + #[StreamedName('@id')] + public string $id = 'VIRTUAL'; + + #[StreamedName('@type')] + public string $type = 'Collection'; + + public float $totalItems; + + public ?IriTemplate $search = null; + public ?PartialCollectionView $view = null; + + /** + * @var list + */ + public iterable $member; +} diff --git a/src/Hydra/IriTemplate.php b/src/Hydra/IriTemplate.php new file mode 100644 index 00000000000..78cb9782cc8 --- /dev/null +++ b/src/Hydra/IriTemplate.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\Hydra; + +use Symfony\Component\JsonStreamer\Attribute\StreamedName; +use Symfony\Component\Serializer\Annotation\SerializedName; + +final class IriTemplate +{ + #[StreamedName('@type')] + #[SerializedName('@type')] + public string $type = 'IriTemplate'; + + public function __construct( + public string $variableRepresentation, + /** @var list */ + public array $mapping = [], + public ?string $template = null, + ) { + } +} diff --git a/src/Hydra/IriTemplateMapping.php b/src/Hydra/IriTemplateMapping.php new file mode 100644 index 00000000000..9cbb7502752 --- /dev/null +++ b/src/Hydra/IriTemplateMapping.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\Hydra; + +use Symfony\Component\JsonStreamer\Attribute\StreamedName; +use Symfony\Component\Serializer\Annotation\SerializedName; + +class IriTemplateMapping +{ + #[StreamedName('@type')] + #[SerializedName('@type')] + public string $type = 'IriTemplateMapping'; + + public function __construct( + public string $variable, + public ?string $property, + public bool $required = false, + ) { + } +} diff --git a/src/Hydra/PartialCollectionView.php b/src/Hydra/PartialCollectionView.php new file mode 100644 index 00000000000..61ae09c9aab --- /dev/null +++ b/src/Hydra/PartialCollectionView.php @@ -0,0 +1,36 @@ + + * + * 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\Hydra; + +use Symfony\Component\JsonStreamer\Attribute\StreamedName; + +class PartialCollectionView +{ + #[StreamedName('@type')] + public string $type = 'PartialCollectionView'; + + public function __construct( + #[StreamedName('@id')] + public string $id, + #[StreamedName('first')] + public ?string $first = null, + #[StreamedName('last')] + public ?string $last = null, + #[StreamedName('previous')] + public ?string $previous = null, + #[StreamedName('next')] + public ?string $next = null, + ) { + } +} diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index 17147293ae2..c527a32f544 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -13,10 +13,10 @@ namespace ApiPlatform\Hydra\Serializer; +use ApiPlatform\Hydra\IriTemplateMapping; +use ApiPlatform\Hydra\State\Util\SearchHelperTrait; use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; use ApiPlatform\Metadata\FilterInterface; -use ApiPlatform\Metadata\Parameters; -use ApiPlatform\Metadata\QueryParameterInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\State\Util\StateOptionsTrait; @@ -34,6 +34,7 @@ final class CollectionFiltersNormalizer implements NormalizerInterface, NormalizerAwareInterface { use HydraPrefixTrait; + use SearchHelperTrait; use StateOptionsTrait; private ?ContainerInterface $filterLocator = null; @@ -108,7 +109,13 @@ public function normalize(mixed $object, ?string $format = null, array $context if ($currentFilters || ($parameters && \count($parameters))) { $hydraPrefix = $this->getHydraPrefix($context + $this->defaultContext); - $data[$hydraPrefix.'search'] = $this->getSearch($resourceClass, $requestParts, $currentFilters, $parameters, $hydraPrefix); + ['mapping' => $mapping, 'keys' => $keys] = $this->getSearchMappingAndKeys($operation, $resourceClass, $currentFilters, $parameters, [$this, 'getFilter']); + $data[$hydraPrefix.'search'] = [ + '@type' => $hydraPrefix.'IriTemplate', + $hydraPrefix.'template' => \sprintf('%s{?%s}', $requestParts['path'], implode(',', $keys)), + $hydraPrefix.'variableRepresentation' => 'BasicRepresentation', + $hydraPrefix.'mapping' => $this->convertMappingToArray($mapping), + ]; } return $data; @@ -125,88 +132,28 @@ public function setNormalizer(NormalizerInterface $normalizer): void } /** - * Returns the content of the Hydra search property. + * @param list $mapping * - * @param FilterInterface[] $filters + * @return array> */ - private function getSearch(string $resourceClass, array $parts, array $filters, ?Parameters $parameters, string $hydraPrefix): array + private function convertMappingToArray(array $mapping): array { - $variables = []; - $mapping = []; - foreach ($filters as $filter) { - foreach ($filter->getDescription($resourceClass) as $variable => $data) { - $variables[] = $variable; - $mapping[] = ['@type' => 'IriTemplateMapping', 'variable' => $variable, 'property' => $data['property'] ?? null, 'required' => $data['required'] ?? false]; - } - } - - foreach ($parameters ?? [] as $key => $parameter) { - // Each IriTemplateMapping maps a variable used in the template to a property - if (!$parameter instanceof QueryParameterInterface || false === $parameter->getHydra()) { - continue; - } - - if (($filterId = $parameter->getFilter()) && \is_string($filterId) && ($filter = $this->getFilter($filterId))) { - $filterDescription = $filter->getDescription($resourceClass); - - foreach ($filterDescription as $variable => $description) { - // // This is a practice induced by PHP and is not necessary when implementing URI template - if (str_ends_with((string) $variable, '[]')) { - continue; - } - - if (!($descriptionProperty = $description['property'] ?? null)) { - continue; - } - - if (($prop = $parameter->getProperty()) && $descriptionProperty !== $prop) { - continue; - } - - // :property is a pattern allowed when defining parameters - $k = str_replace(':property', $descriptionProperty, $key); - $variable = str_replace($descriptionProperty, $k, $variable); - $variables[] = $variable; - $m = ['@type' => 'IriTemplateMapping', 'variable' => $variable, 'property' => $descriptionProperty]; - if (null !== ($required = $parameter->getRequired() ?? $description['required'] ?? null)) { - $m['required'] = $required; - } - $mapping[] = $m; - } - - if ($filterDescription) { - continue; - } + $convertedMapping = []; + foreach ($mapping as $m) { + $converted = [ + '@type' => 'IriTemplateMapping', + 'variable' => $m->variable, + 'property' => $m->property, + ]; + + if (null !== ($r = $m->required)) { + $converted['required'] = $r; } - if (str_contains($key, ':property') && $parameter->getProperties()) { - $required = $parameter->getRequired(); - foreach ($parameter->getProperties() as $prop) { - $k = str_replace(':property', $prop, $key); - $m = ['@type' => 'IriTemplateMapping', 'variable' => $k, 'property' => $prop]; - $variables[] = $k; - if (null !== $required) { - $m['required'] = $required; - } - $mapping[] = $m; - } - - continue; - } - - if (!($property = $parameter->getProperty())) { - continue; - } - - $m = ['@type' => 'IriTemplateMapping', 'variable' => $key, 'property' => $property]; - $variables[] = $key; - if (null !== ($required = $parameter->getRequired())) { - $m['required'] = $required; - } - $mapping[] = $m; + $convertedMapping[] = $converted; } - return ['@type' => $hydraPrefix.'IriTemplate', $hydraPrefix.'template' => \sprintf('%s{?%s}', $parts['path'], implode(',', $variables)), $hydraPrefix.'variableRepresentation' => 'BasicRepresentation', $hydraPrefix.'mapping' => $mapping]; + return $convertedMapping; } /** diff --git a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php index e9d3a446c79..c37586f7e95 100644 --- a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php +++ b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Hydra\Serializer; +use ApiPlatform\Hydra\State\Util\PaginationHelperTrait; use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -35,6 +36,7 @@ final class PartialCollectionViewNormalizer implements NormalizerInterface, NormalizerAwareInterface { use HydraPrefixTrait; + use PaginationHelperTrait; private readonly PropertyAccessorInterface $propertyAccessor; /** @@ -60,21 +62,11 @@ public function normalize(mixed $object, ?string $format = null, array $context throw new UnexpectedValueException('Expected data to be an array'); } - $currentPage = $lastPage = $itemsPerPage = $pageTotalItems = null; - if ($paginated = ($object instanceof PartialPaginatorInterface)) { - if ($object instanceof PaginatorInterface) { - $paginated = 1. !== $lastPage = $object->getLastPage(); - } else { - $itemsPerPage = $object->getItemsPerPage(); - $pageTotalItems = (float) \count($object); - } - - $currentPage = $object->getCurrentPage(); + $paginated = $object instanceof PartialPaginatorInterface; + if ($paginated && $object instanceof PaginatorInterface) { + $paginated = 1. !== $object->getLastPage(); } - // TODO: This needs to be changed as well as I wrote in the CollectionFiltersNormalizer - // We should not rely on the request_uri but instead rely on the UriTemplate - // This needs that we implement the RFC and that we do more parsing before calling the serialization (MainController) $parsed = IriHelper::parseIri($context['uri'] ?? $context['request_uri'] ?? '/', $this->pageParameterName); $appliedFilters = $parsed['parameters']; unset($appliedFilters[$this->enabledParameterName]); @@ -94,18 +86,35 @@ public function normalize(mixed $object, ?string $format = null, array $context $isPaginatedWithCursor = (bool) $cursorPaginationAttribute; $hydraPrefix = $this->getHydraPrefix($context + $this->defaultContext); - $data[$hydraPrefix.'view'] = ['@id' => null, '@type' => $hydraPrefix.'PartialCollectionView']; if ($isPaginatedWithCursor) { + $data[$hydraPrefix.'view'] = ['@id' => null, '@type' => $hydraPrefix.'PartialCollectionView']; + return $this->populateDataWithCursorBasedPagination($data, $parsed, $object, $cursorPaginationAttribute, $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy, $hydraPrefix); } - $data[$hydraPrefix.'view']['@id'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy); + $partialCollectionView = $this->getPartialCollectionView($object, $context['uri'] ?? $context['request_uri'] ?? '/', $this->pageParameterName, $this->enabledParameterName, $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy); + + $view = [ + '@id' => $partialCollectionView->id, + '@type' => $hydraPrefix.'PartialCollectionView', + ]; + + if (null !== $partialCollectionView->first) { + $view[$hydraPrefix.'first'] = $partialCollectionView->first; + $view[$hydraPrefix.'last'] = $partialCollectionView->last; + } - if ($paginated) { - return $this->populateDataWithPagination($data, $parsed, $currentPage, $lastPage, $itemsPerPage, $pageTotalItems, $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy, $hydraPrefix); + if (null !== $partialCollectionView->previous) { + $view[$hydraPrefix.'previous'] = $partialCollectionView->previous; } + if (null !== $partialCollectionView->next) { + $view[$hydraPrefix.'next'] = $partialCollectionView->next; + } + + $data[$hydraPrefix.'view'] = $view; + return $data; } @@ -174,22 +183,4 @@ private function populateDataWithCursorBasedPagination(array $data, array $parse return $data; } - - private function populateDataWithPagination(array $data, array $parsed, ?float $currentPage, ?float $lastPage, ?float $itemsPerPage, ?float $pageTotalItems, ?int $urlGenerationStrategy, string $hydraPrefix): array - { - if (null !== $lastPage) { - $data[$hydraPrefix.'view'][$hydraPrefix.'first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy); - $data[$hydraPrefix.'view'][$hydraPrefix.'last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy); - } - - if (1. !== $currentPage) { - $data[$hydraPrefix.'view'][$hydraPrefix.'previous'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy); - } - - if ((null !== $lastPage && $currentPage < $lastPage) || (null === $lastPage && $pageTotalItems >= $itemsPerPage)) { - $data[$hydraPrefix.'view'][$hydraPrefix.'next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy); - } - - return $data; - } } diff --git a/src/Hydra/State/JsonStreamerProcessor.php b/src/Hydra/State/JsonStreamerProcessor.php new file mode 100644 index 00000000000..e98cf7a33a9 --- /dev/null +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -0,0 +1,120 @@ + + * + * 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\Hydra\State; + +use ApiPlatform\Hydra\Collection; +use ApiPlatform\Hydra\State\Util\PaginationHelperTrait; +use ApiPlatform\Hydra\State\Util\SearchHelperTrait; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\State\Pagination\PaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\Util\HttpResponseHeadersTrait; +use ApiPlatform\State\Util\HttpResponseStatusTrait; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\JsonStreamer\StreamWriterInterface; +use Symfony\Component\TypeInfo\Type; + +/** + * @implements ProcessorInterface + */ +final class JsonStreamerProcessor implements ProcessorInterface +{ + use HttpResponseHeadersTrait; + use HttpResponseStatusTrait; + use PaginationHelperTrait; + use SearchHelperTrait; + + /** + * @param ProcessorInterface $processor + * @param StreamWriterInterface> $jsonStreamer + */ + public function __construct( + private readonly ProcessorInterface $processor, + private readonly StreamWriterInterface $jsonStreamer, + ?IriConverterInterface $iriConverter = null, + ?ResourceClassResolverInterface $resourceClassResolver = null, + ?OperationMetadataFactoryInterface $operationMetadataFactory = null, + private readonly string $pageParameterName = 'page', + private readonly string $enabledParameterName = 'pagination', + private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH, + ) { + $this->resourceClassResolver = $resourceClassResolver; + $this->iriConverter = $iriConverter; + $this->operationMetadataFactory = $operationMetadataFactory; + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if ( + $operation instanceof Error + || $data instanceof Response + || !$operation instanceof HttpOperation + || !($request = $context['request'] ?? null) + || !$operation->getJsonStream() + || 'jsonld' !== $request->getRequestFormat() + ) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof CollectionOperationInterface) { + $requestUri = $request->getRequestUri() ?? ''; + $collection = new Collection(); + $collection->member = $data; + $collection->view = $this->getPartialCollectionView($data, $requestUri, $this->pageParameterName, $this->enabledParameterName, $operation->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy); + + if ($operation->getParameters()) { + $parts = parse_url($requestUri); + $collection->search = $this->getSearch($parts['path'] ?? '', $operation); + } + + if ($data instanceof PaginatorInterface) { + $collection->totalItems = $data->getTotalItems(); + } + + if (\is_array($data) || ($data instanceof \Countable && !$data instanceof PartialPaginatorInterface)) { + $collection->totalItems = \count($data); + } + + $data = $this->jsonStreamer->write( + $collection, + Type::generic(Type::object($collection::class), Type::object($operation->getClass())), + ['data' => $data, 'operation' => $operation], + ); + } else { + $data = $this->jsonStreamer->write( + $data, + Type::object($operation->getClass()), + ['data' => $data, 'operation' => $operation], + ); + } + + /** @var iterable $data */ + $response = new StreamedResponse( + $data, + $this->getStatus($request, $operation, $context), + $this->getHeaders($request, $operation, $context) + ); + + return $this->processor->process($response, $operation, $uriVariables, $context); + } +} diff --git a/src/Hydra/State/JsonStreamerProvider.php b/src/Hydra/State/JsonStreamerProvider.php new file mode 100644 index 00000000000..8e4e143baa9 --- /dev/null +++ b/src/Hydra/State/JsonStreamerProvider.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\Hydra\State; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\JsonStreamer\StreamReaderInterface; +use Symfony\Component\TypeInfo\Type; + +final class JsonStreamerProvider implements ProviderInterface +{ + public function __construct( + private readonly ?ProviderInterface $decorated, + private readonly StreamReaderInterface $jsonStreamReader, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if (!$operation instanceof HttpOperation || !$operation->getJsonStream() || !($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() || 'jsonld' !== $request->attributes->get('input_format')) { + return $data; + } + + $data = $this->jsonStreamReader->read($request->getContent(true), Type::object($operation->getClass())); + $context['request']->attributes->set('deserialized', true); + + return $data; + } +} diff --git a/src/Hydra/State/Util/PaginationHelperTrait.php b/src/Hydra/State/Util/PaginationHelperTrait.php new file mode 100644 index 00000000000..82c30651ac6 --- /dev/null +++ b/src/Hydra/State/Util/PaginationHelperTrait.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Hydra\State\Util; + +use ApiPlatform\Hydra\PartialCollectionView; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Metadata\Util\IriHelper; +use ApiPlatform\State\Pagination\PaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; + +trait PaginationHelperTrait +{ + private function getPaginationIri(array $parsed, ?float $currentPage, ?float $lastPage, ?float $itemsPerPage, ?float $pageTotalItems, ?int $urlGenerationStrategy, string $pageParameterName): array + { + $first = $last = $previous = $next = null; + + if (null !== $lastPage) { + $first = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $pageParameterName, 1., $urlGenerationStrategy); + $last = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $pageParameterName, $lastPage, $urlGenerationStrategy); + } + + if (1. !== $currentPage) { + $previous = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $pageParameterName, $currentPage - 1., $urlGenerationStrategy); + } + + if ((null !== $lastPage && $currentPage < $lastPage) || (null === $lastPage && $pageTotalItems >= $itemsPerPage)) { + $next = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $pageParameterName, $currentPage + 1., $urlGenerationStrategy); + } + + return [ + 'first' => $first, + 'last' => $last, + 'previous' => $previous, + 'next' => $next, + ]; + } + + private function getPartialCollectionView(mixed $object, string $requestUri, string $pageParameterName, string $enabledParameterName, ?int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH): PartialCollectionView + { + $currentPage = $lastPage = $itemsPerPage = $pageTotalItems = null; + $paginated = false; + if ($object instanceof PartialPaginatorInterface) { + $paginated = true; + if ($object instanceof PaginatorInterface) { + $paginated = 1. !== $lastPage = $object->getLastPage(); + } else { + $itemsPerPage = $object->getItemsPerPage(); + $pageTotalItems = (float) \count($object); + } + $currentPage = $object->getCurrentPage(); + } + + $parsed = IriHelper::parseIri($requestUri, $pageParameterName); + $appliedFilters = $parsed['parameters']; + unset($appliedFilters[$enabledParameterName]); + + $id = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy); + + if (!$paginated && $appliedFilters) { + return new PartialCollectionView($id); + } + + ['first' => $first, 'last' => $last, 'previous' => $previous, 'next' => $next] = $this->getPaginationIri($parsed, $currentPage, $lastPage, $itemsPerPage, $pageTotalItems, $urlGenerationStrategy, $pageParameterName); + + if (!$paginated) { + $first = null; + $last = null; + } + + return new PartialCollectionView( + $id, + $first, + $last, + $previous, + $next + ); + } +} diff --git a/src/Hydra/State/Util/SearchHelperTrait.php b/src/Hydra/State/Util/SearchHelperTrait.php new file mode 100644 index 00000000000..30c203f5621 --- /dev/null +++ b/src/Hydra/State/Util/SearchHelperTrait.php @@ -0,0 +1,126 @@ + + * + * 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\Hydra\State\Util; + +use ApiPlatform\Hydra\IriTemplate; +use ApiPlatform\Hydra\IriTemplateMapping; +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\QueryParameterInterface; + +trait SearchHelperTrait +{ + /** + * @param FilterInterface[] $filters + */ + private function getSearch(string $path, ?Operation $operation = null, ?string $resourceClass = null, ?array $filters = [], ?Parameters $parameters = null, ?callable $getFilter = null): IriTemplate + { + ['mapping' => $mapping, 'keys' => $keys] = $this->getSearchMappingAndKeys($operation, $resourceClass, $filters, $parameters, $getFilter); + + return new IriTemplate( + variableRepresentation: 'BasicRepresentation', + mapping: $mapping, + template: \sprintf('%s{?%s}', $path, implode(',', $keys)), + ); + } + + /** + * @param FilterInterface[] $filters + * + * @return array{mapping: list, keys: list} + */ + private function getSearchMappingAndKeys(?Operation $operation = null, ?string $resourceClass = null, ?array $filters = [], ?Parameters $parameters = null, ?callable $getFilter = null): array + { + $mapping = []; + $keys = []; + + if ($filters) { + foreach ($filters as $filter) { + foreach ($filter->getDescription($resourceClass) as $variable => $data) { + $keys[] = $variable; + $mapping[] = new IriTemplateMapping(variable: $variable, property: $data['property'] ?? null, required: $data['required'] ?? false); + } + } + } + + $params = $operation ? ($operation->getParameters() ?? []) : ($parameters ?? []); + + foreach ($params as $key => $parameter) { + if (!$parameter instanceof QueryParameterInterface || false === $parameter->getHydra()) { + continue; + } + + if ($getFilter && ($filterId = $parameter->getFilter()) && \is_string($filterId) && ($filter = $getFilter($filterId))) { + $filterDescription = $filter->getDescription($resourceClass); + + foreach ($filterDescription as $variable => $description) { + // // This is a practice induced by PHP and is not necessary when implementing URI template + if (str_ends_with((string) $variable, '[]')) { + continue; + } + + if (!($descriptionProperty = $description['property'] ?? null)) { + continue; + } + + if (($prop = $parameter->getProperty()) && $descriptionProperty !== $prop) { + continue; + } + + $k = str_replace(':property', $description['property'], $key); + $variable = str_replace($description['property'], $k, $variable); + $keys[] = $variable; + $m = new IriTemplateMapping(variable: $variable, property: $description['property'], required: $description['required']); + if (null !== ($required = $parameter->getRequired())) { + $m->required = $required; + } + $mapping[] = $m; + } + + if ($filterDescription) { + continue; + } + } + + if (str_contains($key, ':property') && $parameter->getProperties()) { + $required = $parameter->getRequired(); + foreach ($parameter->getProperties() as $prop) { + $k = str_replace(':property', $prop, $key); + $m = new IriTemplateMapping(variable: $k, property: $prop); + $keys[] = $k; + if (null !== $required) { + $m->required = $required; + } + $mapping[] = $m; + } + + continue; + } + + if (!($property = $parameter->getProperty())) { + continue; + } + + $m = new IriTemplateMapping(variable: $key, property: $property); + $keys[] = $key; + if (null !== ($required = $parameter->getRequired())) { + $m->required = $required; + } + $mapping[] = $m; + } + + return ['mapping' => $mapping, 'keys' => $keys]; + } +} diff --git a/src/Hydra/Tests/Serializer/PartialCollectionViewNormalizerTest.php b/src/Hydra/Tests/Serializer/PartialCollectionViewNormalizerTest.php index 3f45a4287f2..2b13ef703df 100644 --- a/src/Hydra/Tests/Serializer/PartialCollectionViewNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/PartialCollectionViewNormalizerTest.php @@ -111,7 +111,10 @@ public function testNormalizeWithCursorBasedPagination(): void private function normalizePaginator(bool $partial = false, bool $cursor = false) { $paginatorProphecy = $this->prophesize($partial ? PartialPaginatorInterface::class : PaginatorInterface::class); - $paginatorProphecy->getCurrentPage()->willReturn(3)->shouldBeCalled(); + + if (!$cursor) { + $paginatorProphecy->getCurrentPage()->willReturn(3)->shouldBeCalled(); + } $decoratedNormalize = ['foo' => 'bar']; diff --git a/src/JsonLd/JsonStreamer/ValueTransformer/ContextValueTransformer.php b/src/JsonLd/JsonStreamer/ValueTransformer/ContextValueTransformer.php new file mode 100644 index 00000000000..fbf75692828 --- /dev/null +++ b/src/JsonLd/JsonStreamer/ValueTransformer/ContextValueTransformer.php @@ -0,0 +1,41 @@ + + * + * 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\JsonLd\JsonStreamer\ValueTransformer; + +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use Symfony\Component\JsonStreamer\ValueTransformer\ValueTransformerInterface; +use Symfony\Component\TypeInfo\Type; + +final class ContextValueTransformer implements ValueTransformerInterface +{ + public function __construct( + private readonly UrlGeneratorInterface $urlGenerator, + ) { + } + + public function transform(mixed $value, array $options = []): mixed + { + if (!isset($options['operation'])) { + throw new RuntimeException('Operation is not defined'); + } + + return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $options['operation']->getShortName()], $options['operation']->getUrlGenerationStrategy()); + } + + public static function getStreamValueType(): Type + { + return Type::string(); + } +} diff --git a/src/JsonLd/JsonStreamer/ValueTransformer/IriValueTransformer.php b/src/JsonLd/JsonStreamer/ValueTransformer/IriValueTransformer.php new file mode 100644 index 00000000000..383ed920c8a --- /dev/null +++ b/src/JsonLd/JsonStreamer/ValueTransformer/IriValueTransformer.php @@ -0,0 +1,52 @@ + + * + * 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\JsonLd\JsonStreamer\ValueTransformer; + +use ApiPlatform\Hydra\Collection; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use Symfony\Component\JsonStreamer\ValueTransformer\ValueTransformerInterface; +use Symfony\Component\TypeInfo\Type; + +final class IriValueTransformer implements ValueTransformerInterface +{ + public function __construct( + private readonly IriConverterInterface $iriConverter, + ) { + } + + public function transform(mixed $value, array $options = []): mixed + { + if (!isset($options['operation'])) { + throw new RuntimeException('Operation is not defined'); + } + + if ($options['_current_object'] instanceof Collection) { + return $this->iriConverter->getIriFromResource($options['operation']->getClass(), UrlGeneratorInterface::ABS_PATH, $options['operation']); + } + + return $this->iriConverter->getIriFromResource( + $options['_current_object'], + UrlGeneratorInterface::ABS_PATH, + $options['operation'] instanceof CollectionOperationInterface ? null : $options['operation'], + ); + } + + public static function getStreamValueType(): Type + { + return Type::string(); + } +} diff --git a/src/JsonLd/JsonStreamer/ValueTransformer/TypeValueTransformer.php b/src/JsonLd/JsonStreamer/ValueTransformer/TypeValueTransformer.php new file mode 100644 index 00000000000..aed1098b8fe --- /dev/null +++ b/src/JsonLd/JsonStreamer/ValueTransformer/TypeValueTransformer.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\JsonLd\JsonStreamer\ValueTransformer; + +use ApiPlatform\Hydra\Collection; +use ApiPlatform\Metadata\Exception\RuntimeException; +use Symfony\Component\JsonStreamer\ValueTransformer\ValueTransformerInterface; +use Symfony\Component\TypeInfo\Type; + +final class TypeValueTransformer implements ValueTransformerInterface +{ + public function transform(mixed $value, array $options = []): mixed + { + if ($options['_current_object'] instanceof Collection) { + return 'Collection'; + } + + if (!isset($options['operation'])) { + throw new RuntimeException('Operation is not defined'); + } + + return $options['operation']->getShortName(); + } + + public static function getStreamValueType(): Type + { + return Type::string(); + } +} diff --git a/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php b/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php new file mode 100644 index 00000000000..a3cba6c0246 --- /dev/null +++ b/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.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\JsonLd\JsonStreamer; + +use ApiPlatform\Hydra\Collection; +use ApiPlatform\Hydra\IriTemplate; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\TypeHelper; +use Symfony\Component\JsonStreamer\Mapping\PropertyMetadata; +use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\Type; + +final class WritePropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + public function __construct( + private readonly PropertyMetadataLoaderInterface $loader, + private readonly ResourceClassResolverInterface $resourceClassResolver, + ) { + } + + public function load(string $className, array $options = [], array $context = []): array + { + $properties = $this->loader->load($className, $options, $context); + + if (IriTemplate::class === $className) { + $properties['template'] = new PropertyMetadata( + 'template', + Type::string(), + ['api_platform.hydra.json_streamer.write.value_transformer.template'], + ); + + return $properties; + } + + if (Collection::class !== $className && !$this->resourceClassResolver->isResourceClass($className)) { + return $properties; + } + + $properties['@id'] = new PropertyMetadata( + 'id', // virtual property + Type::mixed(), // virtual property + ['api_platform.jsonld.json_streamer.write.value_transformer.iri'], + ); + + $properties['@type'] = new PropertyMetadata( + 'id', // virtual property + Type::mixed(), // virtual property + ['api_platform.jsonld.json_streamer.write.value_transformer.type'], + ); + + $originalClassName = TypeHelper::getClassName($context['original_type']); + + if (Collection::class === $originalClassName || ($this->resourceClassResolver->isResourceClass($originalClassName) && !isset($context['generated_classes'][Collection::class]))) { + $properties['@context'] = new PropertyMetadata( + 'id', // virual property + Type::string(), // virtual property + ['api_platform.jsonld.json_streamer.write.value_transformer.context'], + ); + } + + return $properties; + } +} diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index 18e3b880ed0..70759a4be24 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -969,6 +969,7 @@ public function __construct( array|Parameters|null $parameters = null, protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, + ?bool $jsonStream = null, protected array $extraProperties = [], ) { parent::__construct( @@ -1014,6 +1015,7 @@ class: $class, middleware: $middleware, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + jsonStream: $jsonStream, extraProperties: $extraProperties ); diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index a343d4fa03d..5b412c1851f 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -99,6 +99,7 @@ public function __construct( array|string|null $middleware = null, ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, + ?bool $jsonStream = null, array $extraProperties = [], ) { parent::__construct( @@ -175,6 +176,7 @@ class: $class, rules: $rules, policy: $policy, middleware: $middleware, + jsonStream: $jsonStream, extraProperties: $extraProperties, collectDenormalizationErrors: $collectDenormalizationErrors, parameters: $parameters, diff --git a/src/Metadata/Error.php b/src/Metadata/Error.php index 0bbe9772b75..79e1781a0d8 100644 --- a/src/Metadata/Error.php +++ b/src/Metadata/Error.php @@ -94,6 +94,7 @@ public function __construct( $processor = null, ?OptionsInterface $stateOptions = null, ?bool $hideHydraOperation = null, + ?bool $jsonStream = null, array $extraProperties = [], ) { parent::__construct( @@ -169,6 +170,7 @@ class: $class, processor: $processor, stateOptions: $stateOptions, hideHydraOperation: $hideHydraOperation, + jsonStream: $jsonStream, extraProperties: $extraProperties, ); } diff --git a/src/Metadata/ErrorResource.php b/src/Metadata/ErrorResource.php index a232c866e9b..8f1586ac038 100644 --- a/src/Metadata/ErrorResource.php +++ b/src/Metadata/ErrorResource.php @@ -83,6 +83,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + ?bool $jsonStream = null, array $extraProperties = [], ) { parent::__construct( @@ -149,6 +150,7 @@ class: $class, provider: $provider, processor: $processor, stateOptions: $stateOptions, + jsonStream: $jsonStream, extraProperties: $extraProperties ); } diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 85dc61fdf48..faf3be7ca82 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -145,6 +145,7 @@ private function buildBase(\SimpleXMLElement $resource): array 'extraProperties' => $this->buildExtraProperties($resource, 'extraProperties'), 'read' => $this->phpize($resource, 'read', 'bool'), 'write' => $this->phpize($resource, 'write', 'bool'), + 'jsonStream' => $this->phpize($resource, 'jsonStream', 'bool'), ]; } diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index 3332996b072..bc9861071bd 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -171,6 +171,7 @@ private function buildBase(array $resource): array 'messenger' => $this->buildMessenger($resource), 'read' => $this->phpize($resource, 'read', 'bool'), 'write' => $this->phpize($resource, 'write', 'bool'), + 'jsonStream' => $this->phpize($resource, 'jsonStream', 'bool'), ]; } diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index 4d42dca9fdc..e9c849ac86a 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -523,6 +523,7 @@ + diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 9d15fb3a1d5..13f497679d2 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -99,6 +99,7 @@ public function __construct( array|string|null $middleware = null, ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, + ?bool $jsonStream = null, array $extraProperties = [], ) { parent::__construct( @@ -179,6 +180,7 @@ class: $class, middleware: $middleware, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + jsonStream: $jsonStream, extraProperties: $extraProperties, ); } diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 469c771febd..7d1a2d73a69 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -99,6 +99,7 @@ public function __construct( array|string|null $middleware = null, ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, + ?bool $jsonStream = null, array $extraProperties = [], private ?string $itemUriTemplate = null, ) { @@ -174,6 +175,7 @@ class: $class, provider: $provider, processor: $processor, parameters: $parameters, + jsonStream: $jsonStream, extraProperties: $extraProperties, rules: $rules, policy: $policy, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 4ecec52f33d..69fb8099aa9 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -219,6 +219,7 @@ public function __construct( ?string $policy = null, array|string|null $middleware = null, ?bool $queryParameterValidationEnabled = null, + ?bool $jsonStream = null, array $extraProperties = [], ) { $this->formats = (null === $formats || \is_array($formats)) ? $formats : [$formats]; @@ -276,6 +277,7 @@ class: $class, queryParameterValidationEnabled: $queryParameterValidationEnabled, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + jsonStream: $jsonStream, extraProperties: $extraProperties ); } diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index da0d7e2fe25..e9ca34c0563 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -83,6 +83,7 @@ public function __construct( protected ?bool $queryParameterValidationEnabled = null, protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, + protected ?bool $jsonStream = null, protected array $extraProperties = [], ) { if (\is_array($parameters) && $parameters) { @@ -681,4 +682,17 @@ public function withHideHydraOperation(bool $hideHydraOperation): static return $self; } + + public function getJsonStream(): ?bool + { + return $this->jsonStream; + } + + public function withJsonStream(bool $jsonStream): static + { + $self = clone $this; + $self->jsonStream = $jsonStream; + + return $self; + } } diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index f8d91c9f135..42f936789e0 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -812,6 +812,7 @@ public function __construct( ?bool $queryParameterValidationEnabled = null, protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, + protected ?bool $jsonStream = null, protected array $extraProperties = [], ) { parent::__construct( @@ -858,6 +859,7 @@ class: $class, queryParameterValidationEnabled: $queryParameterValidationEnabled, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + jsonStream: $jsonStream, extraProperties: $extraProperties, ); } diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 8af313c70e4..5a399725c5b 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -99,6 +99,7 @@ public function __construct( array|string|null $middleware = null, ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, + ?bool $jsonStream = null, array $extraProperties = [], ) { parent::__construct( @@ -180,6 +181,7 @@ class: $class, middleware: $middleware, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + jsonStream: $jsonStream, extraProperties: $extraProperties ); } diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 1017c531d55..4db4e245aa8 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -97,6 +97,7 @@ public function __construct( mixed $rules = null, ?string $policy = null, array|string|null $middleware = null, + ?bool $jsonStream = null, array $extraProperties = [], private ?string $itemUriTemplate = null, ?bool $strictQueryParameterValidation = null, @@ -181,6 +182,7 @@ class: $class, middleware: $middleware, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + jsonStream: $jsonStream, extraProperties: $extraProperties ); } diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 7d48a5aa053..81e33066710 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -97,6 +97,7 @@ public function __construct( mixed $rules = null, ?string $policy = null, array|string|null $middleware = null, + ?bool $jsonStream = null, array $extraProperties = [], ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, @@ -181,6 +182,7 @@ class: $class, middleware: $middleware, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + jsonStream: $jsonStream, extraProperties: $extraProperties ); } diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index a1c852e5924..3fd2bf0bf1a 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -65,6 +65,7 @@ final class XmlResourceAdapter implements ResourceAdapterInterface 'hideHydraOperation', 'stateOptions', 'collectDenormalizationErrors', + 'jsonStream', 'links', 'parameters', ]; @@ -544,6 +545,11 @@ private function buildParameters(\SimpleXMLElement $resource, ?array $values = n } } + private function buildJsonStream(\SimpleXMLElement $resource, bool $value): void + { + $resource->addAttribute('jsonStream', $this->parse($value)); + } + private function parse($value): ?string { if (null === $value) { diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.xml b/src/Metadata/Tests/Extractor/Adapter/resources.xml index 084985d0db0..b7e83452477 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.xml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.xml @@ -1,3 +1,3 @@ -someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazbazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazbazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet +someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazbazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazbazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index 729c5d74bd4..515981dd993 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -338,6 +338,7 @@ resources: parameters: null strictQueryParameterValidation: false hideHydraOperation: false + jsonStream: true extraProperties: custom_property: 'Lorem ipsum dolor sit amet' another_custom_property: diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php index cc06cd38c38..9e1e8e14c86 100644 --- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php @@ -163,6 +163,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'Lorem ipsum' => 'Dolor sit amet', ], ], + 'jsonStream' => true, 'mercure' => true, 'stateOptions' => [ 'elasticsearchOptions' => [ @@ -472,6 +473,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'filters', 'order', 'extraProperties', + 'jsonStream', ]; private const EXTENDED_BASE = [ 'uriTemplate', @@ -754,4 +756,9 @@ private function withParameters(array $values): ?array return $parameters; } + + private function withJsonStream(bool $value): bool + { + return $value; + } } diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php index e636b47dcad..4f26329a723 100644 --- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php @@ -103,6 +103,7 @@ public function testValidXML(): void 'links' => null, 'headers' => null, 'parameters' => null, + 'jsonStream' => null, ], [ 'uriTemplate' => '/users/{author}/comments{._format}', @@ -278,6 +279,7 @@ public function testValidXML(): void 'headers' => ['hello' => 'world'], 'parameters' => null, 'routeName' => 'custom_route_name', + 'jsonStream' => null, ], [ 'name' => null, @@ -390,6 +392,7 @@ public function testValidXML(): void ), ], 'routeName' => null, + 'jsonStream' => null, ], ], 'graphQlOperations' => null, @@ -401,6 +404,7 @@ public function testValidXML(): void 'links' => null, 'headers' => ['hello' => 'world'], 'parameters' => null, + 'jsonStream' => null, ], ], ], $extractor->getResources()); diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php index b3be3fb5d8e..9d08bb94ef4 100644 --- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php @@ -102,6 +102,7 @@ public function testValidYaml(): void 'links' => null, 'headers' => null, 'parameters' => null, + 'jsonStream' => null, ], ], Program::class => [ @@ -173,6 +174,7 @@ public function testValidYaml(): void 'links' => null, 'headers' => null, 'parameters' => null, + 'jsonStream' => null, ], [ 'uriTemplate' => '/users/{author}/programs{._format}', @@ -315,6 +317,7 @@ public function testValidYaml(): void 'links' => null, 'headers' => ['hello' => 'world'], 'parameters' => null, + 'jsonStream' => null, ], [ 'name' => null, @@ -400,6 +403,7 @@ public function testValidYaml(): void 'links' => null, 'headers' => ['hello' => 'world'], 'parameters' => ['author' => new QueryParameter(schema: ['type' => 'string'], required: true, key: 'author', description: 'hello')], + 'jsonStream' => null, ], ], 'graphQlOperations' => null, @@ -411,6 +415,7 @@ public function testValidYaml(): void 'links' => null, 'headers' => ['hello' => 'world'], 'parameters' => null, + 'jsonStream' => null, ], ], SingleFileConfigDummy::class => [ @@ -482,6 +487,7 @@ public function testValidYaml(): void 'links' => null, 'headers' => null, 'parameters' => null, + 'jsonStream' => null, ], ], ], $extractor->getResources()); diff --git a/src/Serializer/State/JsonStreamerProcessor.php b/src/Serializer/State/JsonStreamerProcessor.php new file mode 100644 index 00000000000..76f6b04defb --- /dev/null +++ b/src/Serializer/State/JsonStreamerProcessor.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\Serializer\State; + +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\Util\HttpResponseHeadersTrait; +use ApiPlatform\State\Util\HttpResponseStatusTrait; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\JsonStreamer\StreamWriterInterface; +use Symfony\Component\TypeInfo\Type; + +/** + * @implements ProcessorInterface + */ +final class JsonStreamerProcessor implements ProcessorInterface +{ + use HttpResponseHeadersTrait; + use HttpResponseStatusTrait; + + /** + * @param ProcessorInterface $processor + * @param StreamWriterInterface> $jsonStreamer + */ + public function __construct( + private readonly ProcessorInterface $processor, + private readonly StreamWriterInterface $jsonStreamer, + ?IriConverterInterface $iriConverter = null, + ?ResourceClassResolverInterface $resourceClassResolver = null, + ?OperationMetadataFactoryInterface $operationMetadataFactory = null, + ) { + $this->resourceClassResolver = $resourceClassResolver; + $this->iriConverter = $iriConverter; + $this->operationMetadataFactory = $operationMetadataFactory; + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if ( + $operation instanceof Error + || $data instanceof Response + || !$operation instanceof HttpOperation + || !($request = $context['request'] ?? null) + || !$operation->getJsonStream() + || 'json' !== $request->getRequestFormat() + ) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof CollectionOperationInterface) { + $data = $this->jsonStreamer->write( + $data, + Type::list(Type::object($operation->getClass())), + ['data' => $data, 'operation' => $operation], + ); + } else { + $data = $this->jsonStreamer->write( + $data, + Type::object($operation->getClass()), + ['data' => $data, 'operation' => $operation], + ); + } + + /** @var iterable $data */ + $response = new StreamedResponse( + $data, + $this->getStatus($request, $operation, $context), + $this->getHeaders($request, $operation, $context) + ); + + return $this->processor->process($response, $operation, $uriVariables, $context); + } +} diff --git a/src/Serializer/State/JsonStreamerProvider.php b/src/Serializer/State/JsonStreamerProvider.php new file mode 100644 index 00000000000..2da1256c44e --- /dev/null +++ b/src/Serializer/State/JsonStreamerProvider.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\Serializer\State; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\JsonStreamer\StreamReaderInterface; +use Symfony\Component\TypeInfo\Type; + +final class JsonStreamerProvider implements ProviderInterface +{ + public function __construct( + private readonly ?ProviderInterface $decorated, + private readonly StreamReaderInterface $jsonStreamReader, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if (!$operation instanceof HttpOperation || !$operation->getJsonStream() || !($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() || 'json' !== $request->attributes->get('input_format')) { + return $data; + } + + $data = $this->jsonStreamReader->read($request->getContent(true), Type::object($operation->getClass())); + $context['request']->attributes->set('deserialized', true); + + return $data; + } +} diff --git a/src/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index f716484b456..ad2195cb9a1 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -13,24 +13,17 @@ namespace ApiPlatform\State\Processor; -use ApiPlatform\Metadata\Exception\HttpExceptionInterface; -use ApiPlatform\Metadata\Exception\InvalidArgumentException; -use ApiPlatform\Metadata\Exception\ItemNotFoundException; -use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation; 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 ApiPlatform\State\StopwatchAwareInterface; use ApiPlatform\State\StopwatchAwareTrait; +use ApiPlatform\State\Util\HttpResponseHeadersTrait; +use ApiPlatform\State\Util\HttpResponseStatusTrait; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; /** * Serializes data. @@ -39,20 +32,18 @@ */ final class RespondProcessor implements ProcessorInterface, StopwatchAwareInterface { - use ClassInfoTrait; - use CloneTrait; + use HttpResponseHeadersTrait; + use HttpResponseStatusTrait; use StopwatchAwareTrait; - 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, + ?IriConverterInterface $iriConverter = null, + ?ResourceClassResolverInterface $resourceClassResolver = null, + ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ) { + $this->iriConverter = $iriConverter; + $this->resourceClassResolver = $resourceClassResolver; + $this->operationMetadataFactory = $operationMetadataFactory; } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) @@ -67,87 +58,8 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $this->stopwatch?->start('api_platform.processor.respond'); - $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) { - $isAlternateResourceMetadata = $operation->getExtraProperties()['is_alternate_resource_metadata'] ?? false; - $canonicalUriTemplate = $operation->getExtraProperties()['canonical_uri_template'] ?? null; - - if ( - !isset($headers['Location']) - && 300 <= $status && $status < 400 - && ($isAlternateResourceMetadata || $canonicalUriTemplate) - ) { - $canonicalOperation = $operation; - if ($this->operationMetadataFactory && null !== $canonicalUriTemplate) { - $canonicalOperation = $this->operationMetadataFactory->create($canonicalUriTemplate, $context); - } - - if ($this->iriConverter) { - $headers['Location'] = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, $canonicalOperation); - } - } elseif ('PUT' === $method && !$request->attributes->get('previous_data') && null === $status && ($operation instanceof Put && ($operation->getAllowCreate() ?? false))) { - $status = 201; - } - } - - $status ??= self::METHOD_TO_CODE[$method] ?? 200; - - $requestParts = parse_url($request->getRequestUri()); - if ($this->iriConverter && !isset($headers['Content-Location'])) { - try { - $iri = null; - if ($hasData) { - $iri = $this->iriConverter->getIriFromResource($originalData); - } elseif ($operation->getClass()) { - $iri = $this->iriConverter->getIriFromResource($operation->getClass(), UrlGeneratorInterface::ABS_PATH, $operation); - } - - if ($iri && 'GET' !== $method) { - $location = \sprintf('%s.%s', $iri, $request->getRequestFormat()); - if (isset($requestParts['query'])) { - $location .= '?'.$requestParts['query']; - } - - $headers['Content-Location'] = $location; - if ((201 === $status || (300 <= $status && $status < 400)) && 'POST' === $method && !isset($headers['Location'])) { - $headers['Location'] = $iri; - } - } - } catch (InvalidArgumentException|ItemNotFoundException|RuntimeException) { - } - } + $status = $this->getStatus($request, $operation, $context); + $headers = $this->getHeaders($request, $operation, $context); $this->stopwatch?->stop('api_platform.processor.respond'); diff --git a/src/State/Processor/SerializeProcessor.php b/src/State/Processor/SerializeProcessor.php index 10f07ce4a60..8047a384899 100644 --- a/src/State/Processor/SerializeProcessor.php +++ b/src/State/Processor/SerializeProcessor.php @@ -42,8 +42,11 @@ final class SerializeProcessor implements ProcessorInterface, StopwatchAwareInte /** * @param ProcessorInterface|null $processor */ - public function __construct(private readonly ?ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder) - { + public function __construct( + private readonly ?ProcessorInterface $processor, + private readonly SerializerInterface $serializer, + private readonly SerializerContextBuilderInterface $serializerContextBuilder, + ) { } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php index 7d4599bbea4..9c281e2f00e 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -59,7 +59,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $data = $this->decorated ? $this->decorated->provide($operation, $uriVariables, $context) : $request->attributes->get('data'); - if (!$operation->canDeserialize()) { + if (!$operation->canDeserialize() || $context['request']->attributes->has('deserialized')) { return $data; } diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php new file mode 100644 index 00000000000..ec80d85b913 --- /dev/null +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -0,0 +1,127 @@ + + * + * 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\Exception\HttpExceptionInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Metadata\Util\CloneTrait; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; + +/** + * Shares the logic to create API Platform's headers. + * + * @internal + */ +trait HttpResponseHeadersTrait +{ + use ClassInfoTrait; + use CloneTrait; + private ?IriConverterInterface $iriConverter; + private ?OperationMetadataFactoryInterface $operationMetadataFactory; + + /** + * @param array $context + * + * @return array + */ + private function getHeaders(Request $request, HttpOperation $operation, array $context): array + { + $status = $this->getStatus($request, $operation, $context); + $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); + } + + 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) { + $isAlternateResourceMetadata = $operation->getExtraProperties()['is_alternate_resource_metadata'] ?? false; + $canonicalUriTemplate = $operation->getExtraProperties()['canonical_uri_template'] ?? null; + + if ( + !isset($headers['Location']) + && 300 <= $status && $status < 400 + && ($isAlternateResourceMetadata || $canonicalUriTemplate) + ) { + $canonicalOperation = $operation; + if ($this->operationMetadataFactory && null !== $canonicalUriTemplate) { + $canonicalOperation = $this->operationMetadataFactory->create($canonicalUriTemplate, $context); + } + + if ($this->iriConverter) { + $headers['Location'] = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, $canonicalOperation); + } + } + } + + $requestParts = parse_url($request->getRequestUri()); + if ($this->iriConverter && !isset($headers['Content-Location'])) { + try { + $iri = null; + if ($hasData) { + $iri = $this->iriConverter->getIriFromResource($originalData); + } elseif ($operation->getClass()) { + $iri = $this->iriConverter->getIriFromResource($operation->getClass(), UrlGeneratorInterface::ABS_PATH, $operation); + } + + if ($iri && 'GET' !== $method) { + $location = \sprintf('%s.%s', $iri, $request->getRequestFormat()); + if (isset($requestParts['query'])) { + $location .= '?'.$requestParts['query']; + } + + $headers['Content-Location'] = $location; + if ((Response::HTTP_CREATED === $status || (300 <= $status && $status < 400)) && 'POST' === $method && !isset($headers['Location'])) { + $headers['Location'] = $iri; + } + } + } catch (InvalidArgumentException|ItemNotFoundException|RuntimeException) { + } + } + + return $headers; + } +} diff --git a/src/State/Util/HttpResponseStatusTrait.php b/src/State/Util/HttpResponseStatusTrait.php new file mode 100644 index 00000000000..89b9156c3ea --- /dev/null +++ b/src/State/Util/HttpResponseStatusTrait.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\Util; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Metadata\Util\CloneTrait; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +trait HttpResponseStatusTrait +{ + use ClassInfoTrait; + use CloneTrait; + private ?ResourceClassResolverInterface $resourceClassResolver; + + public const METHOD_TO_CODE = [ + 'POST' => Response::HTTP_CREATED, + 'DELETE' => Response::HTTP_NO_CONTENT, + ]; + + /** + * @param array $context + */ + private function getStatus(Request $request, HttpOperation $operation, array $context): int + { + $status = $operation->getStatus(); + $method = $request->getMethod(); + + $outputMetadata = $operation->getOutput() ?? ['class' => $operation->getClass()]; + $hasOutput = \is_array($outputMetadata) && \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class']; + $originalData = $context['original_data'] ?? null; + $hasData = !$hasOutput ? false : ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))); + + if ($hasData) { + if ('PUT' === $method && !$request->attributes->get('previous_data') && null === $status && ($operation instanceof Put && ($operation->getAllowCreate() ?? false))) { + $status = Response::HTTP_CREATED; + } + } + + return $status ?? self::METHOD_TO_CODE[$method] ?? Response::HTTP_OK; + } +} diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 333adf97e25..ad8efaf6b0b 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -172,6 +172,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerMakerConfiguration($container, $config, $loader); $this->registerArgumentResolverConfiguration($loader); $this->registerLinkSecurityConfiguration($loader, $config); + $this->registerJsonStreamerConfiguration($container, $loader, $formats, $config); if (class_exists(ObjectMapper::class)) { $loader->load('state/object_mapper.xml'); @@ -191,7 +192,8 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('api_platform.resource') ->addTag('container.excluded', ['source' => 'by #[ApiResource] attribute']); }); - $container->registerAttributeForAutoconfiguration(AsResourceMutator::class, + $container->registerAttributeForAutoconfiguration( + AsResourceMutator::class, static function (ChildDefinition $definition, AsResourceMutator $attribute, \Reflector $reflector): void { if (!$reflector instanceof \ReflectionClass) { return; @@ -207,7 +209,8 @@ static function (ChildDefinition $definition, AsResourceMutator $attribute, \Ref }, ); - $container->registerAttributeForAutoconfiguration(AsOperationMutator::class, + $container->registerAttributeForAutoconfiguration( + AsOperationMutator::class, static function (ChildDefinition $definition, AsOperationMutator $attribute, \Reflector $reflector): void { if (!$reflector instanceof \ReflectionClass) { return; @@ -979,4 +982,22 @@ private function registerLinkSecurityConfiguration(XmlFileLoader $loader, array $loader->load('link_security.xml'); } } + + private function registerJsonStreamerConfiguration(ContainerBuilder $container, XmlFileLoader $loader, array $formats, array $config): void + { + if (!$config['enable_json_streamer']) { + return; + } + + if (isset($formats['jsonld'])) { + $container->setParameter('.json_streamer.stream_writers_dir.jsonld', '%kernel.cache_dir%/json_streamer/stream_writer/jsonld'); + $container->setParameter('.json_streamer.stream_readers_dir.jsonld', '%kernel.cache_dir%/json_streamer/stream_reader/jsonld'); + $container->setParameter('.json_streamer.lazy_ghosts_dir.jsonld', '%kernel.cache_dir%/json_streamer/lazy_ghost/jsonld'); + $loader->load('json_streamer/hydra.xml'); + } + + if (isset($formats['json'])) { + $loader->load('json_streamer/json.xml'); + } + } } diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 493c98523d0..c11f113f59d 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -33,6 +33,7 @@ use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\JsonStreamer\JsonStreamWriter; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; @@ -108,6 +109,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->booleanNode('handle_symfony_errors')->defaultFalse()->info('Allows to handle symfony exceptions.')->end() ->booleanNode('enable_swagger')->defaultTrue()->info('Enable the Swagger documentation and export.')->end() + ->booleanNode('enable_json_streamer')->defaultValue(class_exists(JsonStreamWriter::class))->info('Enable json streamer.')->end() ->booleanNode('enable_swagger_ui')->defaultValue(class_exists(TwigBundle::class))->info('Enable Swagger UI')->end() ->booleanNode('enable_re_doc')->defaultValue(class_exists(TwigBundle::class))->info('Enable ReDoc')->end() ->booleanNode('enable_entrypoint')->defaultTrue()->info('Enable the entrypoint')->end() diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/hydra.xml b/src/Symfony/Bundle/Resources/config/json_streamer/hydra.xml new file mode 100644 index 00000000000..9cbdcad8111 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/json_streamer/hydra.xml @@ -0,0 +1,54 @@ + + + + + + + %.json_streamer.stream_writers_dir.jsonld% + + + + + + %.json_streamer.stream_readers_dir.jsonld% + %.json_streamer.lazy_ghosts_dir.jsonld% + + + + + + + + + + + + + + + + + + + + + + + + + + + + %api_platform.collection.pagination.page_parameter_name% + %api_platform.collection.pagination.enabled_parameter_name% + %api_platform.url_generation_strategy% + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/json.xml b/src/Symfony/Bundle/Resources/config/json_streamer/json.xml new file mode 100644 index 00000000000..9e9d6fbacd1 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/json_streamer/json.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index ded589e35f1..1adf558453b 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -42,6 +42,7 @@ class ApiPlatformExtensionTest extends TestCase 'title' => 'title', 'description' => 'description', 'version' => 'version', + 'enable_json_streamer' => true, 'serializer' => ['hydra_prefix' => true], 'formats' => [ 'json' => ['mime_types' => ['json']], diff --git a/tests/Fixtures/TestBundle/Entity/JsonStreamResource.php b/tests/Fixtures/TestBundle/Entity/JsonStreamResource.php new file mode 100644 index 00000000000..b16c83cfaf4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/JsonStreamResource.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity()] +#[ORM\Table(name: 'json_stream_resource')] +#[ApiResource( + jsonStream: true, + paginationEnabled: false, + normalizationContext: ['hydra_prefix' => false] +)] +class JsonStreamResource +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + public int $id; + + #[ORM\Column(length: 255)] + public string $title; + + #[ORM\Column(type: 'datetime_immutable')] + public \DateTimeImmutable $createdAt; + + #[ORM\Column(type: 'date_immutable')] + public \DateTimeImmutable $publishedAt; + + #[ORM\Column(type: 'integer')] + public int $views; + + #[ORM\Column(type: 'float')] + public float $rating; + + #[ORM\Column(type: 'boolean')] + public bool $isFeatured; + + #[ORM\Column(type: 'decimal', precision: 10, scale: 2)] + public string $price; +} diff --git a/tests/Functional/JsonLdTest.php b/tests/Functional/JsonLdTest.php index 250193d0ed6..0215d0da601 100644 --- a/tests/Functional/JsonLdTest.php +++ b/tests/Functional/JsonLdTest.php @@ -149,12 +149,14 @@ protected function setUp(): void $schemaTool = new SchemaTool($manager); @$schemaTool->createSchema($classes); } catch (\Exception $e) { - return; } $foo = new Foo(); $foo->title = 'Foo'; $manager->persist($foo); + $foo1 = new Foo(); + $foo1->title = 'Foo1'; + $manager->persist($foo1); $bar = new Bar(); $bar->title = 'Bar one'; $manager->persist($bar); diff --git a/tests/Functional/JsonStreamerTest.php b/tests/Functional/JsonStreamerTest.php new file mode 100644 index 00000000000..3760e707503 --- /dev/null +++ b/tests/Functional/JsonStreamerTest.php @@ -0,0 +1,302 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\JsonStreamResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Tools\SchemaTool; + +class JsonStreamerTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [JsonStreamResource::class]; + } + + protected function setUp(): void + { + self::bootKernel(); + + $container = static::getContainer(); + + if ('mongodb' === $container->getParameter('kernel.environment')) { + return; + } + + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + if (!$manager instanceof EntityManagerInterface) { + return; + } + + $classes = []; + foreach ([JsonStreamResource::class] as $entityClass) { + $classes[] = $manager->getClassMetadata($entityClass); + } + + try { + $schemaTool = new SchemaTool($manager); + @$schemaTool->createSchema($classes); + } catch (\Exception $e) { + } + + for ($i = 0; $i < 10; ++$i) { + $resource = new JsonStreamResource(); + $resource->title = 'Title '.$i; + $resource->createdAt = new \DateTimeImmutable(); + $resource->publishedAt = new \DateTimeImmutable(); + $resource->views = random_int(1, 1000); + $resource->rating = random_int(1, 5); + $resource->isFeatured = (bool) random_int(0, 1); + $resource->price = number_format((float) random_int(10, 1000) / 100, 2, '.', ''); + + $manager->persist($resource); + } + + $manager->flush(); + } + + protected function tearDown(): void + { + $container = static::getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + if (!$manager instanceof EntityManagerInterface) { + return; + } + + $classes = []; + foreach ([JsonStreamResource::class] as $entityClass) { + $classes[] = $manager->getClassMetadata($entityClass); + } + + $schemaTool = new SchemaTool($manager); + @$schemaTool->dropSchema($classes); + parent::tearDown(); + } + + public function testJsonStreamerJsonLd(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $buffer = ''; + ob_start(function (string $chunk) use (&$buffer): void { + $buffer .= $chunk; + }); + + self::createClient()->request('GET', '/json_stream_resources/1', ['headers' => ['accept' => 'application/ld+json']]); + + ob_get_clean(); + + $res = json_decode($buffer, true); + $this->assertIsInt($res['views']); + $this->assertIsInt($res['rating']); + $this->assertIsBool($res['isFeatured']); + $this->assertIsString($res['price']); + $this->assertEquals('/json_stream_resources/1', $res['@id']); + $this->assertEquals('JsonStreamResource', $res['@type']); + $this->assertEquals('/contexts/JsonStreamResource', $res['@context']); + } + + public function testJsonStreamerCollectionJsonLd(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $buffer = ''; + ob_start(function (string $chunk) use (&$buffer): void { + $buffer .= $chunk; + }); + + self::createClient()->request('GET', '/json_stream_resources', ['headers' => ['accept' => 'application/ld+json']]); + + ob_get_clean(); + + $res = json_decode($buffer, true); + + $this->assertIsArray($res); + $this->assertArrayHasKey('@context', $res); + $this->assertArrayHasKey('@id', $res); + $this->assertArrayHasKey('@type', $res); + $this->assertEquals('Collection', $res['@type']); + $this->assertArrayHasKey('member', $res); + $this->assertIsArray($res['member']); + $this->assertEquals('JsonStreamResource', $res['member'][0]['@type']); + $this->assertArrayHasKey('totalItems', $res); + $this->assertIsInt($res['totalItems']); + } + + public function testJsonStreamerJson(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $buffer = ''; + ob_start(function (string $chunk) use (&$buffer): void { + $buffer .= $chunk; + }); + + self::createClient()->request('GET', '/json_stream_resources/1', ['headers' => ['accept' => 'application/json']]); + + ob_get_clean(); + + $res = json_decode($buffer, true); + $this->assertIsInt($res['views']); + $this->assertIsInt($res['rating']); + $this->assertIsBool($res['isFeatured']); + $this->assertIsString($res['price']); + $this->assertArrayNotHasKey('@id', $res); + $this->assertArrayNotHasKey('@type', $res); + $this->assertArrayNotHasKey('@context', $res); + } + + public function testJsonStreamerCollectionJson(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $buffer = ''; + ob_start(function (string $chunk) use (&$buffer): void { + $buffer .= $chunk; + }); + + self::createClient()->request('GET', '/json_stream_resources', ['headers' => ['accept' => 'application/json']]); + + ob_get_clean(); + + $res = json_decode($buffer, true); + + $this->assertIsArray($res); + $this->assertArrayNotHasKey('@id', $res); + $this->assertArrayNotHasKey('@type', $res); + $this->assertArrayNotHasKey('@context', $res); + } + + public function testJsonStreamerWriteJsonLd(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + if (\PHP_VERSION_ID < 80400) { + $this->markTestSkipped('PHP version is lower than 8.4'); + } + + $buffer = ''; + ob_start(function (string $chunk) use (&$buffer): void { + $buffer .= $chunk; + }); + + self::createClient()->request('POST', '/json_stream_resources', [ + 'json' => [ + 'title' => 'asd', + 'views' => 0, + 'createdAt' => '2024-01-01T12:00:00+00:00', + 'publishedAt' => '2024-01-01T12:00:00+00:00', + 'rating' => 0.0, + 'isFeatured' => false, + 'price' => '0.00', + ], + 'headers' => ['content-type' => 'application/ld+json'], + ]); + + ob_get_clean(); + + $res = json_decode($buffer, true); + + $this->assertResponseIsSuccessful(); + $this->assertSame('asd', $res['title']); + $this->assertSame(0, $res['views']); + $this->assertSame(0, $res['rating']); + $this->assertFalse($res['isFeatured']); + $this->assertSame('0', $res['price']); + $this->assertStringStartsWith('/json_stream_resources/', $res['@id']); + $this->assertSame('/contexts/JsonStreamResource', $res['@context']); + + $container = static::getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + $jsonStreamResource = $manager->find(JsonStreamResource::class, $res['id']); + $this->assertNotNull($jsonStreamResource); + } + + public function testJsonStreamerWriteJson(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + if (\PHP_VERSION_ID < 80400) { + $this->markTestSkipped('PHP version is lower than 8.4'); + } + + $buffer = ''; + ob_start(function (string $chunk) use (&$buffer): void { + $buffer .= $chunk; + }); + + self::createClient()->request('POST', '/json_stream_resources', [ + 'json' => [ + 'title' => 'asd', + 'views' => 0, + 'createdAt' => '2024-01-01T12:00:00+00:00', + 'publishedAt' => '2024-01-01T12:00:00+00:00', + 'rating' => 0.0, + 'isFeatured' => false, + 'price' => '0.00', + ], + 'headers' => ['content-type' => 'application/json', 'accept' => 'application/json'], + ]); + + ob_get_clean(); + + $res = json_decode($buffer, true); + + $this->assertResponseIsSuccessful(); + $this->assertSame('asd', $res['title']); + $this->assertSame(0, $res['views']); + $this->assertSame(0, $res['rating']); + $this->assertFalse($res['isFeatured']); + $this->assertSame('0', $res['price']); + $this->assertArrayNotHasKey('@id', $res); + $this->assertArrayNotHasKey('@type', $res); + $this->assertArrayNotHasKey('@context', $res); + + $container = static::getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + $jsonStreamResource = $manager->find(JsonStreamResource::class, $res['id']); + $this->assertNotNull($jsonStreamResource); + } +} diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 2439dcb69d0..000d321c2d9 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -23,6 +23,7 @@ use Symfony\Component\Config\Definition\Exception\InvalidTypeException; use Symfony\Component\Config\Definition\Processor; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\JsonStreamer\JsonStreamWriter; use Symfony\Component\Serializer\Exception\ExceptionInterface; /** @@ -73,6 +74,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm $this->assertEquals([ 'title' => 'title', 'description' => 'description', + 'enable_json_streamer' => class_exists(JsonStreamWriter::class), 'version' => '1.0.0', 'show_webby' => true, 'formats' => [ From 54da3dc9bc8b67ae04ff5ffbe85e449401963fee Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 20 Aug 2025 15:50:17 +0200 Subject: [PATCH 2/2] listeners --- composer.json | 2 +- src/Hydra/State/JsonStreamerProcessor.php | 8 +-- .../State/JsonStreamerProcessor.php | 8 +-- .../ApiPlatformExtension.php | 15 +++- .../Resources/config/json_streamer/common.xml | 39 +++++++++++ .../Resources/config/json_streamer/events.xml | 63 +++++++++++++++++ .../Resources/config/json_streamer/hydra.xml | 32 --------- .../JsonStreamerDeserializeListener.php | 68 +++++++++++++++++++ .../JsonStreamerSerializeListener.php | 63 +++++++++++++++++ 9 files changed, 254 insertions(+), 44 deletions(-) create mode 100644 src/Symfony/Bundle/Resources/config/json_streamer/common.xml create mode 100644 src/Symfony/Bundle/Resources/config/json_streamer/events.xml create mode 100644 src/Symfony/EventListener/JsonStreamerDeserializeListener.php create mode 100644 src/Symfony/EventListener/JsonStreamerSerializeListener.php diff --git a/composer.json b/composer.json index cc07d4b3720..289ff2f3c92 100644 --- a/composer.json +++ b/composer.json @@ -116,7 +116,7 @@ "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/translation-contracts": "^3.3", - "symfony/type-info": "7.4.x-dev", + "symfony/type-info": "^7.3 || 7.4.x-dev", "symfony/validator": "^6.4 || ^7.1", "symfony/web-link": "^6.4 || ^7.1", "willdurand/negotiation": "^3.1" diff --git a/src/Hydra/State/JsonStreamerProcessor.php b/src/Hydra/State/JsonStreamerProcessor.php index e98cf7a33a9..967316ccd8f 100644 --- a/src/Hydra/State/JsonStreamerProcessor.php +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -45,11 +45,11 @@ final class JsonStreamerProcessor implements ProcessorInterface use SearchHelperTrait; /** - * @param ProcessorInterface $processor + * @param ProcessorInterface|null $processor * @param StreamWriterInterface> $jsonStreamer */ public function __construct( - private readonly ProcessorInterface $processor, + private readonly ?ProcessorInterface $processor, private readonly StreamWriterInterface $jsonStreamer, ?IriConverterInterface $iriConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, @@ -73,7 +73,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = || !$operation->getJsonStream() || 'jsonld' !== $request->getRequestFormat() ) { - return $this->processor->process($data, $operation, $uriVariables, $context); + return $this->processor?->process($data, $operation, $uriVariables, $context); } if ($operation instanceof CollectionOperationInterface) { @@ -115,6 +115,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $this->getHeaders($request, $operation, $context) ); - return $this->processor->process($response, $operation, $uriVariables, $context); + return $this->processor ? $this->processor->process($response, $operation, $uriVariables, $context) : $response; } } diff --git a/src/Serializer/State/JsonStreamerProcessor.php b/src/Serializer/State/JsonStreamerProcessor.php index 76f6b04defb..d81651bfd7c 100644 --- a/src/Serializer/State/JsonStreamerProcessor.php +++ b/src/Serializer/State/JsonStreamerProcessor.php @@ -37,11 +37,11 @@ final class JsonStreamerProcessor implements ProcessorInterface use HttpResponseStatusTrait; /** - * @param ProcessorInterface $processor + * @param ProcessorInterface|null $processor * @param StreamWriterInterface> $jsonStreamer */ public function __construct( - private readonly ProcessorInterface $processor, + private readonly ?ProcessorInterface $processor, private readonly StreamWriterInterface $jsonStreamer, ?IriConverterInterface $iriConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, @@ -62,7 +62,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = || !$operation->getJsonStream() || 'json' !== $request->getRequestFormat() ) { - return $this->processor->process($data, $operation, $uriVariables, $context); + return $this->processor?->process($data, $operation, $uriVariables, $context); } if ($operation instanceof CollectionOperationInterface) { @@ -86,6 +86,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $this->getHeaders($request, $operation, $context) ); - return $this->processor->process($response, $operation, $uriVariables, $context); + return $this->processor ? $this->processor->process($response, $operation, $uriVariables, $context) : $response; } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index ad8efaf6b0b..3f17569c555 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -993,11 +993,20 @@ private function registerJsonStreamerConfiguration(ContainerBuilder $container, $container->setParameter('.json_streamer.stream_writers_dir.jsonld', '%kernel.cache_dir%/json_streamer/stream_writer/jsonld'); $container->setParameter('.json_streamer.stream_readers_dir.jsonld', '%kernel.cache_dir%/json_streamer/stream_reader/jsonld'); $container->setParameter('.json_streamer.lazy_ghosts_dir.jsonld', '%kernel.cache_dir%/json_streamer/lazy_ghost/jsonld'); - $loader->load('json_streamer/hydra.xml'); } - if (isset($formats['json'])) { - $loader->load('json_streamer/json.xml'); + $loader->load('json_streamer/common.xml'); + + if ($config['use_symfony_listeners']) { + $loader->load('json_streamer/events.xml'); + } else { + if (isset($formats['jsonld'])) { + $loader->load('json_streamer/hydra.xml'); + } + + if (isset($formats['json'])) { + $loader->load('json_streamer/json.xml'); + } } } } diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/common.xml b/src/Symfony/Bundle/Resources/config/json_streamer/common.xml new file mode 100644 index 00000000000..38fe492a19d --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/json_streamer/common.xml @@ -0,0 +1,39 @@ + + + + + + + %.json_streamer.stream_writers_dir.jsonld% + + + + + + %.json_streamer.stream_readers_dir.jsonld% + %.json_streamer.lazy_ghosts_dir.jsonld% + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/events.xml b/src/Symfony/Bundle/Resources/config/json_streamer/events.xml new file mode 100644 index 00000000000..40d5ea7059c --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/json_streamer/events.xml @@ -0,0 +1,63 @@ + + + + + null + + + + + %api_platform.collection.pagination.page_parameter_name% + %api_platform.collection.pagination.enabled_parameter_name% + %api_platform.url_generation_strategy% + + + + null + + + + + null + + + + + + + + null + + + + + + json + + + + + + + jsonld + + + + + + + json + + + + + + + jsonld + + + + + diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/hydra.xml b/src/Symfony/Bundle/Resources/config/json_streamer/hydra.xml index 9cbdcad8111..4caf369befe 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/hydra.xml +++ b/src/Symfony/Bundle/Resources/config/json_streamer/hydra.xml @@ -3,38 +3,6 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - - - - %.json_streamer.stream_writers_dir.jsonld% - - - - - - %.json_streamer.stream_readers_dir.jsonld% - %.json_streamer.lazy_ghosts_dir.jsonld% - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Symfony/EventListener/JsonStreamerDeserializeListener.php b/src/Symfony/EventListener/JsonStreamerDeserializeListener.php new file mode 100644 index 00000000000..aebc9cf6182 --- /dev/null +++ b/src/Symfony/EventListener/JsonStreamerDeserializeListener.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\EventListener; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\Util\OperationRequestInitiatorTrait; +use ApiPlatform\State\Util\RequestAttributesExtractor; +use Symfony\Component\HttpKernel\Event\RequestEvent; + +/** + * Deserializes the data sent in the requested format using JSON Streamer. + * + * @author Kévin Dunglas + */ +final class JsonStreamerDeserializeListener +{ + use OperationRequestInitiatorTrait; + + /** + * @param ProviderInterface $jsonStreamerProvider + */ + public function __construct( + private ProviderInterface $jsonStreamerProvider, + private readonly string $format, + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, + ) { + $this->resourceMetadataCollectionFactory = $resourceMetadataFactory; + } + + /** + * Deserializes the data sent in the requested format. + */ + public function onKernelRequest(RequestEvent $event): void + { + $request = $event->getRequest(); + $operation = $this->initializeOperation($request); + + if ( + !($attributes = RequestAttributesExtractor::extractAttributes($request)) + || !$attributes['receive'] + || !$operation + || !$operation->getJsonStream() + || $this->format !== $request->attributes->get('input_format') + ) { + return; + } + + $data = $this->jsonStreamerProvider->provide($operation, $request->attributes->get('_api_uri_variables') ?? [], [ + 'request' => $request, + 'uri_variables' => $request->attributes->get('_api_uri_variables') ?? [], + 'resource_class' => $operation->getClass(), + ]); + + $request->attributes->set('data', $data); + } +} diff --git a/src/Symfony/EventListener/JsonStreamerSerializeListener.php b/src/Symfony/EventListener/JsonStreamerSerializeListener.php new file mode 100644 index 00000000000..9655ceb3d36 --- /dev/null +++ b/src/Symfony/EventListener/JsonStreamerSerializeListener.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\EventListener; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\Util\OperationRequestInitiatorTrait; +use ApiPlatform\State\Util\RequestAttributesExtractor; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ViewEvent; + +/** + * Serializes data using JSON Streamer. + * + * @author Kévin Dunglas + */ +final class JsonStreamerSerializeListener +{ + use OperationRequestInitiatorTrait; + + /** + * @param ProcessorInterface $jsonStreamerProcessor + */ + public function __construct(private readonly ProcessorInterface $jsonStreamerProcessor, private readonly string $format, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null) + { + $this->resourceMetadataCollectionFactory = $resourceMetadataFactory; + } + + /** + * Creates a Response to send to the client according to the requested format. + */ + public function onKernelView(ViewEvent $event): void + { + $request = $event->getRequest(); + $operation = $this->initializeOperation($request); + + $attributes = RequestAttributesExtractor::extractAttributes($request); + if (!($attributes['respond'] ?? $request->attributes->getBoolean('_api_respond')) || !$operation || !$operation->getJsonStream() || $this->format !== $request->getRequestFormat()) { + return; + } + + $uriVariables = $request->attributes->get('_api_uri_variables') ?? []; + $response = $this->jsonStreamerProcessor->process($event->getControllerResult(), $operation, $uriVariables, [ + 'request' => $request, + 'uri_variables' => $uriVariables, + 'resource_class' => $operation->getClass(), + 'original_data' => $request->attributes->get('original_data'), + ]); + + $event->setResponse($response); + } +}