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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -300,6 +307,39 @@ private function getJsonSchemaFromType(Type $type, ?bool $readableLink = null):
return ['type' => Schema::UNKNOWN_TYPE];
}

/**
* @return array<string, mixed>
*/
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.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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, ...<string, int>} */
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;
}
}
104 changes: 104 additions & 0 deletions tests/Functional/JsonSchema/JsonSchemaArrayShapeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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'];
}
}
Loading