Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/JsonSchema/SchemaFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,8 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str
}

if (Schema::UNKNOWN_TYPE === $propertySchemaType) {
$propertySchema = [];
// the type is resolved to a reference below: drop the unresolved one, keep the rest of the property schema
$propertySchema = $this->withoutTypeExpression($propertySchema);
}

// property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref)
Expand Down Expand Up @@ -492,9 +493,9 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str
}

if (($c = \count($refs)) > 1) {
$propertySchema = ['anyOf' => $refs];
$propertySchema['anyOf'] = $refs;
} elseif (1 === $c) {
$propertySchema = ['$ref' => $refs[0]['$ref']];
$propertySchema['$ref'] = $refs[0]['$ref'];
}
}

Expand Down Expand Up @@ -563,6 +564,17 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
$this->schemaFactory = $schemaFactory;
}

/**
* Drops every key getSchemaValue() reads the type from, keeping the keys that document or
* constrain the property.
*/
private function withoutTypeExpression(array $schema): array
{
unset($schema['type'], $schema['items'], $schema['allOf'], $schema['anyOf'], $schema['oneOf']);

return $schema;
}

private function getSchemaValue(array $schema, string $key): array|string|null
{
if (isset($schema['items'])) {
Expand Down
127 changes: 127 additions & 0 deletions src/JsonSchema/Tests/SchemaFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
use Symfony\Component\PropertyInfo\Type as LegacyType;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\TypeInfo\Type;
Expand Down Expand Up @@ -731,4 +732,130 @@ public function testBuildSchemaForAssociativeArray(): void
$this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['bar']['type']);
$this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['bar']['additionalProperties']);
}

/**
* @see https://github.com/api-platform/core/issues/8453
*/
public function testBuildSchemaKeepsPropertyMetadataAlongsideASingleReference(): void
{
$properties = $this->buildPropertiesWithReference('child', (new ApiProperty())
->withNativeType(Type::object(GenericChild::class))
->withDescription('The child.')
->withSchema(['description' => 'The child.', 'type' => Schema::UNKNOWN_TYPE]));

$this->assertSame([
'description' => 'The child.',
'$ref' => '#/definitions/GenericChild',
], $properties['child']->getArrayCopy());
}

/**
* @see https://github.com/api-platform/core/issues/8453
*/
public function testBuildSchemaKeepsPropertyMetadataAlongsideANullableReference(): void
{
$properties = $this->buildPropertiesWithReference('child', (new ApiProperty())
->withNativeType(Type::nullable(Type::object(GenericChild::class)))
->withDescription('The child.')
->withDefault('default_child')
->withExample('example_child')
->withSchema(['description' => 'The child.', 'default' => 'default_child', 'example' => 'example_child', 'type' => Schema::UNKNOWN_TYPE]));

$this->assertSame([
'description' => 'The child.',
'default' => 'default_child',
'example' => 'example_child',
'anyOf' => [
['$ref' => '#/definitions/GenericChild'],
['type' => 'null'],
],
], $properties['child']->getArrayCopy());
}

/**
* @see https://github.com/api-platform/core/issues/8453
*/
public function testBuildSchemaKeepsPropertyMetadataAlongsideACollectionOfReferences(): void
{
$properties = $this->buildPropertiesWithReference('child', (new ApiProperty())
->withNativeType(Type::list(Type::object(GenericChild::class)))
->withDescription('The children.')
->withWritable(false)
->withSchema(['description' => 'The children.', 'readOnly' => true, 'maxItems' => 1, 'type' => 'array', 'items' => ['type' => Schema::UNKNOWN_TYPE]]));

$this->assertSame([
'description' => 'The children.',
'readOnly' => true,
'maxItems' => 1,
'type' => 'array',
'items' => ['$ref' => '#/definitions/GenericChild'],
], $properties['child']->getArrayCopy());
}

/**
* An intersection carries its unresolved type in an allOf, which must not survive next to the references.
*
* @see https://github.com/api-platform/core/issues/8453
*/
public function testBuildSchemaKeepsPropertyMetadataAlongsideAnIntersectionOfReferences(): void
{
$properties = $this->buildPropertiesWithReference('child', (new ApiProperty())
->withNativeType(Type::intersection(Type::object(GenericChild::class), Type::object(Serializable::class)))
->withDescription('The child.')
->withSchema(['description' => 'The child.', 'allOf' => [['type' => Schema::UNKNOWN_TYPE], ['type' => Schema::UNKNOWN_TYPE]]]));

$this->assertSame([
'description' => 'The child.',
'anyOf' => [
['$ref' => '#/definitions/GenericChild'],
['$ref' => '#/definitions/Serializable'],
],
], $properties['child']->getArrayCopy());
}

/**
* Builds the schema of a non-resource class holding a single property typed after another class.
*
* @return array<string, \ArrayObject<string, mixed>>
*/
private function buildPropertiesWithReference(string $propertyName, ApiProperty $propertyMetadata): array
{
if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true
$this->markTestSkipped('This test only supports type-info component');
}

$propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class);
$propertyNameCollectionFactory->method('create')->willReturnCallback(
static fn (string $class): PropertyNameCollection => new PropertyNameCollection(
NotAResource::class === $class ? [$propertyName] : ['property'],
),
);

$childPropertyMetadata = (new ApiProperty())
->withNativeType(Type::string())
->withReadable(true)
->withSchema(['type' => 'string']);

$propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class);
$propertyMetadataFactory->method('create')->willReturnCallback(
static fn (string $class): ApiProperty => NotAResource::class === $class
? $propertyMetadata->withReadable(true)
: $childPropertyMetadata,
);

$resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class);
$resourceClassResolver->method('isResourceClass')->willReturn(false);

$schemaFactory = new SchemaFactory(
resourceMetadataFactory: $this->createStub(ResourceMetadataCollectionFactoryInterface::class),
propertyNameCollectionFactory: $propertyNameCollectionFactory,
propertyMetadataFactory: $propertyMetadataFactory,
resourceClassResolver: $resourceClassResolver,
definitionNameFactory: new DefinitionNameFactory(),
);

$schema = $schemaFactory->buildSchema(NotAResource::class);

return $schema->getDefinitions()[$schema->getRootDefinitionKey()]['properties'];
}
}
Loading