From 809f22b6d4188b67dacbba2e53ba0033ec3b3bd6 Mon Sep 17 00:00:00 2001 From: Sije Harkema Date: Mon, 26 Oct 2026 14:28:07 +0100 Subject: [PATCH] feat(jsonschema): describe PHPDoc array shapes as objects Keyed array shapes such as array{total: int, failed?: int} now generate an object schema with their properties and required keys, instead of a generic map with a union value type. Sealed shapes get additionalProperties: false, unsealed shapes with a value type get it as additionalProperties. List shapes stay arrays. Closes #8766 --- .../Factory/SchemaPropertyMetadataFactory.php | 40 +++++++ .../ApiResource/Issue8766/Issue8766Report.php | 45 ++++++++ .../JsonSchema/JsonSchemaArrayShapeTest.php | 104 ++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue8766/Issue8766Report.php create mode 100644 tests/Functional/JsonSchema/JsonSchemaArrayShapeTest.php diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index a188aa92ed..7d87358860 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; use Ramsey\Uuid\UuidInterface; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ArrayShapeType; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\IntersectionType; @@ -244,6 +245,12 @@ private function getJsonSchemaFromType(Type $type, ?bool $readableLink = null): return $this->applyNullability(['allOf' => $schemas], $isNullable); } + // A list shape (array{int, string}) is serialized as a JSON array, so it is left to the CollectionType branch. + // symfony/type-info < 7.4.20 resolves its keyless items to a single '' key, which is left to that branch too. + if ($type instanceof ArrayShapeType && !array_is_list($shape = $type->getShape()) && !isset($shape[''])) { + return $this->applyNullability($this->getArrayShapeSchema($type, $readableLink), $isNullable); + } + if ($type instanceof CollectionType) { $valueType = $type->getCollectionValueType(); $valueSchema = $this->getJsonSchemaFromType($valueType, $readableLink); @@ -300,6 +307,39 @@ private function getJsonSchemaFromType(Type $type, ?bool $readableLink = null): return ['type' => Schema::UNKNOWN_TYPE]; } + /** + * @return array + */ + private function getArrayShapeSchema(ArrayShapeType $type, ?bool $readableLink): array + { + $properties = []; + $required = []; + + foreach ($type->getShape() as $key => ['type' => $valueType, 'optional' => $optional]) { + $valueSchema = $this->getJsonSchemaFromType($valueType, $readableLink); + $properties[(string) $key] = [] === $valueSchema ? new \ArrayObject() : $valueSchema; + + if (!$optional) { + $required[] = (string) $key; + } + } + + $schema = ['type' => 'object', 'properties' => $properties]; + + // An empty "required" is invalid in OpenAPI 3.0 + if ($required) { + $schema['required'] = $required; + } + + if ($type->isSealed()) { + $schema['additionalProperties'] = false; + } elseif (null !== ($extraValueType = $type->getExtraValueType()) && [] !== ($extraSchema = $this->getJsonSchemaFromType($extraValueType, $readableLink))) { + $schema['additionalProperties'] = $extraSchema; + } + + return $schema; + } + /** * Gets the JSON Schema definition for a class. */ diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue8766/Issue8766Report.php b/tests/Fixtures/TestBundle/ApiResource/Issue8766/Issue8766Report.php new file mode 100644 index 0000000000..3707c06ca2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue8766/Issue8766Report.php @@ -0,0 +1,45 @@ + + * + * 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\Issue8766; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; + +#[ApiResource( + shortName: 'Issue8766Report', + operations: [new Get(uriTemplate: '/issue8766_reports/{id}', provider: [self::class, 'provide'])], +)] +final class Issue8766Report +{ + public int $id = 1; + + /** @var array{total: int, processed: int, failed?: int|null} */ + public array $totals; + + /** @var array{name: string, ...} */ + public array $unsealed; + + /** @var array{int, string} */ + public array $tuple; + + public static function provide(): self + { + $report = new self(); + $report->totals = ['total' => 0, 'processed' => 0]; + $report->unsealed = ['name' => '']; + $report->tuple = [0, '']; + + return $report; + } +} diff --git a/tests/Functional/JsonSchema/JsonSchemaArrayShapeTest.php b/tests/Functional/JsonSchema/JsonSchemaArrayShapeTest.php new file mode 100644 index 0000000000..85c1fe0037 --- /dev/null +++ b/tests/Functional/JsonSchema/JsonSchemaArrayShapeTest.php @@ -0,0 +1,104 @@ + + * + * 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\Functional\JsonSchema; + +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8766\Issue8766Report; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ArrayShapeType; +use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; + +final class JsonSchemaArrayShapeTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private SchemaFactoryInterface $schemaFactory; + + protected function setUp(): void + { + parent::setUp(); + $this->schemaFactory = self::getContainer()->get('api_platform.json_schema.schema_factory'); + } + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Issue8766Report::class]; + } + + public function testArrayShapeDescribesItsKeys(): void + { + $this->assertEquals(new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'total' => ['type' => 'integer'], + 'processed' => ['type' => 'integer'], + 'failed' => ['type' => ['integer', 'null']], + ], + 'required' => ['processed', 'total'], + 'additionalProperties' => false, + ]), $this->properties()['totals']); + } + + public function testUnsealedArrayShapeAllowsExtraKeys(): void + { + if (!Type::arrayShape([], false, Type::string(), Type::int())->getExtraValueType()?->isIdentifiedBy(TypeIdentifier::INT)) { + $this->markTestSkipped('symfony/type-info < 7.4.6 returns the extra key type from ArrayShapeType::getExtraValueType().'); + } + + $this->assertEquals(new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + ], + 'required' => ['name'], + 'additionalProperties' => ['type' => 'integer'], + ]), $this->properties()['unsealed']); + } + + public function testListArrayShapeStaysAnArray(): void + { + // symfony/type-info < 7.4.20 merges keyless items into one '' key, the tuple then falls back to a map + $tuple = (new StringTypeResolver())->resolve('array{int, string}'); + if ($tuple instanceof ArrayShapeType && !array_is_list($tuple->getShape())) { + $this->assertArrayNotHasKey('properties', $this->properties()['tuple']); + + return; + } + + $this->assertEquals(new \ArrayObject([ + 'type' => 'array', + 'items' => [ + 'anyOf' => [ + ['type' => 'integer'], + ['type' => 'string'], + ], + ], + ]), $this->properties()['tuple']); + } + + private function properties(): array + { + $schema = $this->schemaFactory->buildSchema(Issue8766Report::class, 'json'); + + return (array) $schema->getDefinitions()['Issue8766Report']['properties']; + } +}