Skip to content

Commit ea5077d

Browse files
mtarldsoyuka
authored andcommitted
feat: json streamer
1 parent cff61ea commit ea5077d

File tree

21 files changed

+777
-7
lines changed

21 files changed

+777
-7
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,12 @@
110110
"symfony/deprecation-contracts": "^3.1",
111111
"symfony/http-foundation": "^6.4 || ^7.0",
112112
"symfony/http-kernel": "^6.4 || ^7.0",
113+
"symfony/json-streamer": "^7.3",
113114
"symfony/property-access": "^6.4 || ^7.0",
114115
"symfony/property-info": "^6.4 || ^7.1",
115116
"symfony/serializer": "^6.4 || ^7.0",
116117
"symfony/translation-contracts": "^3.3",
117-
"symfony/type-info": "v7.3.0-RC1",
118+
"symfony/type-info": "^7.3",
118119
"symfony/web-link": "^6.4 || ^7.1",
119120
"willdurand/negotiation": "^3.1"
120121
},

src/Hydra/Collection.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ApiPlatform\Hydra;
6+
7+
use Symfony\Component\JsonStreamer\Attribute\StreamedName;
8+
9+
/**
10+
* @template T
11+
*
12+
* @internal
13+
*/
14+
class Collection
15+
{
16+
#[StreamedName('@context')]
17+
public string $context = 'VIRTUAL';
18+
19+
#[StreamedName('@id')]
20+
public CollectionId $id = CollectionId::VALUE;
21+
22+
#[StreamedName('@type')]
23+
public string $type = 'Collection';
24+
25+
public float $totalItems;
26+
27+
public ?IriTemplate $search = null;
28+
public ?PartialCollectionView $view = null;
29+
30+
/**
31+
* @var list<T>
32+
*/
33+
public iterable $member;
34+
}

src/Hydra/CollectionId.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ApiPlatform\Hydra;
6+
7+
enum CollectionId
8+
{
9+
case VALUE;
10+
}

src/Hydra/IriTemplate.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Hydra;
15+
16+
use Symfony\Component\JsonStreamer\Attribute\StreamedName;
17+
18+
final class IriTemplate
19+
{
20+
#[StreamedName('@type')]
21+
public string $type = 'IriTemplate';
22+
23+
public function __construct(
24+
public string $variableRepresentation,
25+
/** @var list<IriTemplateMapping> */
26+
public array $mapping = [],
27+
public ?string $template = null,
28+
) {
29+
}
30+
}

src/Hydra/IriTemplateMapping.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Hydra;
15+
16+
use Symfony\Component\JsonStreamer\Attribute\StreamedName;
17+
18+
class IriTemplateMapping
19+
{
20+
#[StreamedName('@type')]
21+
public string $type = 'IriTemplateMapping';
22+
23+
public function __construct(
24+
public string $variable,
25+
public string $property,
26+
public bool $required = false,
27+
) {
28+
}
29+
}

src/Hydra/PartialCollectionView.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Hydra;
15+
16+
use Symfony\Component\JsonStreamer\Attribute\StreamedName;
17+
18+
class PartialCollectionView
19+
{
20+
#[StreamedName('@type')]
21+
public string $type = 'PartialCollectionView';
22+
23+
public function __construct(
24+
#[StreamedName('@id')]
25+
public string $id,
26+
#[StreamedName('first')]
27+
public ?string $first = null,
28+
#[StreamedName('last')]
29+
public ?string $last = null,
30+
#[StreamedName('previous')]
31+
public ?string $previous = null,
32+
#[StreamedName('next')]
33+
public ?string $next = null,
34+
) {
35+
}
36+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Hydra\State;
15+
16+
use ApiPlatform\Hydra\Collection;
17+
use ApiPlatform\Hydra\IriTemplate;
18+
use ApiPlatform\Hydra\IriTemplateMapping;
19+
use ApiPlatform\Hydra\PartialCollectionView;
20+
use ApiPlatform\Metadata\CollectionOperationInterface;
21+
use ApiPlatform\Metadata\Error;
22+
use ApiPlatform\Metadata\Operation;
23+
use ApiPlatform\Metadata\QueryParameterInterface;
24+
use ApiPlatform\Metadata\UrlGeneratorInterface;
25+
use ApiPlatform\Metadata\Util\IriHelper;
26+
use ApiPlatform\State\Pagination\PaginatorInterface;
27+
use ApiPlatform\State\Pagination\PartialPaginatorInterface;
28+
use ApiPlatform\State\ProcessorInterface;
29+
use Symfony\Component\HttpFoundation\Response;
30+
use Symfony\Component\HttpFoundation\StreamedResponse;
31+
use Symfony\Component\JsonStreamer\StreamWriterInterface;
32+
use Symfony\Component\TypeInfo\Type;
33+
34+
final class JsonStreamerProcessor implements ProcessorInterface
35+
{
36+
public function __construct(
37+
private readonly ProcessorInterface $processor,
38+
private readonly StreamWriterInterface $jsonStreamer,
39+
private readonly string $pageParameterName = 'page',
40+
private readonly string $enabledParameterName = 'pagination',
41+
private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH
42+
) {
43+
}
44+
45+
private function getSearch(Operation $operation, string $requestUri): IriTemplate
46+
{
47+
/** @var list<IriTemplateMapping> */
48+
$mapping = [];
49+
$keys = [];
50+
51+
foreach ($operation->getParameters() ?? [] as $key => $parameter) {
52+
if (!$parameter instanceof QueryParameterInterface || false === $parameter->getHydra()) {
53+
continue;
54+
}
55+
56+
if (!($property = $parameter->getProperty())) {
57+
continue;
58+
}
59+
60+
$keys[] = $key;
61+
$m = new IriTemplateMapping(
62+
variable: $key,
63+
property: $property,
64+
required: $parameter->getRequired() ?? false
65+
);
66+
$mapping[] = $m;
67+
}
68+
69+
$parts = parse_url($requestUri);
70+
return new IriTemplate(
71+
variableRepresentation: 'BasicRepresentation',
72+
mapping: $mapping,
73+
template: \sprintf('%s{?%s}', $parts['path'] ?? '', implode(',', $keys)),
74+
);
75+
}
76+
77+
private function getView(mixed $object, string $requestUri, Operation $operation): PartialCollectionView
78+
{
79+
$currentPage = $lastPage = $itemsPerPage = $pageTotalItems = null;
80+
if ($paginated = ($object instanceof PartialPaginatorInterface)) {
81+
if ($object instanceof PaginatorInterface) {
82+
$paginated = 1. !== $lastPage = $object->getLastPage();
83+
} else {
84+
$itemsPerPage = $object->getItemsPerPage();
85+
$pageTotalItems = (float) \count($object);
86+
}
87+
88+
$currentPage = $object->getCurrentPage();
89+
}
90+
91+
// TODO: This needs to be changed as well as I wrote in the CollectionFiltersNormalizer
92+
// We should not rely on the request_uri but instead rely on the UriTemplate
93+
// This needs that we implement the RFC and that we do more parsing before calling the serialization (MainController)
94+
$parsed = IriHelper::parseIri($requestUri ?? '/', $this->pageParameterName);
95+
$appliedFilters = $parsed['parameters'];
96+
unset($appliedFilters[$this->enabledParameterName]);
97+
98+
$urlGenerationStrategy = $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy;
99+
$id = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy);
100+
if (!$appliedFilters && !$paginated) {
101+
return new PartialCollectionView($id);
102+
}
103+
104+
$first = $last = $previous = $next = null;
105+
if (null !== $lastPage) {
106+
$first = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy);
107+
$last = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy);
108+
}
109+
110+
if (1. !== $currentPage) {
111+
$previous = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy);
112+
}
113+
114+
if ((null !== $lastPage && $currentPage < $lastPage) || (null === $lastPage && $pageTotalItems >= $itemsPerPage)) {
115+
$next = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy);
116+
}
117+
118+
return new PartialCollectionView($id, $first, $last, $previous, $next);
119+
}
120+
121+
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
122+
{
123+
if ($context['request']->query->has('skip_json_stream')) {
124+
return $this->processor->process($data, $operation, $uriVariables, $context);
125+
}
126+
127+
if ($operation instanceof Error || $data instanceof Response) {
128+
return $this->processor->process($data, $operation, $uriVariables, $context);
129+
}
130+
131+
if ($operation instanceof CollectionOperationInterface) {
132+
$requestUri = $context['request']->getRequestUri() ?? '';
133+
$collection = new Collection();
134+
$collection->member = $data;
135+
$collection->view = $this->getView($data, $requestUri, $operation);
136+
137+
if ($operation->getParameters()) {
138+
$collection->search = $this->getSearch($operation, $requestUri);
139+
}
140+
141+
if ($data instanceof PaginatorInterface) {
142+
$collection->totalItems = $data->getTotalItems();
143+
}
144+
145+
if (\is_array($data) || ($data instanceof \Countable && !$data instanceof PartialPaginatorInterface)) {
146+
$collection->totalItems = \count($data);
147+
}
148+
149+
$response = new StreamedResponse($this->jsonStreamer->write($collection, Type::generic(Type::object($collection::class), Type::object($operation->getClass())), [
150+
'data' => $data,
151+
'operation' => $operation,
152+
]));
153+
} else {
154+
$response = new StreamedResponse($this->jsonStreamer->write($data, Type::object($operation->getClass()), [
155+
'data' => $data,
156+
'operation' => $operation,
157+
]));
158+
}
159+
160+
return $this->processor->process($response, $operation, $uriVariables, $context);
161+
}
162+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Hydra\State;
15+
16+
use ApiPlatform\Metadata\HttpOperation;
17+
use ApiPlatform\Metadata\Operation;
18+
use ApiPlatform\State\ProviderInterface;
19+
use Symfony\Component\JsonStreamer\StreamReaderInterface;
20+
use Symfony\Component\TypeInfo\Type;
21+
22+
final class JsonStreamerProvider implements ProviderInterface
23+
{
24+
public function __construct(
25+
private readonly ?ProviderInterface $decorated,
26+
private readonly StreamReaderInterface $jsonStreamReader,
27+
) {
28+
}
29+
30+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
31+
{
32+
if (!$operation instanceof HttpOperation || !($request = $context['request'] ?? null)) {
33+
return $this->decorated?->provide($operation, $uriVariables, $context);
34+
}
35+
36+
$data = $this->decorated ? $this->decorated->provide($operation, $uriVariables, $context) : $request->attributes->get('data');
37+
38+
if (!$operation->canDeserialize()) {
39+
return $data;
40+
}
41+
42+
$context['request']->attributes->set('deserialized', true);
43+
44+
return $this->jsonStreamReader->read($request->getContent(true), Type::object($operation->getClass()));
45+
}
46+
}

0 commit comments

Comments
 (0)