Skip to content

Commit 5bba1f8

Browse files
committed
fix(jsonschema): hashmaps produces invalid openapi schema
1 parent 717c7e5 commit 5bba1f8

File tree

4 files changed

+170
-4
lines changed

4 files changed

+170
-4
lines changed

src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ final class SchemaPropertyMetadataFactory implements PropertyMetadataFactoryInte
3434

3535
public const JSON_SCHEMA_USER_DEFINED = 'user_defined_schema';
3636

37-
public function __construct(ResourceClassResolverInterface $resourceClassResolver, private readonly ?PropertyMetadataFactoryInterface $decorated = null)
38-
{
37+
public function __construct(
38+
ResourceClassResolverInterface $resourceClassResolver,
39+
private readonly ?PropertyMetadataFactoryInterface $decorated = null,
40+
) {
3941
$this->resourceClassResolver = $resourceClassResolver;
4042
}
4143

@@ -170,10 +172,19 @@ private function getType(Type $type, ?bool $readableLink = null): array
170172
$keyType = $type->getCollectionKeyTypes()[0] ?? null;
171173
$subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false);
172174

173-
if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) {
175+
if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType() && null !== $subType) {
176+
// If the value is an object, treat as an object
177+
if (null !== $subType->getClassName()) {
178+
return $this->addNullabilityToTypeDefinition([
179+
'type' => 'object',
180+
'additionalProperties' => $this->getType($keyType, $readableLink),
181+
], $type);
182+
}
183+
184+
// Otherwise, treat as a hashmap with a generic value
174185
return $this->addNullabilityToTypeDefinition([
175186
'type' => 'object',
176-
'additionalProperties' => $this->getType($subType, $readableLink),
187+
'items' => $this->getType($subType, $readableLink),
177188
], $type);
178189
}
179190

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6800;
15+
16+
class Foo
17+
{
18+
public string $bar;
19+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6800;
15+
16+
use ApiPlatform\Metadata\Get;
17+
18+
#[Get]
19+
class TestApiDocHashmapArrayObjectIssue
20+
{
21+
/** @var array<Foo> */
22+
public array $foos;
23+
24+
/** @var Foo[] */
25+
public array $fooOtherSyntax;
26+
27+
/** @var array<string, Foo> */
28+
public array $fooHashmaps;
29+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\JsonSchema;
15+
16+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6800\TestApiDocHashmapArrayObjectIssue;
17+
use Symfony\Bundle\FrameworkBundle\Console\Application;
18+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
19+
use Symfony\Component\Console\Tester\ApplicationTester;
20+
21+
final class SchemaPropertyMetadataOpenApiTest extends KernelTestCase
22+
{
23+
private const RESOURCE_NAME_JSONLD = 'TestApiDocHashmapArrayObjectIssue.jsonld';
24+
private const FOO_JSONLD = 'Foo.jsonld';
25+
private ApplicationTester $tester;
26+
27+
protected function setUp(): void
28+
{
29+
self::bootKernel();
30+
31+
$application = new Application(self::$kernel);
32+
$application->setCatchExceptions(true);
33+
$application->setAutoExit(false);
34+
35+
$this->tester = new ApplicationTester($application);
36+
}
37+
38+
/**
39+
* @dataProvider arrayPropertyTypeSyntaxProvider
40+
*/
41+
public function testOpenApiSchemaGenerationForArrayProperty(string $propertyName): void
42+
{
43+
$this->runJsonSchemaGeneration();
44+
$result = $this->tester->getDisplay();
45+
$json = json_decode($result, true);
46+
47+
$this->assertArrayHasKey(self::RESOURCE_NAME_JSONLD, $json['definitions']);
48+
$this->assertArrayHasKey(self::FOO_JSONLD, $json['definitions']);
49+
50+
$this->assertEquals('object', $json['definitions'][self::RESOURCE_NAME_JSONLD]['type']);
51+
52+
$this->assertArrayHasKey($propertyName, $json['definitions'][self::RESOURCE_NAME_JSONLD]['properties']);
53+
$this->assertEquals(
54+
[
55+
'type' => 'array',
56+
'items' => [
57+
'$ref' => '#/definitions/'.self::FOO_JSONLD,
58+
],
59+
],
60+
$json['definitions'][self::RESOURCE_NAME_JSONLD]['properties'][$propertyName]
61+
);
62+
63+
$this->assertEquals('object', $json['definitions'][self::FOO_JSONLD]['type']);
64+
$this->assertArrayHasKey('bar', $json['definitions'][self::FOO_JSONLD]['properties']);
65+
$this->assertEquals(['type' => 'string'], $json['definitions'][self::FOO_JSONLD]['properties']['bar']);
66+
}
67+
68+
public static function arrayPropertyTypeSyntaxProvider(): \Generator
69+
{
70+
yield 'Array of Foo objects using array<Foo> syntax' => ['foos'];
71+
yield 'Array of Foo objects using Foo[] syntax' => ['fooOtherSyntax'];
72+
}
73+
74+
public function testSchemaPropertyOpenApiWhenTypeIsHashmap(): void
75+
{
76+
$this->runJsonSchemaGeneration();
77+
$result = $this->tester->getDisplay();
78+
$json = json_decode($result, true);
79+
80+
$this->assertArrayHasKey(self::RESOURCE_NAME_JSONLD, $json['definitions']);
81+
$this->assertArrayHasKey(self::FOO_JSONLD, $json['definitions']);
82+
83+
$this->assertEquals('object', $json['definitions'][self::RESOURCE_NAME_JSONLD]['type']);
84+
85+
$this->assertArrayHasKey('fooHashmaps', $json['definitions'][self::RESOURCE_NAME_JSONLD]['properties']);
86+
$this->assertEquals(
87+
[
88+
'type' => 'object',
89+
'additionalProperties' => [
90+
'type' => 'string',
91+
],
92+
],
93+
$json['definitions'][self::RESOURCE_NAME_JSONLD]['properties']['fooHashmaps']
94+
);
95+
}
96+
97+
private function runJsonSchemaGeneration(): void
98+
{
99+
$this->tester->run([
100+
'command' => 'api:json-schema:generate',
101+
'resource' => TestApiDocHashmapArrayObjectIssue::class,
102+
'--operation' => '_api_/test_api_doc_hashmap_array_object_issues{._format}_get',
103+
'--type' => 'output',
104+
'--format' => 'jsonld',
105+
]);
106+
}
107+
}

0 commit comments

Comments
 (0)