diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 1f758471913..fff0050c674 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -24,6 +24,7 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5501\Related; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; @@ -38,6 +39,8 @@ final class SchemaFactory implements SchemaFactoryInterface use ResourceClassInfoTrait; private array $distinctFormats = []; + // Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object + public const FORCE_SUBSCHEMA = '_api_subschema_force_readable_link'; public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; 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 } $inputOrOutput = ['class' => $className]; + $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput); + $outputClass = ($serializerContext[self::FORCE_SUBSCHEMA] ?? false) ? ($inputOrOutput['class'] ?? $inputOrOutput->class ?? $operation->getClass()) : ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null); - if ($operation) { - $inputOrOutput = Schema::TYPE_OUTPUT === $type ? ($operation->getOutput() ?? $inputOrOutput) : ($operation->getInput() ?? $inputOrOutput); - } - - if (null === ($inputOrOutput['class'] ?? $inputOrOutput->class ?? null)) { + if (null === $outputClass) { // input or output disabled return null; } - if (!$operation) { - return [$operation, $serializerContext ?? [], [], $inputOrOutput['class'] ?? $inputOrOutput->class]; - } - return [ $operation, $serializerContext ?? $this->getSerializerContext($operation, $type), $this->getValidationGroups($operation), - $inputOrOutput['class'] ?? $inputOrOutput->class, + $outputClass, ]; } - private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation) + private function findOperationForType(ResourceMetadataCollection $resourceMetadataCollection, string $type, Operation $operation): Operation { // Find the operation and use the first one that matches criterias foreach ($resourceMetadataCollection as $resourceMetadata) { diff --git a/src/JsonSchema/Tests/TypeFactoryTest.php b/src/JsonSchema/Tests/TypeFactoryTest.php index 80955373f66..9fbce51e81b 100644 --- a/src/JsonSchema/Tests/TypeFactoryTest.php +++ b/src/JsonSchema/Tests/TypeFactoryTest.php @@ -399,7 +399,7 @@ public function testGetClassType(): void { $schemaFactoryProphecy = $this->prophesize(SchemaFactoryInterface::class); - $schemaFactoryProphecy->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, null, Argument::type(Schema::class), ['foo' => 'bar'], false)->will(function (array $args) { + $schemaFactoryProphecy->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, null, Argument::type(Schema::class), Argument::type('array'), false)->will(function (array $args) { $args[4]['$ref'] = 'ref'; return $args[4]; diff --git a/src/JsonSchema/TypeFactory.php b/src/JsonSchema/TypeFactory.php index 991564180ba..608e349d400 100644 --- a/src/JsonSchema/TypeFactory.php +++ b/src/JsonSchema/TypeFactory.php @@ -152,6 +152,7 @@ private function getClassType(?string $className, bool $nullable, string $format throw new \LogicException('The schema factory must be injected by calling the "setSchemaFactory" method.'); } + $serializerContext += [SchemaFactory::FORCE_SUBSCHEMA => true]; $subSchema = $this->schemaFactory->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, $subSchema, $serializerContext, false); return ['$ref' => $subSchema['$ref']]; diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5501/BrokenDocs.php b/tests/Fixtures/TestBundle/ApiResource/Issue5501/BrokenDocs.php new file mode 100644 index 00000000000..594ab4404db --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5501/BrokenDocs.php @@ -0,0 +1,37 @@ + + * + * 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\ApiResource\Issue5501; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use Doctrine\Common\Collections\ArrayCollection; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource( + operations: [ + new Get( + normalizationContext: ['groups' => ['location:read_collection']] + ), + ] +)] +class BrokenDocs +{ + public ?int $id = null; + + /** + * @var ?ArrayCollection + */ + #[Groups(['location:write', 'location:read_collection'])] + public ?ArrayCollection $locations; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue5501/Related.php b/tests/Fixtures/TestBundle/ApiResource/Issue5501/Related.php new file mode 100644 index 00000000000..b336908f613 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue5501/Related.php @@ -0,0 +1,24 @@ + + * + * 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\ApiResource\Issue5501; + +use ApiPlatform\Metadata\ApiResource; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource(operations: [])] +class Related +{ + #[Groups(['location:write', 'location:read_collection'])] + public ?string $name = null; +} diff --git a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php index 0fb4e096388..89d09df732d 100644 --- a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php +++ b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php @@ -80,4 +80,17 @@ public function testExecuteWithJsonldTypeInput(): void $this->assertStringNotContainsString('@context', $result); $this->assertStringNotContainsString('@type', $result); } + + /** + * Test issue #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. + */ + public function testExecuteWithNotExposedResourceAndReadableLink(): void + { + $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => 'ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5501\BrokenDocs', '--type' => 'output']); + $result = $this->tester->getDisplay(); + + $this->assertStringContainsString('Related.jsonld-location.read_collection', $result); + } }