From 4cd5e41f44e165952f163b8bf8d4823e9c716e4f Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 25 Jul 2025 21:25:13 +0200 Subject: [PATCH 1/3] feat: json streamer --- composer.json | 1 + src/Hydra/Collection.php | 34 ++++ src/Hydra/CollectionId.php | 10 ++ src/Hydra/IriTemplate.php | 30 ++++ src/Hydra/IriTemplateMapping.php | 29 ++++ src/Hydra/PartialCollectionView.php | 36 ++++ src/Hydra/State/JsonStreamerProcessor.php | 162 ++++++++++++++++++ src/Hydra/State/JsonStreamerProvider.php | 46 +++++ .../JsonStreamer/IriValueTransformer.php | 38 ++++ .../ReadPropertyMetadataLoader.php | 26 +++ .../WritePropertyMetadataLoader.php | 72 ++++++++ src/State/Processor/SerializeProcessor.php | 9 +- src/State/Provider/DeserializeProvider.php | 2 +- .../ApiPlatformExtension.php | 8 + .../DependencyInjection/Configuration.php | 2 + .../Bundle/Resources/config/json_streamer.xml | 50 ++++++ .../ApiPlatformExtensionTest.php | 2 + .../TestBundle/Entity/JsonStreamResource.php | 52 ++++++ tests/Functional/JsonLdTest.php | 16 +- tests/Functional/JsonStreamerTest.php | 155 +++++++++++++++++ .../DependencyInjection/ConfigurationTest.php | 2 + 21 files changed, 776 insertions(+), 6 deletions(-) create mode 100644 src/Hydra/Collection.php create mode 100644 src/Hydra/CollectionId.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/JsonLd/JsonStreamer/IriValueTransformer.php create mode 100644 src/JsonLd/JsonStreamer/ReadPropertyMetadataLoader.php create mode 100644 src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php create mode 100644 src/Symfony/Bundle/Resources/config/json_streamer.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 995f25bb3cb..74578896706 100644 --- a/composer.json +++ b/composer.json @@ -111,6 +111,7 @@ "symfony/deprecation-contracts": "^3.1", "symfony/http-foundation": "^6.4 || ^7.0", "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/json-streamer": "^7.3", "symfony/property-access": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", diff --git a/src/Hydra/Collection.php b/src/Hydra/Collection.php new file mode 100644 index 00000000000..b268643ebdb --- /dev/null +++ b/src/Hydra/Collection.php @@ -0,0 +1,34 @@ + + */ + public iterable $member; +} diff --git a/src/Hydra/CollectionId.php b/src/Hydra/CollectionId.php new file mode 100644 index 00000000000..f4d1221575f --- /dev/null +++ b/src/Hydra/CollectionId.php @@ -0,0 +1,10 @@ + + * + * 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; + +final class IriTemplate +{ + #[StreamedName('@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..d3160c598d6 --- /dev/null +++ b/src/Hydra/IriTemplateMapping.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Hydra; + +use Symfony\Component\JsonStreamer\Attribute\StreamedName; + +class IriTemplateMapping +{ + #[StreamedName('@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/State/JsonStreamerProcessor.php b/src/Hydra/State/JsonStreamerProcessor.php new file mode 100644 index 00000000000..9b6a925bac5 --- /dev/null +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -0,0 +1,162 @@ + + * + * 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\IriTemplate; +use ApiPlatform\Hydra\IriTemplateMapping; +use ApiPlatform\Hydra\PartialCollectionView; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\QueryParameterInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Metadata\Util\IriHelper; +use ApiPlatform\State\Pagination\PaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\JsonStreamer\StreamWriterInterface; +use Symfony\Component\TypeInfo\Type; + +final class JsonStreamerProcessor implements ProcessorInterface +{ + public function __construct( + private readonly ProcessorInterface $processor, + private readonly StreamWriterInterface $jsonStreamer, + private readonly string $pageParameterName = 'page', + private readonly string $enabledParameterName = 'pagination', + private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH + ) { + } + + private function getSearch(Operation $operation, string $requestUri): IriTemplate + { + /** @var list */ + $mapping = []; + $keys = []; + + foreach ($operation->getParameters() ?? [] as $key => $parameter) { + if (!$parameter instanceof QueryParameterInterface || false === $parameter->getHydra()) { + continue; + } + + if (!($property = $parameter->getProperty())) { + continue; + } + + $keys[] = $key; + $m = new IriTemplateMapping( + variable: $key, + property: $property, + required: $parameter->getRequired() ?? false + ); + $mapping[] = $m; + } + + $parts = parse_url($requestUri); + return new IriTemplate( + variableRepresentation: 'BasicRepresentation', + mapping: $mapping, + template: \sprintf('%s{?%s}', $parts['path'] ?? '', implode(',', $keys)), + ); + } + + private function getView(mixed $object, string $requestUri, Operation $operation): PartialCollectionView + { + $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(); + } + + // 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($requestUri ?? '/', $this->pageParameterName); + $appliedFilters = $parsed['parameters']; + unset($appliedFilters[$this->enabledParameterName]); + + $urlGenerationStrategy = $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy; + $id = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy); + if (!$appliedFilters && !$paginated) { + return new PartialCollectionView($id); + } + + $first = $last = $previous = $next = null; + if (null !== $lastPage) { + $first = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy); + $last = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy); + } + + if (1. !== $currentPage) { + $previous = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy); + } + + if ((null !== $lastPage && $currentPage < $lastPage) || (null === $lastPage && $pageTotalItems >= $itemsPerPage)) { + $next = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy); + } + + return new PartialCollectionView($id, $first, $last, $previous, $next); + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + { + if ($context['request']->query->has('skip_json_stream')) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof Error || $data instanceof Response) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof CollectionOperationInterface) { + $requestUri = $context['request']->getRequestUri() ?? ''; + $collection = new Collection(); + $collection->member = $data; + $collection->view = $this->getView($data, $requestUri, $operation); + + if ($operation->getParameters()) { + $collection->search = $this->getSearch($operation, $requestUri); + } + + if ($data instanceof PaginatorInterface) { + $collection->totalItems = $data->getTotalItems(); + } + + if (\is_array($data) || ($data instanceof \Countable && !$data instanceof PartialPaginatorInterface)) { + $collection->totalItems = \count($data); + } + + $response = new StreamedResponse($this->jsonStreamer->write($collection, Type::generic(Type::object($collection::class), Type::object($operation->getClass())), [ + 'data' => $data, + 'operation' => $operation, + ])); + } else { + $response = new StreamedResponse($this->jsonStreamer->write($data, Type::object($operation->getClass()), [ + 'data' => $data, + 'operation' => $operation, + ])); + } + + 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..789dc0d17c3 --- /dev/null +++ b/src/Hydra/State/JsonStreamerProvider.php @@ -0,0 +1,46 @@ + + * + * 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 || !($request = $context['request'] ?? null)) { + return $this->decorated?->provide($operation, $uriVariables, $context); + } + + $data = $this->decorated ? $this->decorated->provide($operation, $uriVariables, $context) : $request->attributes->get('data'); + + if (!$operation->canDeserialize()) { + return $data; + } + + $context['request']->attributes->set('deserialized', true); + + return $this->jsonStreamReader->read($request->getContent(true), Type::object($operation->getClass())); + } +} diff --git a/src/JsonLd/JsonStreamer/IriValueTransformer.php b/src/JsonLd/JsonStreamer/IriValueTransformer.php new file mode 100644 index 00000000000..e0885c60d4a --- /dev/null +++ b/src/JsonLd/JsonStreamer/IriValueTransformer.php @@ -0,0 +1,38 @@ +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/ReadPropertyMetadataLoader.php b/src/JsonLd/JsonStreamer/ReadPropertyMetadataLoader.php new file mode 100644 index 00000000000..a362afc3739 --- /dev/null +++ b/src/JsonLd/JsonStreamer/ReadPropertyMetadataLoader.php @@ -0,0 +1,26 @@ +loader->load($className, $options, $context); + + return $properties; + } +} diff --git a/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php b/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php new file mode 100644 index 00000000000..baaadd70fbd --- /dev/null +++ b/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php @@ -0,0 +1,72 @@ + + * + * 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\IriTemplate; +use ApiPlatform\Hydra\Collection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +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, + private readonly UrlGeneratorInterface $urlGenerator, + ) { + } + + 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; + } + + // Missing @type => $operation->getShortName + + $properties['@id'] = new PropertyMetadata( + 'id', // virtual property + Type::mixed(), // virtual property + ['api_platform.jsonld.json_streamer.write.value_transformer.iri'], + ); + + $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 + staticValue: $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $options['operation']->getShortName()], $options['operation']->getUrlGenerationStrategy()), + ); + } + + return $properties; + } +} diff --git a/src/State/Processor/SerializeProcessor.php b/src/State/Processor/SerializeProcessor.php index b56bd332a4d..07bd1565b16 100644 --- a/src/State/Processor/SerializeProcessor.php +++ b/src/State/Processor/SerializeProcessor.php @@ -18,6 +18,8 @@ use ApiPlatform\State\ResourceList; use ApiPlatform\State\SerializerContextBuilderInterface; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\JsonStreamer\StreamWriterInterface; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\WebLink\GenericLinkProvider; @@ -38,8 +40,11 @@ final class SerializeProcessor implements ProcessorInterface /** * @param ProcessorInterface|null $processor */ - public function __construct(private readonly ?ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder) - { + public function __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 e911d3018b0..37e28691198 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -55,7 +55,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/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 45136e4d3dd..67574f87920 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($loader, $config); if (class_exists(ObjectMapper::class)) { $loader->load('state/object_mapper.xml'); @@ -952,4 +953,11 @@ private function registerLinkSecurityConfiguration(XmlFileLoader $loader, array $loader->load('link_security.xml'); } } + + private function registerJsonStreamerConfiguration(XmlFileLoader $loader, array $config): void + { + if ($config['enable_json_streamer']) { + $loader->load('json_streamer.xml'); + } + } } diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 493c98523d0..dfb73b6c852 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 the json stream writerw.')->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.xml b/src/Symfony/Bundle/Resources/config/json_streamer.xml new file mode 100644 index 00000000000..5fbb4d7be88 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/json_streamer.xml @@ -0,0 +1,50 @@ + + + + + + + %.json_streamer.stream_writers_dir% + + + + + + %.json_streamer.stream_readers_dir% + %.json_streamer.lazy_ghosts_dir% + + + + + + + + + + + + + + + + + + + + + + + %api_platform.collection.pagination.page_parameter_name% + %api_platform.collection.pagination.enabled_parameter_name% + %api_platform.url_generation_strategy% + + + + + + + + + diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index ded589e35f1..69f22b0cb19 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -35,6 +35,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\JsonStreamer\JsonStreamWriter; class ApiPlatformExtensionTest extends TestCase { @@ -42,6 +43,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..a8e7a40312b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/JsonStreamResource.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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity()] +#[ORM\Table(name: 'json_stream_resource')] +#[ApiResource( + 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 62188b6bffb..759af07a915 100644 --- a/tests/Functional/JsonLdTest.php +++ b/tests/Functional/JsonLdTest.php @@ -49,11 +49,19 @@ public function testIssue6465(): void $this->markTestSkipped(); } - $response = self::createClient()->request('POST', '/foo/1/validate', [ + $buffer = ''; + ob_start(function (string $chunk) use (&$buffer): void { + $buffer .= $chunk; + }); + + self::createClient()->request('POST', '/foo/1/validate', [ 'json' => ['bar' => '/bar6465s/2'], ]); - $res = $response->toArray(); + ob_get_clean(); + + $res = json_decode($buffer, true); + dump($res); $this->assertEquals('Bar two', $res['title']); } @@ -123,12 +131,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..cbc6a6f30ae --- /dev/null +++ b/tests/Functional/JsonStreamerTest.php @@ -0,0 +1,155 @@ +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 < 1000; ++$i) { + $resource = new JsonStreamResource(); + $resource->title = 'Title ' . $i; + // $resource->createdAt = new \DateTimeImmutable(); + // $resource->publishedAt = new \DateTimeImmutable(); + $resource->views = rand(1, 1000); + $resource->rating = rand(1, 5); + $resource->isFeatured = (bool) rand(0, 1); + $resource->price = number_format((float) rand(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 testJsonStreamer(): 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); + dump($res); + } + + public function testJsonStreamerCollection(): 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); + dump($res); + } + + public function testJsonStreamerWrite(): 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('POST', '/json_stream_resources', [ + 'json' => [ + 'title' => 'asd', + 'views' => 0, + 'rating' => 0.0, + 'isFeatured' => false, + 'price' => '0.00', + ], + ]); + + ob_get_clean(); + + $res = json_decode($buffer, true); + dump($res); + } +} 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 0a313c6840e5750f40576cc1d2f2bd609f775d66 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 27 Jun 2025 17:09:42 +0200 Subject: [PATCH 2/3] add flag metadata + few fixes + headers/status --- composer.json | 6 +- src/Hydra/Collection.php | 9 ++ src/Hydra/CollectionId.php | 9 ++ src/Hydra/State/JsonStreamerProcessor.php | 127 +++++++++++------- src/Hydra/State/JsonStreamerProvider.php | 2 +- .../ReadPropertyMetadataLoader.php | 26 ---- .../ContextValueTransformer.php | 40 ++++++ .../IriValueTransformer.php | 16 ++- .../ValueTransformer/TypeValueTransformer.php | 40 ++++++ .../WritePropertyMetadataLoader.php | 14 +- src/Metadata/ApiResource.php | 2 + src/Metadata/Delete.php | 2 + src/Metadata/Error.php | 2 + src/Metadata/ErrorResource.php | 2 + 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 + src/State/Processor/RespondProcessor.php | 115 ++-------------- src/State/Processor/SerializeProcessor.php | 2 - src/State/Util/HttpResponseHeadersTrait.php | 127 ++++++++++++++++++ src/State/Util/HttpResponseStatusTrait.php | 56 ++++++++ .../Bundle/Resources/config/json_streamer.xml | 21 +-- .../ApiPlatformExtensionTest.php | 1 - .../TestBundle/Entity/JsonStreamResource.php | 1 + tests/Functional/JsonStreamerTest.php | 59 ++++++-- 30 files changed, 501 insertions(+), 206 deletions(-) delete mode 100644 src/JsonLd/JsonStreamer/ReadPropertyMetadataLoader.php create mode 100644 src/JsonLd/JsonStreamer/ValueTransformer/ContextValueTransformer.php rename src/JsonLd/JsonStreamer/{ => ValueTransformer}/IriValueTransformer.php (71%) create mode 100644 src/JsonLd/JsonStreamer/ValueTransformer/TypeValueTransformer.php create mode 100644 src/State/Util/HttpResponseHeadersTrait.php create mode 100644 src/State/Util/HttpResponseStatusTrait.php diff --git a/composer.json b/composer.json index 74578896706..d6c68a5f7fa 100644 --- a/composer.json +++ b/composer.json @@ -111,7 +111,6 @@ "symfony/deprecation-contracts": "^3.1", "symfony/http-foundation": "^6.4 || ^7.0", "symfony/http-kernel": "^6.4 || ^7.0", - "symfony/json-streamer": "^7.3", "symfony/property-access": "^6.4 || ^7.0", "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", @@ -176,13 +175,14 @@ "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", - "symfony/object-mapper": "^7.3", + "symfony/object-mapper": "7.4.x-dev", "symfony/routing": "^6.4 || ^7.0", "symfony/security-bundle": "^6.4 || ^7.0", "symfony/security-core": "^6.4 || ^7.0", diff --git a/src/Hydra/Collection.php b/src/Hydra/Collection.php index b268643ebdb..bac9d0a21db 100644 --- a/src/Hydra/Collection.php +++ b/src/Hydra/Collection.php @@ -1,5 +1,14 @@ + * + * 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; diff --git a/src/Hydra/CollectionId.php b/src/Hydra/CollectionId.php index f4d1221575f..33d0e8bdd38 100644 --- a/src/Hydra/CollectionId.php +++ b/src/Hydra/CollectionId.php @@ -1,5 +1,14 @@ + * + * 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; diff --git a/src/Hydra/State/JsonStreamerProcessor.php b/src/Hydra/State/JsonStreamerProcessor.php index 9b6a925bac5..9f6dcf2e737 100644 --- a/src/Hydra/State/JsonStreamerProcessor.php +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -19,29 +19,107 @@ use ApiPlatform\Hydra\PartialCollectionView; 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\QueryParameterInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\IriHelper; 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; + + /** + * @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 + 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->getJsonStream() || !($request = $context['request'] ?? null)) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + // TODO: remove this before merging + if ($request->query->has('skip_json_stream')) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof Error || $data instanceof Response || !$operation instanceof HttpOperation) { + return $this->processor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof CollectionOperationInterface) { + $requestUri = $request->getRequestUri() ?? ''; + $collection = new Collection(); + $collection->member = $data; + $collection->view = $this->getView($data, $requestUri, $operation); + + if ($operation->getParameters()) { + $collection->search = $this->getSearch($operation, $requestUri); + } + + 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); + } + + // TODO: These come from our Hydra collection normalizer, try to share the logic private function getSearch(Operation $operation, string $requestUri): IriTemplate { /** @var list */ @@ -67,6 +145,7 @@ private function getSearch(Operation $operation, string $requestUri): IriTemplat } $parts = parse_url($requestUri); + return new IriTemplate( variableRepresentation: 'BasicRepresentation', mapping: $mapping, @@ -91,11 +170,11 @@ private function getView(mixed $object, string $requestUri, Operation $operation // 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($requestUri ?? '/', $this->pageParameterName); + $parsed = IriHelper::parseIri($requestUri, $this->pageParameterName); $appliedFilters = $parsed['parameters']; unset($appliedFilters[$this->enabledParameterName]); - $urlGenerationStrategy = $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy; + $urlGenerationStrategy = $operation->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy; $id = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy); if (!$appliedFilters && !$paginated) { return new PartialCollectionView($id); @@ -117,46 +196,4 @@ private function getView(mixed $object, string $requestUri, Operation $operation return new PartialCollectionView($id, $first, $last, $previous, $next); } - - public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) - { - if ($context['request']->query->has('skip_json_stream')) { - return $this->processor->process($data, $operation, $uriVariables, $context); - } - - if ($operation instanceof Error || $data instanceof Response) { - return $this->processor->process($data, $operation, $uriVariables, $context); - } - - if ($operation instanceof CollectionOperationInterface) { - $requestUri = $context['request']->getRequestUri() ?? ''; - $collection = new Collection(); - $collection->member = $data; - $collection->view = $this->getView($data, $requestUri, $operation); - - if ($operation->getParameters()) { - $collection->search = $this->getSearch($operation, $requestUri); - } - - if ($data instanceof PaginatorInterface) { - $collection->totalItems = $data->getTotalItems(); - } - - if (\is_array($data) || ($data instanceof \Countable && !$data instanceof PartialPaginatorInterface)) { - $collection->totalItems = \count($data); - } - - $response = new StreamedResponse($this->jsonStreamer->write($collection, Type::generic(Type::object($collection::class), Type::object($operation->getClass())), [ - 'data' => $data, - 'operation' => $operation, - ])); - } else { - $response = new StreamedResponse($this->jsonStreamer->write($data, Type::object($operation->getClass()), [ - 'data' => $data, - 'operation' => $operation, - ])); - } - - return $this->processor->process($response, $operation, $uriVariables, $context); - } } diff --git a/src/Hydra/State/JsonStreamerProvider.php b/src/Hydra/State/JsonStreamerProvider.php index 789dc0d17c3..815fb570ddc 100644 --- a/src/Hydra/State/JsonStreamerProvider.php +++ b/src/Hydra/State/JsonStreamerProvider.php @@ -29,7 +29,7 @@ public function __construct( public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - if (!$operation instanceof HttpOperation || !($request = $context['request'] ?? null)) { + if (!$operation instanceof HttpOperation || !$operation->getJsonStream() || !($request = $context['request'] ?? null)) { return $this->decorated?->provide($operation, $uriVariables, $context); } diff --git a/src/JsonLd/JsonStreamer/ReadPropertyMetadataLoader.php b/src/JsonLd/JsonStreamer/ReadPropertyMetadataLoader.php deleted file mode 100644 index a362afc3739..00000000000 --- a/src/JsonLd/JsonStreamer/ReadPropertyMetadataLoader.php +++ /dev/null @@ -1,26 +0,0 @@ -loader->load($className, $options, $context); - - return $properties; - } -} diff --git a/src/JsonLd/JsonStreamer/ValueTransformer/ContextValueTransformer.php b/src/JsonLd/JsonStreamer/ValueTransformer/ContextValueTransformer.php new file mode 100644 index 00000000000..ac30ae39e24 --- /dev/null +++ b/src/JsonLd/JsonStreamer/ValueTransformer/ContextValueTransformer.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\Metadata\UrlGeneratorInterface; +use Symfony\Component\JsonStreamer\ValueTransformer\ValueTransformerInterface; +use Symfony\Component\TypeInfo\Type; +use ApiPlatform\Metadata\Exception\RuntimeException; + +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/IriValueTransformer.php b/src/JsonLd/JsonStreamer/ValueTransformer/IriValueTransformer.php similarity index 71% rename from src/JsonLd/JsonStreamer/IriValueTransformer.php rename to src/JsonLd/JsonStreamer/ValueTransformer/IriValueTransformer.php index e0885c60d4a..383ed920c8a 100644 --- a/src/JsonLd/JsonStreamer/IriValueTransformer.php +++ b/src/JsonLd/JsonStreamer/ValueTransformer/IriValueTransformer.php @@ -1,11 +1,21 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + declare(strict_types=1); -namespace ApiPlatform\JsonLd\JsonStreamer; +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; @@ -20,6 +30,10 @@ public function __construct( 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']); } 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 index baaadd70fbd..a3cba6c0246 100644 --- a/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php +++ b/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php @@ -13,10 +13,9 @@ namespace ApiPlatform\JsonLd\JsonStreamer; -use ApiPlatform\Hydra\IriTemplate; use ApiPlatform\Hydra\Collection; +use ApiPlatform\Hydra\IriTemplate; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\TypeHelper; use Symfony\Component\JsonStreamer\Mapping\PropertyMetadata; use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface; @@ -27,7 +26,6 @@ final class WritePropertyMetadataLoader implements PropertyMetadataLoaderInterfa public function __construct( private readonly PropertyMetadataLoaderInterface $loader, private readonly ResourceClassResolverInterface $resourceClassResolver, - private readonly UrlGeneratorInterface $urlGenerator, ) { } @@ -49,21 +47,25 @@ public function load(string $className, array $options = [], array $context = [] return $properties; } - // Missing @type => $operation->getShortName - $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 - staticValue: $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $options['operation']->getShortName()], $options['operation']->getUrlGenerationStrategy()), + ['api_platform.jsonld.json_streamer.write.value_transformer.context'], ); } 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 b0ed5a30336..1c32196904b 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/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 493459a4676..6a11680845f 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 5f5bf50b5c3..a3c284e6650 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) { @@ -678,4 +679,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/State/Processor/RespondProcessor.php b/src/State/Processor/RespondProcessor.php index 58941e437c3..47fce98190b 100644 --- a/src/State/Processor/RespondProcessor.php +++ b/src/State/Processor/RespondProcessor.php @@ -13,22 +13,15 @@ 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\Util\HttpResponseHeadersTrait; +use ApiPlatform\State\Util\HttpResponseStatusTrait; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; /** * Serializes data. @@ -37,19 +30,17 @@ */ final class RespondProcessor implements ProcessorInterface { - use ClassInfoTrait; - use CloneTrait; - - public const METHOD_TO_CODE = [ - 'POST' => Response::HTTP_CREATED, - 'DELETE' => Response::HTTP_NO_CONTENT, - ]; + use HttpResponseHeadersTrait; + use HttpResponseStatusTrait; 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 = []) @@ -62,92 +53,10 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return $data; } - $headers = [ - 'Content-Type' => \sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())), - 'Vary' => 'Accept', - 'X-Content-Type-Options' => 'nosniff', - 'X-Frame-Options' => 'deny', - ]; - - $exception = $request->attributes->get('exception'); - if (($exception instanceof HttpExceptionInterface || $exception instanceof SymfonyHttpExceptionInterface) && $exceptionHeaders = $exception->getHeaders()) { - $headers = array_merge($headers, $exceptionHeaders); - } - - if ($operationHeaders = $operation->getHeaders()) { - $headers = array_merge($headers, $operationHeaders); - } - - $status = $operation->getStatus(); - - if ($sunset = $operation->getSunset()) { - $headers['Sunset'] = (new \DateTimeImmutable($sunset))->format(\DateTimeInterface::RFC1123); - } - - if ($acceptPatch = $operation->getAcceptPatch()) { - $headers['Accept-Patch'] = $acceptPatch; - } - - $method = $request->getMethod(); - $originalData = $context['original_data'] ?? null; - - $outputMetadata = $operation->getOutput() ?? ['class' => $operation->getClass()]; - $hasOutput = \is_array($outputMetadata) && \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class']; - $hasData = !$hasOutput ? false : ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))); - - if ($hasData) { - $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) { - } - } - return new Response( $data, - $status, - $headers + $this->getStatus($request, $operation, $context), + $this->getHeaders($request, $operation, $context) ); } } diff --git a/src/State/Processor/SerializeProcessor.php b/src/State/Processor/SerializeProcessor.php index 07bd1565b16..74d174d0c21 100644 --- a/src/State/Processor/SerializeProcessor.php +++ b/src/State/Processor/SerializeProcessor.php @@ -18,8 +18,6 @@ use ApiPlatform\State\ResourceList; use ApiPlatform\State\SerializerContextBuilderInterface; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\StreamedResponse; -use Symfony\Component\JsonStreamer\StreamWriterInterface; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\WebLink\GenericLinkProvider; 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/Resources/config/json_streamer.xml b/src/Symfony/Bundle/Resources/config/json_streamer.xml index 5fbb4d7be88..d1f30296d02 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer.xml +++ b/src/Symfony/Bundle/Resources/config/json_streamer.xml @@ -11,7 +11,7 @@ - + %.json_streamer.stream_readers_dir% %.json_streamer.lazy_ghosts_dir% @@ -19,23 +19,28 @@ - - - - - + + + - - + + + + + + + + + %api_platform.collection.pagination.page_parameter_name% %api_platform.collection.pagination.enabled_parameter_name% %api_platform.url_generation_strategy% diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 69f22b0cb19..1adf558453b 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -35,7 +35,6 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\JsonStreamer\JsonStreamWriter; class ApiPlatformExtensionTest extends TestCase { diff --git a/tests/Fixtures/TestBundle/Entity/JsonStreamResource.php b/tests/Fixtures/TestBundle/Entity/JsonStreamResource.php index a8e7a40312b..fc17f235da4 100644 --- a/tests/Fixtures/TestBundle/Entity/JsonStreamResource.php +++ b/tests/Fixtures/TestBundle/Entity/JsonStreamResource.php @@ -19,6 +19,7 @@ #[ORM\Entity()] #[ORM\Table(name: 'json_stream_resource')] #[ApiResource( + jsonStream: true, paginationEnabled: false, normalizationContext: ['hydra_prefix' => false] )] diff --git a/tests/Functional/JsonStreamerTest.php b/tests/Functional/JsonStreamerTest.php index cbc6a6f30ae..d6d7b1810fb 100644 --- a/tests/Functional/JsonStreamerTest.php +++ b/tests/Functional/JsonStreamerTest.php @@ -1,5 +1,16 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + namespace ApiPlatform\Tests\Functional; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; @@ -49,15 +60,15 @@ protected function setUp(): void } catch (\Exception $e) { } - for ($i = 0; $i < 1000; ++$i) { + for ($i = 0; $i < 10; ++$i) { $resource = new JsonStreamResource(); - $resource->title = 'Title ' . $i; + $resource->title = 'Title '.$i; // $resource->createdAt = new \DateTimeImmutable(); // $resource->publishedAt = new \DateTimeImmutable(); - $resource->views = rand(1, 1000); - $resource->rating = rand(1, 5); - $resource->isFeatured = (bool) rand(0, 1); - $resource->price = number_format((float) rand(10, 1000) / 100, 2, '.', ''); + $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); } @@ -84,7 +95,6 @@ protected function tearDown(): void parent::tearDown(); } - public function testJsonStreamer(): void { $container = static::getContainer(); @@ -102,7 +112,13 @@ public function testJsonStreamer(): void ob_get_clean(); $res = json_decode($buffer, true); - dump($res); + $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 testJsonStreamerCollection(): void @@ -122,7 +138,17 @@ public function testJsonStreamerCollection(): void ob_get_clean(); $res = json_decode($buffer, true); - dump($res); + + $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 testJsonStreamerWrite(): void @@ -150,6 +176,19 @@ public function testJsonStreamerWrite(): void ob_get_clean(); $res = json_decode($buffer, true); - dump($res); + + $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); } } From 7a47f7d24981fe53b59f32598d86d94237604c52 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 25 Jul 2025 18:01:40 +0200 Subject: [PATCH 3/3] refactor --- src/Hydra/IriTemplate.php | 2 + src/Hydra/IriTemplateMapping.php | 4 +- .../CollectionFiltersNormalizer.php | 95 ++------------ .../PartialCollectionViewNormalizer.php | 62 ++++----- src/Hydra/State/JsonStreamerProcessor.php | 85 ++---------- .../State/Util/PaginationHelperTrait.php | 84 ++++++++++++ src/Hydra/State/Util/SearchHelperTrait.php | 121 ++++++++++++++++++ 7 files changed, 254 insertions(+), 199 deletions(-) create mode 100644 src/Hydra/State/Util/PaginationHelperTrait.php create mode 100644 src/Hydra/State/Util/SearchHelperTrait.php diff --git a/src/Hydra/IriTemplate.php b/src/Hydra/IriTemplate.php index df2ecb466a8..78cb9782cc8 100644 --- a/src/Hydra/IriTemplate.php +++ b/src/Hydra/IriTemplate.php @@ -14,10 +14,12 @@ 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( diff --git a/src/Hydra/IriTemplateMapping.php b/src/Hydra/IriTemplateMapping.php index d3160c598d6..9cbb7502752 100644 --- a/src/Hydra/IriTemplateMapping.php +++ b/src/Hydra/IriTemplateMapping.php @@ -14,15 +14,17 @@ 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 ?string $property, public bool $required = false, ) { } diff --git a/src/Hydra/Serializer/CollectionFiltersNormalizer.php b/src/Hydra/Serializer/CollectionFiltersNormalizer.php index 44db5251c4e..895f68e6ac3 100644 --- a/src/Hydra/Serializer/CollectionFiltersNormalizer.php +++ b/src/Hydra/Serializer/CollectionFiltersNormalizer.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\State\Util\StateOptionsTrait; +use ApiPlatform\Hydra\State\Util\SearchHelperTrait; use Psr\Container\ContainerInterface; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; @@ -35,6 +36,7 @@ final class CollectionFiltersNormalizer implements NormalizerInterface, Normaliz { use HydraPrefixTrait; use StateOptionsTrait; + use SearchHelperTrait; private ?ContainerInterface $filterLocator = null; /** @@ -105,7 +107,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' => $mapping, + ]; } return $data; @@ -121,91 +129,6 @@ public function setNormalizer(NormalizerInterface $normalizer): void } } - /** - * Returns the content of the Hydra search property. - * - * @param FilterInterface[] $filters - */ - private function getSearch(string $resourceClass, array $parts, array $filters, ?Parameters $parameters, string $hydraPrefix): 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; - } - } - - 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; - } - - return ['@type' => $hydraPrefix.'IriTemplate', $hydraPrefix.'template' => \sprintf('%s{?%s}', $parts['path'], implode(',', $variables)), $hydraPrefix.'variableRepresentation' => 'BasicRepresentation', $hydraPrefix.'mapping' => $mapping]; - } - /** * Gets a filter with a backward compatibility. */ diff --git a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php index 59932f2e2f4..9ad8263f90f 100644 --- a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php +++ b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php @@ -20,6 +20,8 @@ use ApiPlatform\Metadata\Util\IriHelper; use ApiPlatform\State\Pagination\PaginatorInterface; use ApiPlatform\State\Pagination\PartialPaginatorInterface; +use ApiPlatform\Hydra\State\Util\PaginationHelperTrait; +use ApiPlatform\Hydra\PartialCollectionView; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -35,6 +37,7 @@ final class PartialCollectionViewNormalizer implements NormalizerInterface, NormalizerAwareInterface { use HydraPrefixTrait; + use PaginationHelperTrait; private readonly PropertyAccessorInterface $propertyAccessor; /** @@ -60,21 +63,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 +87,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; } @@ -168,22 +178,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 index 9f6dcf2e737..fce23331ba2 100644 --- a/src/Hydra/State/JsonStreamerProcessor.php +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -27,14 +27,15 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\IriHelper; +use ApiPlatform\State\Util\HttpResponseStatusTrait; 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 ApiPlatform\Hydra\State\Util\PaginationHelperTrait; +use ApiPlatform\Hydra\State\Util\SearchHelperTrait; use Symfony\Component\TypeInfo\Type; /** @@ -44,6 +45,8 @@ final class JsonStreamerProcessor implements ProcessorInterface { use HttpResponseHeadersTrait; use HttpResponseStatusTrait; + use PaginationHelperTrait; + use SearchHelperTrait; /** * @param ProcessorInterface $processor @@ -86,7 +89,8 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $collection->view = $this->getView($data, $requestUri, $operation); if ($operation->getParameters()) { - $collection->search = $this->getSearch($operation, $requestUri); + $parts = parse_url($requestUri); + $collection->search = $this->getSearch($parts['path'] ?? '', $operation); } if ($data instanceof PaginatorInterface) { @@ -119,81 +123,8 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return $this->processor->process($response, $operation, $uriVariables, $context); } - // TODO: These come from our Hydra collection normalizer, try to share the logic - private function getSearch(Operation $operation, string $requestUri): IriTemplate - { - /** @var list */ - $mapping = []; - $keys = []; - - foreach ($operation->getParameters() ?? [] as $key => $parameter) { - if (!$parameter instanceof QueryParameterInterface || false === $parameter->getHydra()) { - continue; - } - - if (!($property = $parameter->getProperty())) { - continue; - } - - $keys[] = $key; - $m = new IriTemplateMapping( - variable: $key, - property: $property, - required: $parameter->getRequired() ?? false - ); - $mapping[] = $m; - } - - $parts = parse_url($requestUri); - - return new IriTemplate( - variableRepresentation: 'BasicRepresentation', - mapping: $mapping, - template: \sprintf('%s{?%s}', $parts['path'] ?? '', implode(',', $keys)), - ); - } - private function getView(mixed $object, string $requestUri, Operation $operation): PartialCollectionView { - $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(); - } - - // 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($requestUri, $this->pageParameterName); - $appliedFilters = $parsed['parameters']; - unset($appliedFilters[$this->enabledParameterName]); - - $urlGenerationStrategy = $operation->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy; - $id = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy); - if (!$appliedFilters && !$paginated) { - return new PartialCollectionView($id); - } - - $first = $last = $previous = $next = null; - if (null !== $lastPage) { - $first = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy); - $last = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy); - } - - if (1. !== $currentPage) { - $previous = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy); - } - - if ((null !== $lastPage && $currentPage < $lastPage) || (null === $lastPage && $pageTotalItems >= $itemsPerPage)) { - $next = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy); - } - - return new PartialCollectionView($id, $first, $last, $previous, $next); + return $this->getPartialCollectionView($object, $requestUri, $this->pageParameterName, $this->enabledParameterName, $operation->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy); } } diff --git a/src/Hydra/State/Util/PaginationHelperTrait.php b/src/Hydra/State/Util/PaginationHelperTrait.php new file mode 100644 index 00000000000..541a828aa47 --- /dev/null +++ b/src/Hydra/State/Util/PaginationHelperTrait.php @@ -0,0 +1,84 @@ + + * + * 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 (!$appliedFilters && !$paginated) { + return new PartialCollectionView($id); + } + + ['first' => $first, 'last' => $last, 'previous' => $previous, 'next' => $next] = $this->getPaginationIri($parsed, $currentPage, $lastPage, $itemsPerPage, $pageTotalItems, $urlGenerationStrategy, $pageParameterName); + + 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..c171dbfeb06 --- /dev/null +++ b/src/Hydra/State/Util/SearchHelperTrait.php @@ -0,0 +1,121 @@ + + * + * 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) { + if (str_ends_with((string) $variable, '[]')) { + continue; + } + + if (($prop = $parameter->getProperty()) && ($description['property'] ?? null) !== $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]; + } +}