Skip to content

Commit b7779e4

Browse files
KondukterCROMateo Fuzul
and
Mateo Fuzul
authored
release 2.3.6 (#23)
* Support LID * Add links on resource collection * Static code analysis fixes * Add read side validation * Upgrade dependencies (#26) * refactor: remove deprecated methods * refactor: upgrade dependencies --------- Co-authored-by: Mateo Fuzul <[email protected]>
1 parent 4076fa4 commit b7779e4

20 files changed

+2424
-2032
lines changed

composer.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
},
4141
"scripts": {
4242
"lint": [
43-
"php-cs-fixer fix --diff --ansi --dry-run"
43+
"PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix --diff --ansi --dry-run"
4444
],
4545
"phpstan": [
4646
"php -d memory_limit=-1 vendor/bin/phpstan analyse -n --ansi --no-progress"
@@ -53,5 +53,10 @@
5353
"@phpstan",
5454
"@test"
5555
]
56+
},
57+
"config": {
58+
"allow-plugins": {
59+
"phpstan/extension-installer": true
60+
}
5661
}
5762
}

composer.lock

+1,659-1,911
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/DependencyInjection/Configuration.php

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ public function getConfigTreeBuilder(): TreeBuilder
2020
->min(-255)
2121
->max(255)
2222
->end()
23+
?->booleanNode('validate_read_model')
24+
->defaultFalse()
2325
->end();
2426

2527
return $treeBuilder;

src/DependencyInjection/JsonApiSymfonyExtension.php

+1
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,6 @@ public function load(array $configs, ContainerBuilder $container): void
4141
],
4242
]);
4343
$container->setDefinition('json_api_symfony.exception_listener', $definition);
44+
$container->setParameter('json_api_symfony.validate_read_model', $config['validate_read_model'] ?? false);
4445
}
4546
}

src/DependencyInjection/config/services.yaml

+3-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ services:
5151
Undabot\SymfonyJsonApi\Service\Resource\Factory\Definition\ResourceMetadataFactoryInterface: '@Undabot\SymfonyJsonApi\Service\Resource\Factory\ResourceMetadataFactory'
5252
Undabot\SymfonyJsonApi\Service\Resource\Denormalizer\ResourceDenormalizer: ~
5353
Undabot\SymfonyJsonApi\Service\Resource\Validation\ResourceValidator: ~
54-
Undabot\SymfonyJsonApi\Service\Resource\Factory\ResourceFactory: ~
54+
Undabot\SymfonyJsonApi\Service\Resource\Factory\ResourceFactory:
55+
arguments:
56+
$shouldValidateReadModel: '%json_api_symfony.validate_read_model%'
5557

5658
Undabot\SymfonyJsonApi\Http\Service\:
5759
resource: '../../Http/Service/*'

src/Http/Service/EventSubscriber/ViewResponseSubscriber.php

+4-4
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919
use Undabot\SymfonyJsonApi\Http\Model\Response\ResourceResponse;
2020
use Undabot\SymfonyJsonApi\Http\Model\Response\ResourceUpdatedResponse;
2121
use Undabot\SymfonyJsonApi\Http\Model\Response\ResourceValidationErrorsResponse;
22+
use Undabot\SymfonyJsonApi\Service\Pagination\PaginationLinkBuilder;
2223

23-
class ViewResponseSubscriber implements EventSubscriberInterface
24+
final class ViewResponseSubscriber implements EventSubscriberInterface
2425
{
25-
/** @var DocumentToPhpArrayEncoderInterface */
26-
private $documentEncoder;
26+
private DocumentToPhpArrayEncoderInterface $documentEncoder;
2727

2828
public function __construct(DocumentToPhpArrayEncoderInterface $documentEncoder)
2929
{
@@ -47,7 +47,7 @@ public function buildView(ViewEvent $event): void
4747
null,
4848
$data->getMeta(),
4949
$this->buildJsonApi(),
50-
$data->getLinks(),
50+
(new PaginationLinkBuilder())->createLinks($event->getRequest(), $data),
5151
$data->getIncludedResources()
5252
);
5353

src/Http/Service/Factory/RequestFactory.php

+28-3
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ class RequestFactory
2323
{
2424
private PhpArrayToResourceEncoderInterface$resourceEncoder;
2525
private RequestValidator$requestValidator;
26+
/** @var array<string, mixed> */
27+
private array $requestData = [];
2628

2729
public function __construct(
2830
PhpArrayToResourceEncoderInterface $resourceEncoder,
@@ -45,12 +47,22 @@ public function createResourceRequest(
4547
$this->requestValidator->assertValidRequest($request);
4648
$requestPrimaryData = $this->getRequestPrimaryData($request);
4749

48-
// If the server-side ID is passed as argument, we don't expect the Client to generate ID
49-
// https://jsonapi.org/format/#crud-creating-client-ids
50+
/** If the server-side ID is passed as argument, we don't expect the Client to generate ID
51+
* @see https://jsonapi.org/format/#crud-creating-client-ids
52+
*/
5053
if (null !== $id) {
5154
$this->requestValidator->assertResourceIsWithoutClientGeneratedId($requestPrimaryData);
5255
$requestPrimaryData['id'] = $id;
5356
}
57+
/**
58+
* If we have lid sent as id we will pass it as resource id
59+
* @see https://jsonapi.org/format/#document-resource-object-identification
60+
*/
61+
$lid = $this->getResourceLid($request);
62+
if (null !== $lid) {
63+
$requestPrimaryData['id'] = $lid;
64+
unset($requestPrimaryData['lid']);
65+
}
5466

5567
$resource = $this->resourceEncoder->decode($requestPrimaryData);
5668

@@ -136,15 +148,28 @@ public function updateResourceRequest(Request $request, string $id): UpdateResou
136148
*/
137149
private function getRequestPrimaryData(Request $request): array
138150
{
151+
if (false === empty($this->requestData)) {
152+
return $this->requestData;
153+
}
154+
139155
/** @var string $rawRequestData */
140-
$rawRequestData = $request->getContent(false);
156+
$rawRequestData = $request->getContent();
141157
Assertion::isJsonString($rawRequestData, 'Request data must be valid JSON');
142158
$requestData = json_decode($rawRequestData, true);
143159

144160
Assertion::notNull($requestData, 'Request data must be parsable to a valid array');
145161
Assertion::isArray($requestData, 'Request data must be parsable to a valid array');
146162
Assertion::keyExists($requestData, 'data', 'The request MUST include a single resource object as primary data');
163+
$this->requestData = $requestData['data'];
147164

148165
return $requestData['data'];
149166
}
167+
168+
private function getResourceLid(Request $request): ?string
169+
{
170+
$requestPrimaryData = $this->getRequestPrimaryData($request);
171+
$this->requestValidator->assertResourceLidIsValid($requestPrimaryData);
172+
173+
return $requestPrimaryData['lid'] ?? null;
174+
}
150175
}

src/Http/Service/Validation/RequestValidator.php

+7
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,11 @@ public function assertValidUpdateRequestData(array $data, string $id): void
117117

118118
Assertion::same($data['id'], $id, 'Resource with invalid ID given');
119119
}
120+
121+
public function assertResourceLidIsValid(array $requestPrimaryData): void
122+
{
123+
if (true === \array_key_exists('lid', $requestPrimaryData)) {
124+
Assertion::string($requestPrimaryData['lid']);
125+
}
126+
}
120127
}
+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Undabot\SymfonyJsonApi\Model\Link;
6+
7+
/** @psalm-immutable */
8+
final class ResponsePaginationLink
9+
{
10+
public function __construct(
11+
public string $paginationPageKey,
12+
public int $nextSet,
13+
public int $previousSet,
14+
public int $firstPageKey,
15+
public ?int $lastPageKey
16+
) {
17+
}
18+
}

src/Model/Resource/FlatResource.php

+7-3
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,13 @@ public function getIndexedRelationshipObjects(): array
136136
public function getRelationshipMetas(): array
137137
{
138138
if (true === empty($this->relationshipMetas)) {
139-
/** @var RelationshipInterface $relationship */
140-
foreach ($this->resource->getRelationships() as $relationship) {
141-
$this->buildRelationshipMeta($relationship);
139+
$relationships = $this->resource->getRelationships();
140+
141+
if (null !== $relationships) {
142+
/** @var RelationshipInterface $relationship */
143+
foreach ($relationships as $relationship) {
144+
$this->buildRelationshipMeta($relationship);
145+
}
142146
}
143147
}
144148

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Undabot\SymfonyJsonApi\Service\Pagination\Creator;
6+
7+
use Undabot\JsonApi\Definition\Model\Request\Pagination\PaginationInterface;
8+
use Undabot\JsonApi\Implementation\Model\Request\Pagination\OffsetBasedPagination;
9+
use Undabot\SymfonyJsonApi\Model\Link\ResponsePaginationLink;
10+
11+
final class OffsetBasedPaginationLinkParametersFactory implements PaginationLinkParametersFactory
12+
{
13+
public function createLinks(PaginationInterface $pagination, ?int $total): ResponsePaginationLink
14+
{
15+
return new ResponsePaginationLink(
16+
OffsetBasedPagination::PARAM_PAGE_OFFSET,
17+
$pagination->getSize(),
18+
$pagination->getSize() * -1,
19+
0,
20+
null === $total ? null : (int) (ceil($total / $pagination->getSize()) * $pagination->getSize() - $pagination->getSize()),
21+
);
22+
}
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Undabot\SymfonyJsonApi\Service\Pagination\Creator;
6+
7+
use Undabot\JsonApi\Definition\Model\Request\Pagination\PaginationInterface;
8+
use Undabot\JsonApi\Implementation\Model\Request\Pagination\PageBasedPagination;
9+
use Undabot\SymfonyJsonApi\Model\Link\ResponsePaginationLink;
10+
11+
final class PageBasedPaginationLinkParametersFactory implements PaginationLinkParametersFactory
12+
{
13+
public function createLinks(PaginationInterface $pagination, ?int $total): ResponsePaginationLink
14+
{
15+
return new ResponsePaginationLink(
16+
PageBasedPagination::PARAM_PAGE_NUMBER,
17+
1,
18+
-1,
19+
1,
20+
null === $total ? null : (int) ceil($total / $pagination->getSize()),
21+
);
22+
}
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Undabot\SymfonyJsonApi\Service\Pagination\Creator;
6+
7+
use Undabot\JsonApi\Definition\Model\Request\Pagination\PaginationInterface;
8+
use Undabot\SymfonyJsonApi\Model\Link\ResponsePaginationLink;
9+
10+
interface PaginationLinkParametersFactory
11+
{
12+
public function createLinks(PaginationInterface $pagination, ?int $total): ResponsePaginationLink;
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Undabot\SymfonyJsonApi\Service\Pagination;
6+
7+
use Symfony\Component\HttpFoundation\Request;
8+
use Undabot\JsonApi\Definition\Model\Link\LinkCollectionInterface;
9+
use Undabot\JsonApi\Definition\Model\Link\LinkNamesEnum;
10+
use Undabot\JsonApi\Definition\Model\Request\Pagination\PaginationInterface;
11+
use Undabot\JsonApi\Implementation\Factory\PaginationFactory;
12+
use Undabot\JsonApi\Implementation\Model\Link\Link;
13+
use Undabot\JsonApi\Implementation\Model\Link\LinkCollection;
14+
use Undabot\JsonApi\Implementation\Model\Link\LinkUrl;
15+
use Undabot\JsonApi\Implementation\Model\Request\Pagination\OffsetBasedPagination;
16+
use Undabot\SymfonyJsonApi\Http\Model\Request\GetResourceCollectionRequest;
17+
use Undabot\SymfonyJsonApi\Http\Model\Response\ResourceCollectionResponse;
18+
use Undabot\SymfonyJsonApi\Service\Pagination\Creator\OffsetBasedPaginationLinkParametersFactory;
19+
use Undabot\SymfonyJsonApi\Service\Pagination\Creator\PageBasedPaginationLinkParametersFactory;
20+
21+
final class PaginationLinkBuilder
22+
{
23+
public function createLinks(Request $request, ResourceCollectionResponse $response): ?LinkCollectionInterface
24+
{
25+
$pagination = $this->buildPaginationIfAvailable($request);
26+
$links = $response->getLinks();
27+
if (null === $pagination) {
28+
return $links;
29+
}
30+
$queryParams = $request->query->all();
31+
$total = null;
32+
if (null !== $response->getMeta()) {
33+
$total = $response->getMeta()->getData()['total'] ?? null;
34+
}
35+
$responsePaginationLink = (true === ($pagination instanceof OffsetBasedPagination))
36+
? (new OffsetBasedPaginationLinkParametersFactory())->createLinks(
37+
$pagination,
38+
$total,
39+
)
40+
: (new PageBasedPaginationLinkParametersFactory())->createLinks(
41+
$pagination,
42+
$total,
43+
);
44+
$queryParamsFirst = $queryParams;
45+
$queryParamsFirst[GetResourceCollectionRequest::PAGINATION_KEY][$responsePaginationLink->paginationPageKey]
46+
= $responsePaginationLink->firstPageKey;
47+
$paginationLinks = [$this->buildLink(LinkNamesEnum::LINK_NAME_PAGINATION_FIRST, $request, $queryParamsFirst)];
48+
if (null !== $responsePaginationLink->lastPageKey) {
49+
$queryParamsLast = $queryParams;
50+
$queryParamsLast[GetResourceCollectionRequest::PAGINATION_KEY][$responsePaginationLink->paginationPageKey]
51+
= $responsePaginationLink->lastPageKey;
52+
$paginationLinks[] = $this->buildLink(LinkNamesEnum::LINK_NAME_PAGINATION_LAST, $request, $queryParamsLast);
53+
}
54+
if (0 !== $pagination->getOffset()) {
55+
$queryParamsPrev = $queryParams;
56+
$queryParamsPrev[GetResourceCollectionRequest::PAGINATION_KEY][$responsePaginationLink->paginationPageKey] += $responsePaginationLink->previousSet;
57+
$paginationLinks[] = $this->buildLink(LinkNamesEnum::LINK_NAME_PAGINATION_PREV, $request, $queryParamsPrev);
58+
}
59+
if (null !== $total && ($pagination->getOffset() + $pagination->getSize()) < $total) {
60+
$queryParamsNext = $queryParams;
61+
$queryParamsNext[GetResourceCollectionRequest::PAGINATION_KEY][$responsePaginationLink->paginationPageKey] += $responsePaginationLink->nextSet;
62+
$paginationLinks[] = $this->buildLink(LinkNamesEnum::LINK_NAME_PAGINATION_NEXT, $request, $queryParamsNext);
63+
}
64+
65+
return new LinkCollection(array_merge($paginationLinks, null === $links ? [] : $links->getLinks()));
66+
}
67+
68+
/** @param array<string,string> $queryParams */
69+
private function buildLink(string $linkName, Request $request, array $queryParams): Link
70+
{
71+
return new Link(
72+
$linkName,
73+
new LinkUrl($request->getSchemeAndHttpHost() . $request->getPathInfo() . '?'
74+
. urldecode(http_build_query($queryParams))),
75+
);
76+
}
77+
78+
private function buildPaginationIfAvailable(Request $request): ?PaginationInterface
79+
{
80+
if (false === $request->query->has(GetResourceCollectionRequest::PAGINATION_KEY)) {
81+
return null;
82+
}
83+
84+
return (new PaginationFactory())
85+
->fromArray($request->query->all()[GetResourceCollectionRequest::PAGINATION_KEY] ?? null);
86+
}
87+
}

src/Service/Resource/Factory/ResourceFactory.php

+12-7
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,19 @@
1919
use Undabot\SymfonyJsonApi\Model\Resource\Metadata\ResourceMetadata;
2020
use Undabot\SymfonyJsonApi\Service\Resource\Builder\ResourceAttributesBuilder;
2121
use Undabot\SymfonyJsonApi\Service\Resource\Builder\ResourceRelationshipsBuilder;
22+
use Undabot\SymfonyJsonApi\Service\Resource\Validation\ResourceValidator;
2223

2324
/**
2425
* Class responsible for creating a ResourceInterface instance out of given API model that is
2526
* correctly annotated with library's annotations.
2627
*/
2728
class ResourceFactory
2829
{
29-
/** @var ResourceMetadataFactory */
30-
private $metadataFactory;
31-
32-
public function __construct(ResourceMetadataFactory $metadataFactory)
33-
{
34-
$this->metadataFactory = $metadataFactory;
30+
public function __construct(
31+
private ResourceMetadataFactory $metadataFactory,
32+
private bool $shouldValidateReadModel,
33+
private ResourceValidator $validator,
34+
) {
3535
}
3636

3737
/**
@@ -53,7 +53,12 @@ public function make(ApiModel $apiModel): ResourceInterface
5353
$attributes = $this->makeAttributeCollection($apiModel, $metadata);
5454
$relationships = $this->makeRelationshipsCollection($apiModel, $metadata);
5555

56-
return new Resource($id, $type, $attributes, $relationships);
56+
$resource = new Resource($id, $type, $attributes, $relationships);
57+
if (true === $this->shouldValidateReadModel) {
58+
$this->validator->assertValid($resource, \get_class($apiModel));
59+
}
60+
61+
return $resource;
5762
}
5863

5964
/**

0 commit comments

Comments
 (0)