Skip to content

Commit

Permalink
fix(jsonschema): allow embed resources
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka committed Sep 12, 2023
1 parent 4f62cb2 commit a8792e4
Show file tree
Hide file tree
Showing 8 changed files with 225 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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())) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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::UNKNOWN_TYPE];
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/JsonSchema/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 UNKNOWN_TYPE = 'unknown_type';

public function __construct(private readonly string $version = self::VERSION_JSON_SCHEMA)
{
Expand Down
34 changes: 25 additions & 9 deletions src/JsonSchema/SchemaFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -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::UNKNOWN_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);

Expand All @@ -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;
Expand All @@ -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['anyOf'] = [['$ref' => $subSchema['$ref']], ['type' => 'null']];
} else {
$propertySchema['$ref'] = $subSchema['$ref'];
}

unset($propertySchema['type']);
break;
}
Expand Down
94 changes: 94 additions & 0 deletions tests/Fixtures/TestBundle/Entity/Issue5793/BagOfTests.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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<int, TestEntity>
*/
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;
}
}
81 changes: 81 additions & 0 deletions tests/Fixtures/TestBundle/Entity/Issue5793/TestEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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;
}
}
2 changes: 1 addition & 1 deletion tests/Hal/JsonSchema/SchemaFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion tests/Hydra/JsonSchema/SchemaFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
]);
}
}

0 comments on commit a8792e4

Please sign in to comment.