Skip to content

feat: json streamer #7225

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,12 @@
"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",
"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"
},
Expand Down
34 changes: 34 additions & 0 deletions src/Hydra/Collection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

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<T>
*/
public iterable $member;
}
10 changes: 10 additions & 0 deletions src/Hydra/CollectionId.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace ApiPlatform\Hydra;

enum CollectionId
{
case VALUE;
}
30 changes: 30 additions & 0 deletions src/Hydra/IriTemplate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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<IriTemplateMapping> */
public array $mapping = [],
public ?string $template = null,
) {
}
}
29 changes: 29 additions & 0 deletions src/Hydra/IriTemplateMapping.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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,
) {
}
}
36 changes: 36 additions & 0 deletions src/Hydra/PartialCollectionView.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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,
) {
}
}
162 changes: 162 additions & 0 deletions src/Hydra/State/JsonStreamerProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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<IriTemplateMapping> */
$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);

Check failure on line 94 in src/Hydra/State/JsonStreamerProcessor.php

View workflow job for this annotation

GitHub Actions / PHPStan (PHP 8.4)

Variable $requestUri on left side of ?? always exists and is not nullable.
$appliedFilters = $parsed['parameters'];
unset($appliedFilters[$this->enabledParameterName]);

$urlGenerationStrategy = $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy;

Check failure on line 98 in src/Hydra/State/JsonStreamerProcessor.php

View workflow job for this annotation

GitHub Actions / PHPStan (PHP 8.4)

Using nullsafe method call on non-nullable type ApiPlatform\Metadata\Operation. Use -> instead.
$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() ?? '';

Check failure on line 132 in src/Hydra/State/JsonStreamerProcessor.php

View workflow job for this annotation

GitHub Actions / PHPStan (PHP 8.4)

Expression on left side of ?? is not nullable.
$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);
}
}
46 changes: 46 additions & 0 deletions src/Hydra/State/JsonStreamerProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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()));
}
}
Loading
Loading