Skip to content

Commit 05b5722

Browse files
authored
fix(jsonschema): access related subschema on readableLink (#5515)
fixes #5501 The locations relation inside BrokenDocs is a Resource (named Related) but its only operation is a NotExposed. Still, serializer groups are set, and therefore it is a "readableLink" so we actually want to compute the schema, even if it's not accessible directly, it is accessible through that relation.
1 parent f128e3b commit 05b5722

File tree

6 files changed

+84
-12
lines changed

6 files changed

+84
-12
lines changed

src/JsonSchema/SchemaFactory.php

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
2525
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2626
use ApiPlatform\Metadata\Util\ResourceClassInfoTrait;
27+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5501\Related;
2728
use Symfony\Component\PropertyInfo\Type;
2829
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
2930
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
@@ -38,6 +39,8 @@ final class SchemaFactory implements SchemaFactoryInterface
3839
use ResourceClassInfoTrait;
3940
private array $distinctFormats = [];
4041

42+
// Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object
43+
public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link';
4144
public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name';
4245

4346
public function __construct(private readonly TypeFactoryInterface $typeFactory, ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ResourceClassResolverInterface $resourceClassResolver = null)
@@ -295,29 +298,23 @@ private function getMetadata(string $className, string $type = Schema::TYPE_OUTP
295298
}
296299

297300
$inputOrOutput = ['class' => $className];
301+
$inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
302+
$outputClass = ($serializerContext[self::FORCE_SUBSCHEMA] ?? false) ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null);
298303

299-
if ($operation) {
300-
$inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput);
301-
}
302-
303-
if (null === ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null)) {
304+
if (null === $outputClass) {
304305
// input or output disabled
305306
return null;
306307
}
307308

308-
if (!$operation) {
309-
return [$operation, $serializerContext ?? [], [], $inputOrOutput['class'] ?? $inputOrOutput->class];
310-
}
311-
312309
return [
313310
$operation,
314311
$serializerContext ?? $this->getSerializerContext($operation, $type),
315312
$this->getValidationGroups($operation),
316-
$inputOrOutput['class'] ?? $inputOrOutput->class,
313+
$outputClass,
317314
];
318315
}
319316

320-
private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation)
317+
private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation
321318
{
322319
// Find the operation and use the first one that matches criterias
323320
foreach ($resourceMetadataCollection as $resourceMetadata) {

src/JsonSchema/Tests/TypeFactoryTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ public function testGetClassType(): void
399399
{
400400
$schemaFactoryProphecy = $this->prophesize(SchemaFactoryInterface::class);
401401

402-
$schemaFactoryProphecy->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, null, Argument::type(Schema::class), ['foo' => 'bar'], false)->will(function (array $args) {
402+
$schemaFactoryProphecy->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, null, Argument::type(Schema::class), Argument::type('array'), false)->will(function (array $args) {
403403
$args[4]['$ref'] = 'ref';
404404

405405
return $args[4];

src/JsonSchema/TypeFactory.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ private function getClassType(?string $className, bool $nullable, string $format
152152
throw new \LogicException('The schema factory must be injected by calling the "setSchemaFactory" method.');
153153
}
154154

155+
$serializerContext += [SchemaFactory::FORCE_SUBSCHEMA => true];
155156
$subSchema = $this->schemaFactory->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, $subSchema, $serializerContext, false);
156157

157158
return ['$ref' => $subSchema['$ref']];
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\ApiResource\Issue5501;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Get;
18+
use Doctrine\Common\Collections\ArrayCollection;
19+
use Symfony\Component\Serializer\Annotation\Groups;
20+
21+
#[ApiResource(
22+
operations: [
23+
new Get(
24+
normalizationContext: ['groups' => ['location:read_collection']]
25+
),
26+
]
27+
)]
28+
class BrokenDocs
29+
{
30+
public ?int $id = null;
31+
32+
/**
33+
* @var ?ArrayCollection<Related>
34+
*/
35+
#[Groups(['location:write', 'location:read_collection'])]
36+
public ?ArrayCollection $locations;
37+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\ApiResource\Issue5501;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use Symfony\Component\Serializer\Annotation\Groups;
18+
19+
#[ApiResource(operations: [])]
20+
class Related
21+
{
22+
#[Groups(['location:write', 'location:read_collection'])]
23+
public ?string $name = null;
24+
}

tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,17 @@ public function testExecuteWithJsonldTypeInput(): void
8080
$this->assertStringNotContainsString('@context', $result);
8181
$this->assertStringNotContainsString('@type', $result);
8282
}
83+
84+
/**
85+
* Test issue #5501, the locations relation inside BrokenDocs is a Resource (named Related) but its only operation is a NotExposed.
86+
* Still, serializer groups are set, and therefore it is a "readableLink" so we actually want to compute the schema, even if it's not accessible
87+
* directly, it is accessible through that relation.
88+
*/
89+
public function testExecuteWithNotExposedResourceAndReadableLink(): void
90+
{
91+
$this->tester->run(['command' => 'api:json-schema:generate', 'resource' => 'ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5501\BrokenDocs', '--type' => 'output']);
92+
$result = $this->tester->getDisplay();
93+
94+
$this->assertStringContainsString('Related.jsonld-location.read_collection', $result);
95+
}
8396
}

0 commit comments

Comments
 (0)