diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index ad806636771..969efd039ff 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -14,6 +14,7 @@ namespace ApiPlatform\JsonSchema\Metadata\Property\Factory; use ApiPlatform\Exception\PropertyNotFoundException; +use ApiPlatform\JsonSchema\Schema; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; @@ -47,6 +48,7 @@ public function create(string $resourceClass, string $property, array $options = } } + $link = (($options['schema_type'] ?? null) === Schema::TYPE_INPUT) ? $propertyMetadata->isWritableLink() : $propertyMetadata->isReadableLink(); $propertySchema = $propertyMetadata->getSchema() ?? []; if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable())) { @@ -89,8 +91,6 @@ public function create(string $resourceClass, string $property, array $options = $propertySchema['example'] = $propertySchema['default']; } - $types = $propertyMetadata->getBuiltinTypes() ?? []; - // never override the following keys if at least one is already set if ([] === $types || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) @@ -129,7 +129,7 @@ public function create(string $resourceClass, string $property, array $options = $isCollection = false; } - $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $propertyMetadata->isReadableLink()); + $propertyType = $this->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $link); if (!\in_array($propertyType, $valueSchema, true)) { $valueSchema[] = $propertyType; } @@ -254,7 +254,8 @@ private function getClassType(?string $className, bool $nullable, ?bool $readabl ]; } - return ['type' => 'string']; + // TODO: add propertyNameCollectionFactory and recurse to find the underlying schema? Right now SchemaFactory does the job so we don't compute anything here. + return ['type' => Schema::UNKOWN_TYPE]; } /** diff --git a/src/JsonSchema/Schema.php b/src/JsonSchema/Schema.php index 45316495189..279c37da022 100644 --- a/src/JsonSchema/Schema.php +++ b/src/JsonSchema/Schema.php @@ -30,6 +30,7 @@ final class Schema extends \ArrayObject public const VERSION_JSON_SCHEMA = 'json-schema'; public const VERSION_OPENAPI = 'openapi'; public const VERSION_SWAGGER = 'swagger'; + public const UNKOWN_TYPE = 'unkown_type'; public function __construct(private readonly string $version = self::VERSION_JSON_SCHEMA) { diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 521880c7329..326a1167ce9 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -126,7 +126,7 @@ public function buildSchema(string $className, string $format = 'json', string $ $definition['externalDocs'] = ['url' => $operation->getTypes()[0]]; } - $options = $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null); + $options = ['schema_type' => $type] + $this->getFactoryOptions($serializerContext, $validationGroups, $operation instanceof HttpOperation ? $operation : null); foreach ($this->propertyNameCollectionFactory->create($inputOrOutputClass, $options) as $propertyName) { $propertyMetadata = $this->propertyMetadataFactory->create($inputOrOutputClass, $propertyName, $options); if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) { @@ -164,10 +164,16 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str // or if property has no type(s) defined // or if property schema is already fully defined (type=string + format || enum) $propertySchemaType = $propertySchema['type'] ?? false; - if ([] === $types - || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) - || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType) - || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false) + + $isUnknown = 'array' === $propertySchemaType && Schema::UNKOWN_TYPE === ($propertySchema['items']['type'] ?? null); + + if ( + !$isUnknown && ( + [] === $types + || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) + || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType) + || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false) + ) ) { $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); @@ -176,13 +182,13 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref) // complete property schema with resource reference ($ref) only if it's related to an object - $version = $schema->getVersion(); $subSchema = new Schema($version); $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema foreach ($types as $type) { - if ($type->isCollection()) { + $isCollection = $type->isCollection(); + if ($isCollection) { $valueType = $type->getCollectionValueTypes()[0] ?? null; } else { $valueType = $type; @@ -194,8 +200,18 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str } $subSchema = $this->buildSchema($className, $format, Schema::TYPE_OUTPUT, null, $subSchema, $serializerContext + [self::FORCE_SUBSCHEMA => true], false); - $propertySchema['anyOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']]; - // prevent "type" and "anyOf" conflict + if ($isCollection) { + $propertySchema['items']['$ref'] = $subSchema['$ref']; + unset($propertySchema['items']['type']); + break; + } + + if ($type->isNullable()) { + $propertySchema['oneOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']]; + } else { + $propertySchema['$ref'] = $subSchema['$ref']; + } + unset($propertySchema['type']); break; } diff --git a/tests/Fixtures/TestBundle/Entity/Issue5793/BagOfTests.php b/tests/Fixtures/TestBundle/Entity/Issue5793/BagOfTests.php new file mode 100644 index 00000000000..b12f1c7f7b3 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue5793/BagOfTests.php @@ -0,0 +1,94 @@ + + * + * 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\Entity\Issue5793; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ORM\Entity] +#[ApiResource( + normalizationContext: ['groups' => ['read']], + denormalizationContext: ['groups' => ['write']], +)] +class BagOfTests +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['read', 'write'])] + private ?int $id = null; + + #[ORM\Column(length: 255)] + #[Groups(['read', 'write'])] + private ?string $description = null; + + #[ORM\OneToMany(mappedBy: 'bagOfTests', targetEntity: TestEntity::class)] + #[Groups(['read', 'write'])] + private Collection $tests; + + public function __construct() + { + $this->tests = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(string $description): static + { + $this->description = $description; + + return $this; + } + + /** + * @return Collection + */ + public function getTests(): Collection + { + return $this->tests; + } + + public function addTest(TestEntity $test): static + { + if (!$this->tests->contains($test)) { + $this->tests->add($test); + $test->setBagOfTests($this); + } + + return $this; + } + + public function removeTest(TestEntity $test): static + { + if ($this->tests->removeElement($test)) { + // set the owning side to null (unless already changed) + if ($test->getBagOfTests() === $this) { + $test->setBagOfTests(null); + } + } + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue5793/TestEntity.php b/tests/Fixtures/TestBundle/Entity/Issue5793/TestEntity.php new file mode 100644 index 00000000000..72c2129f531 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue5793/TestEntity.php @@ -0,0 +1,81 @@ + + * + * 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\Entity\Issue5793; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ORM\Entity] +#[ApiResource] +class TestEntity +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['read', 'write'])] + private ?int $id = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['read', 'write'])] + private ?string $nullableString = null; + + #[ORM\Column(nullable: true)] + #[Groups(['read', 'write'])] + private ?int $nullableInt = null; + + #[ORM\ManyToOne(inversedBy: 'tests')] + private ?BagOfTests $bagOfTests = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getNullableString(): ?string + { + return $this->nullableString; + } + + public function setNullableString(?string $nullableString): static + { + $this->nullableString = $nullableString; + + return $this; + } + + public function getNullableInt(): ?int + { + return $this->nullableInt; + } + + public function setNullableInt(?int $nullableInt): static + { + $this->nullableInt = $nullableInt; + + return $this; + } + + public function getBagOfTests(): ?BagOfTests + { + return $this->bagOfTests; + } + + public function setBagOfTests(?BagOfTests $bagOfTests): static + { + $this->bagOfTests = $bagOfTests; + + return $this; + } +} diff --git a/tests/Hal/JsonSchema/SchemaFactoryTest.php b/tests/Hal/JsonSchema/SchemaFactoryTest.php index 70cc17da0a9..8d9e39074f6 100644 --- a/tests/Hal/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hal/JsonSchema/SchemaFactoryTest.php @@ -46,7 +46,7 @@ protected function setUp(): void ])), ])); $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true])->willReturn(new PropertyNameCollection()); + $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); $baseSchemaFactory = new BaseSchemaFactory( diff --git a/tests/Hydra/JsonSchema/SchemaFactoryTest.php b/tests/Hydra/JsonSchema/SchemaFactoryTest.php index 28f609db394..709dc5d681b 100644 --- a/tests/Hydra/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hydra/JsonSchema/SchemaFactoryTest.php @@ -47,7 +47,7 @@ protected function setUp(): void ); $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true])->willReturn(new PropertyNameCollection()); + $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_OUTPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); $baseSchemaFactory = new BaseSchemaFactory( diff --git a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php index 89d09df732d..346da8b5eb5 100644 --- a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php +++ b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php @@ -93,4 +93,21 @@ public function testExecuteWithNotExposedResourceAndReadableLink(): void $this->assertStringContainsString('Related.jsonld-location.read_collection', $result); } + + /** + * When serializer groups are present the Schema should have an embed resource. #5470 breaks array references when serializer groups are present. + */ + public function testArraySchemaWithReference(): void + { + $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => 'ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5793\BagOfTests', '--type' => 'input']); + $result = $this->tester->getDisplay(); + $json = json_decode($result, associative: true); + + $this->assertEquals($json['definitions']['BagOfTests.jsonld-write']['properties']['tests'], [ + 'type' => 'array', + 'items' => [ + '$ref' => '#/definitions/TestEntity.jsonld-write', + ], + ]); + } }