diff --git a/composer.json b/composer.json index a9998207b77..a9ffaf808fe 100644 --- a/composer.json +++ b/composer.json @@ -114,7 +114,7 @@ "symfony/property-info": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.0", "symfony/translation-contracts": "^3.3", - "symfony/type-info": "v7.3.0-RC1", + "symfony/type-info": "^7.3", "symfony/web-link": "^6.4 || ^7.1", "willdurand/negotiation": "^3.1" }, @@ -172,9 +172,10 @@ "symfony/expression-language": "^6.4 || ^7.0", "symfony/finder": "^6.4 || ^7.0", "symfony/form": "^6.4 || ^7.0", - "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/framework-bundle": "7.4.x-dev", "symfony/http-client": "^6.4 || ^7.0", "symfony/intl": "^6.4 || ^7.0", + "symfony/json-streamer": "7.4.x-dev", "symfony/maker-bundle": "^1.24", "symfony/mercure-bundle": "*", "symfony/messenger": "^6.4 || ^7.0", diff --git a/src/Hydra/Collection.php b/src/Hydra/Collection.php new file mode 100644 index 00000000000..bac9d0a21db --- /dev/null +++ b/src/Hydra/Collection.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Hydra; + +use Symfony\Component\JsonStreamer\Attribute\StreamedName; + +/** + * @template T + * + * @internal + */ +class Collection +{ + #[StreamedName('@context')] + public string $context = 'VIRTUAL'; + + #[StreamedName('@id')] + public CollectionId $id = CollectionId::VALUE; + + #[StreamedName('@type')] + public string $type = 'Collection'; + + public float $totalItems; + + public ?IriTemplate $search = null; + public ?PartialCollectionView $view = null; + + /** + * @var list + */ + public iterable $member; +} diff --git a/src/Hydra/CollectionId.php b/src/Hydra/CollectionId.php new file mode 100644 index 00000000000..33d0e8bdd38 --- /dev/null +++ b/src/Hydra/CollectionId.php @@ -0,0 +1,19 @@ + + * + * 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; + +enum CollectionId +{ + case VALUE; +} diff --git a/src/Hydra/IriTemplate.php b/src/Hydra/IriTemplate.php new file mode 100644 index 00000000000..df2ecb466a8 --- /dev/null +++ b/src/Hydra/IriTemplate.php @@ -0,0 +1,30 @@ + + * + * 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..962810b4dcc --- /dev/null +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -0,0 +1,198 @@ + + * + * 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\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, + ) { + $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) { + 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 */ + $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); + } +} 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/ValueTransformer/ContextValueTransformer.php b/src/JsonLd/JsonStreamer/ValueTransformer/ContextValueTransformer.php new file mode 100644 index 00000000000..5ff42f29110 --- /dev/null +++ b/src/JsonLd/JsonStreamer/ValueTransformer/ContextValueTransformer.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\JsonLd\JsonStreamer\ValueTransformer; + +use ApiPlatform\Metadata\UrlGeneratorInterface; +use Symfony\Component\JsonStreamer\ValueTransformer\ValueTransformerInterface; +use Symfony\Component\TypeInfo\Type; + +final class ContextValueTransformer implements ValueTransformerInterface +{ + public function __construct( + private readonly UrlGeneratorInterface $urlGenerator, + ) { + } + + public function transform(mixed $value, array $options = []): mixed + { + return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $options['operation']->getShortName()], $options['operation']->getUrlGenerationStrategy()); + } + + public static function getStreamValueType(): Type + { + return Type::string(); + } +} diff --git a/src/JsonLd/JsonStreamer/ValueTransformer/IriValueTransformer.php b/src/JsonLd/JsonStreamer/ValueTransformer/IriValueTransformer.php new file mode 100644 index 00000000000..c67e296b47b --- /dev/null +++ b/src/JsonLd/JsonStreamer/ValueTransformer/IriValueTransformer.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonLd\JsonStreamer\ValueTransformer; + +use ApiPlatform\Hydra\Collection; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use Symfony\Component\JsonStreamer\ValueTransformer\ValueTransformerInterface; +use Symfony\Component\TypeInfo\Type; + +final class IriValueTransformer implements ValueTransformerInterface +{ + public function __construct( + private readonly IriConverterInterface $iriConverter, + ) { + } + + public function transform(mixed $value, array $options = []): mixed + { + if ($options['_current_object'] instanceof Collection) { + return $this->iriConverter->getIriFromResource($options['operation']->getClass(), UrlGeneratorInterface::ABS_PATH, $options['operation']); + } + + return $this->iriConverter->getIriFromResource( + $options['_current_object'], + UrlGeneratorInterface::ABS_PATH, + $options['operation'] instanceof CollectionOperationInterface ? null : $options['operation'], + ); + } + + public static function getStreamValueType(): Type + { + return Type::string(); + } +} diff --git a/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php b/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php new file mode 100644 index 00000000000..4097e3825e3 --- /dev/null +++ b/src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonLd\JsonStreamer; + +use ApiPlatform\Hydra\Collection; +use ApiPlatform\Hydra\IriTemplate; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\TypeHelper; +use Symfony\Component\JsonStreamer\Mapping\PropertyMetadata; +use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\Type; + +final class WritePropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + public function __construct( + private readonly PropertyMetadataLoaderInterface $loader, + private readonly ResourceClassResolverInterface $resourceClassResolver, + ) { + } + + public function load(string $className, array $options = [], array $context = []): array + { + $properties = $this->loader->load($className, $options, $context); + + if (IriTemplate::class === $className) { + $properties['template'] = new PropertyMetadata( + 'template', + Type::string(), + ['api_platform.hydra.json_streamer.write.value_transformer.template'], + ); + + return $properties; + } + + if (Collection::class !== $className && !$this->resourceClassResolver->isResourceClass($className)) { + return $properties; + } + + // 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 + ['api_platform.jsonld.json_streamer.write.value_transformer.context'], + ); + } + + return $properties; + } +} diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index 81ba3b004d1..1ce659bc353 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -965,6 +965,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( @@ -1011,6 +1012,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 7ee88218419..2e3c3f3ae28 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -100,6 +100,7 @@ public function __construct( array|string|null $middleware = null, ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, + ?bool $jsonStream = null, array $extraProperties = [], ) { parent::__construct( @@ -177,6 +178,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 43325a60964..3ac623b7c67 100644 --- a/src/Metadata/Error.php +++ b/src/Metadata/Error.php @@ -95,6 +95,7 @@ public function __construct( $processor = null, ?OptionsInterface $stateOptions = null, ?bool $hideHydraOperation = null, + ?bool $jsonStream = null, array $extraProperties = [], ) { parent::__construct( @@ -171,6 +172,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 a10676037e6..5a16dadb733 100644 --- a/src/Metadata/ErrorResource.php +++ b/src/Metadata/ErrorResource.php @@ -84,6 +84,7 @@ public function __construct( $provider = null, $processor = null, ?OptionsInterface $stateOptions = null, + ?bool $jsonStream = null, array $extraProperties = [], ) { parent::__construct( @@ -151,6 +152,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 c87621fa113..1bacef4bda9 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -100,6 +100,7 @@ public function __construct( array|string|null $middleware = null, ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, + ?bool $jsonStream = null, array $extraProperties = [], ) { parent::__construct( @@ -181,6 +182,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 52398d6282c..8572f6e0896 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -100,6 +100,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, ) { @@ -176,6 +177,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 61fd1189e1f..7009ff38c09 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -213,6 +213,7 @@ public function __construct( ?string $policy = null, array|string|null $middleware = null, ?bool $queryParameterValidationEnabled = null, + ?bool $jsonStream = null, array $extraProperties = [], ) { parent::__construct( @@ -267,6 +268,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 e5edb91cead..b605f3c58e0 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) { @@ -694,4 +695,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 185048ca4af..4396ef52dc5 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -813,6 +813,7 @@ public function __construct( ?bool $queryParameterValidationEnabled = null, protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, + protected ?bool $jsonStream = null, protected array $extraProperties = [], ) { parent::__construct( @@ -860,6 +861,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 f3e8774349d..8ab9a575478 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -100,6 +100,7 @@ public function __construct( array|string|null $middleware = null, ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, + ?bool $jsonStream = null, array $extraProperties = [], ) { parent::__construct( @@ -182,6 +183,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 ebc6f3edbc9..f720e1add59 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -98,6 +98,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, @@ -183,6 +184,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 2df0c5bf3e0..486dbbce788 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -98,6 +98,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, @@ -183,6 +184,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 b56bd332a4d..74d174d0c21 100644 --- a/src/State/Processor/SerializeProcessor.php +++ b/src/State/Processor/SerializeProcessor.php @@ -38,8 +38,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 c3949cfa22b..b4f0a4dbfca 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/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php new file mode 100644 index 00000000000..ec80d85b913 --- /dev/null +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Util; + +use ApiPlatform\Metadata\Exception\HttpExceptionInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Metadata\Util\CloneTrait; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; + +/** + * Shares the logic to create API Platform's headers. + * + * @internal + */ +trait HttpResponseHeadersTrait +{ + use ClassInfoTrait; + use CloneTrait; + private ?IriConverterInterface $iriConverter; + private ?OperationMetadataFactoryInterface $operationMetadataFactory; + + /** + * @param array $context + * + * @return array + */ + private function getHeaders(Request $request, HttpOperation $operation, array $context): array + { + $status = $this->getStatus($request, $operation, $context); + $headers = [ + 'Content-Type' => \sprintf('%s; charset=utf-8', $request->getMimeType($request->getRequestFormat())), + 'Vary' => 'Accept', + 'X-Content-Type-Options' => 'nosniff', + 'X-Frame-Options' => 'deny', + ]; + + $exception = $request->attributes->get('exception'); + if (($exception instanceof HttpExceptionInterface || $exception instanceof SymfonyHttpExceptionInterface) && $exceptionHeaders = $exception->getHeaders()) { + $headers = array_merge($headers, $exceptionHeaders); + } + + if ($operationHeaders = $operation->getHeaders()) { + $headers = array_merge($headers, $operationHeaders); + } + + if ($sunset = $operation->getSunset()) { + $headers['Sunset'] = (new \DateTimeImmutable($sunset))->format(\DateTimeInterface::RFC1123); + } + + if ($acceptPatch = $operation->getAcceptPatch()) { + $headers['Accept-Patch'] = $acceptPatch; + } + + $method = $request->getMethod(); + $originalData = $context['original_data'] ?? null; + $outputMetadata = $operation->getOutput() ?? ['class' => $operation->getClass()]; + $hasOutput = \is_array($outputMetadata) && \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class']; + $hasData = !$hasOutput ? false : ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))); + + if ($hasData) { + $isAlternateResourceMetadata = $operation->getExtraProperties()['is_alternate_resource_metadata'] ?? false; + $canonicalUriTemplate = $operation->getExtraProperties()['canonical_uri_template'] ?? null; + + if ( + !isset($headers['Location']) + && 300 <= $status && $status < 400 + && ($isAlternateResourceMetadata || $canonicalUriTemplate) + ) { + $canonicalOperation = $operation; + if ($this->operationMetadataFactory && null !== $canonicalUriTemplate) { + $canonicalOperation = $this->operationMetadataFactory->create($canonicalUriTemplate, $context); + } + + if ($this->iriConverter) { + $headers['Location'] = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, $canonicalOperation); + } + } + } + + $requestParts = parse_url($request->getRequestUri()); + if ($this->iriConverter && !isset($headers['Content-Location'])) { + try { + $iri = null; + if ($hasData) { + $iri = $this->iriConverter->getIriFromResource($originalData); + } elseif ($operation->getClass()) { + $iri = $this->iriConverter->getIriFromResource($operation->getClass(), UrlGeneratorInterface::ABS_PATH, $operation); + } + + if ($iri && 'GET' !== $method) { + $location = \sprintf('%s.%s', $iri, $request->getRequestFormat()); + if (isset($requestParts['query'])) { + $location .= '?'.$requestParts['query']; + } + + $headers['Content-Location'] = $location; + if ((Response::HTTP_CREATED === $status || (300 <= $status && $status < 400)) && 'POST' === $method && !isset($headers['Location'])) { + $headers['Location'] = $iri; + } + } + } catch (InvalidArgumentException|ItemNotFoundException|RuntimeException) { + } + } + + return $headers; + } +} diff --git a/src/State/Util/HttpResponseStatusTrait.php b/src/State/Util/HttpResponseStatusTrait.php new file mode 100644 index 00000000000..89b9156c3ea --- /dev/null +++ b/src/State/Util/HttpResponseStatusTrait.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Util; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\ClassInfoTrait; +use ApiPlatform\Metadata\Util\CloneTrait; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +trait HttpResponseStatusTrait +{ + use ClassInfoTrait; + use CloneTrait; + private ?ResourceClassResolverInterface $resourceClassResolver; + + public const METHOD_TO_CODE = [ + 'POST' => Response::HTTP_CREATED, + 'DELETE' => Response::HTTP_NO_CONTENT, + ]; + + /** + * @param array $context + */ + private function getStatus(Request $request, HttpOperation $operation, array $context): int + { + $status = $operation->getStatus(); + $method = $request->getMethod(); + + $outputMetadata = $operation->getOutput() ?? ['class' => $operation->getClass()]; + $hasOutput = \is_array($outputMetadata) && \array_key_exists('class', $outputMetadata) && null !== $outputMetadata['class']; + $originalData = $context['original_data'] ?? null; + $hasData = !$hasOutput ? false : ($this->resourceClassResolver && $originalData && \is_object($originalData) && $this->resourceClassResolver->isResourceClass($this->getObjectClass($originalData))); + + if ($hasData) { + if ('PUT' === $method && !$request->attributes->get('previous_data') && null === $status && ($operation instanceof Put && ($operation->getAllowCreate() ?? false))) { + $status = Response::HTTP_CREATED; + } + } + + return $status ?? self::METHOD_TO_CODE[$method] ?? Response::HTTP_OK; + } +} diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 59519ce7e93..a53d6184b59 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -168,6 +168,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); $container->registerForAutoconfiguration(FilterInterface::class) ->addTag('api_platform.filter'); @@ -923,4 +924,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 cb359b38d15..afb2ec6b3d3 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..7ae077c32ce --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/json_streamer.xml @@ -0,0 +1,51 @@ + + + + + + + %.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 3127f4e0599..511983e3c13 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -42,6 +42,7 @@ class ApiPlatformExtensionTest extends TestCase 'title' => 'title', 'description' => 'description', 'version' => 'version', + 'enable_json_streamer' => true, 'serializer' => ['hydra_prefix' => true], 'formats' => [ 'json' => ['mime_types' => ['json']], diff --git a/tests/Fixtures/TestBundle/Entity/JsonStreamResource.php b/tests/Fixtures/TestBundle/Entity/JsonStreamResource.php new file mode 100644 index 00000000000..fc17f235da4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/JsonStreamResource.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity()] +#[ORM\Table(name: 'json_stream_resource')] +#[ApiResource( + jsonStream: true, + paginationEnabled: false, + normalizationContext: ['hydra_prefix' => false] +)] +class JsonStreamResource +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + public int $id; + + #[ORM\Column(length: 255)] + public string $title; + + // #[ORM\Column(type: 'datetime_immutable')] + // public \DateTimeImmutable $createdAt; + // + // #[ORM\Column(type: 'date_immutable')] + // public \DateTimeImmutable $publishedAt; + + #[ORM\Column(type: 'integer')] + public int $views; + + #[ORM\Column(type: 'float')] + public float $rating; + + #[ORM\Column(type: 'boolean')] + public bool $isFeatured; + + #[ORM\Column(type: 'decimal', precision: 10, scale: 2)] + public string $price; +} diff --git a/tests/Functional/JsonLdTest.php b/tests/Functional/JsonLdTest.php index 0b4a5e3926d..96edf85f0f3 100644 --- a/tests/Functional/JsonLdTest.php +++ b/tests/Functional/JsonLdTest.php @@ -45,11 +45,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']); } @@ -87,12 +95,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..b73d112cfc2 --- /dev/null +++ b/tests/Functional/JsonStreamerTest.php @@ -0,0 +1,192 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\JsonStreamResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Tools\SchemaTool; + +class JsonStreamerTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [JsonStreamResource::class]; + } + + protected function setUp(): void + { + self::bootKernel(); + + $container = static::getContainer(); + + if ('mongodb' === $container->getParameter('kernel.environment')) { + return; + } + + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + if (!$manager instanceof EntityManagerInterface) { + return; + } + + $classes = []; + foreach ([JsonStreamResource::class] as $entityClass) { + $classes[] = $manager->getClassMetadata($entityClass); + } + + try { + $schemaTool = new SchemaTool($manager); + @$schemaTool->createSchema($classes); + } catch (\Exception $e) { + } + + for ($i = 0; $i < 10; ++$i) { + $resource = new JsonStreamResource(); + $resource->title = 'Title '.$i; + // $resource->createdAt = new \DateTimeImmutable(); + // $resource->publishedAt = new \DateTimeImmutable(); + $resource->views = random_int(1, 1000); + $resource->rating = random_int(1, 5); + $resource->isFeatured = (bool) random_int(0, 1); + $resource->price = number_format((float) random_int(10, 1000) / 100, 2, '.', ''); + + $manager->persist($resource); + } + + $manager->flush(); + } + + protected function tearDown(): void + { + $container = static::getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + if (!$manager instanceof EntityManagerInterface) { + return; + } + + $classes = []; + foreach ([JsonStreamResource::class] as $entityClass) { + $classes[] = $manager->getClassMetadata($entityClass); + } + + $schemaTool = new SchemaTool($manager); + @$schemaTool->dropSchema($classes); + parent::tearDown(); + } + + public function 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); + $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('/contexts/JsonStreamResource', $res['@context']); + } + + 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); + + $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->assertArrayHasKey('totalItems', $res); + $this->assertIsInt($res['totalItems']); + } + + 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); + + $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); + } +} diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 568e3f7bd31..bac50c780b8 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' => [