Skip to content

Commit 03edb55

Browse files
committed
Conditionally fall back to JSON-Schema compatible with Swagger/OpenAPI v2
OpenAPI V2 has no way to generate accurate nullable types, so we need to disable nullable `oneOf` syntax in JSON-Schema by providing some context to the `TypeFactory` when not operating under OpenAPI v3 or newer considerations. In future, Swagger/OpenAPIV2 will (finally) at some point disappear, so we will be able to get rid of these conditionals once that happens.
1 parent 6b6c40d commit 03edb55

File tree

5 files changed

+164
-32
lines changed

5 files changed

+164
-32
lines changed

src/JsonSchema/SchemaFactory.php

+4-4
Original file line numberDiff line numberDiff line change
@@ -258,19 +258,19 @@ private function getMetadata(string $className, string $type = Schema::TYPE_OUTP
258258

259259
return [
260260
$resourceMetadata,
261-
$serializerContext ?? $this->getSerializerContext($resourceMetadata, $type, $operationType, $operationName),
261+
$this->getSerializerContext($resourceMetadata, $type, $operationType, $operationName, $serializerContext),
262262
$inputOrOutput['class'],
263263
];
264264
}
265265

266-
private function getSerializerContext(ResourceMetadata $resourceMetadata, string $type = Schema::TYPE_OUTPUT, ?string $operationType, ?string $operationName): array
266+
private function getSerializerContext(ResourceMetadata $resourceMetadata, string $type = Schema::TYPE_OUTPUT, ?string $operationType, ?string $operationName, ?array $previousSerializerContext): array
267267
{
268268
$attribute = Schema::TYPE_OUTPUT === $type ? 'normalization_context' : 'denormalization_context';
269269

270270
if (null === $operationType || null === $operationName) {
271-
return $resourceMetadata->getAttribute($attribute, []);
271+
return array_merge($resourceMetadata->getAttribute($attribute, []), (array) $previousSerializerContext);
272272
}
273273

274-
return $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, $attribute, [], true);
274+
return array_merge($resourceMetadata->getTypedOperationAttribute($operationType, $operationName, $attribute, [], true), (array) $previousSerializerContext);
275275
}
276276
}

src/JsonSchema/TypeFactory.php

+25-5
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@
2727
*/
2828
final class TypeFactory implements TypeFactoryInterface
2929
{
30+
/**
31+
* This constant is to be provided as serializer context key to conditionally enable types to be generated in
32+
* a format that is compatible with OpenAPI specifications **PREVIOUS** to 3.0.
33+
*
34+
* Without this flag being set, the generated format will only be compatible with Swagger 3.0 or newer.
35+
*
36+
* Once support for OpenAPI < 3.0 is gone, this constant **WILL BE REMOVED**
37+
*
38+
* @internal Once support for OpenAPI < 3.0 is gone, this constant **WILL BE REMOVED** - do not rely on
39+
* it in downstream projects!
40+
*/
41+
public const CONTEXT_SERIALIZATION_FORMAT_OPENAPI_PRE_V3_0 = self::class.'::CONTEXT_SERIALIZATION_FORMAT_OPENAPI_PRE_V3_0';
42+
3043
use ResourceClassInfoTrait;
3144

3245
/**
@@ -60,7 +73,8 @@ public function getType(Type $type, string $format = 'json', ?bool $readableLink
6073
'type' => 'object',
6174
'additionalProperties' => $this->getType($subType, $format, $readableLink, $serializerContext, $schema),
6275
],
63-
$type
76+
$type,
77+
(array) $serializerContext
6478
);
6579
}
6680

@@ -69,13 +83,15 @@ public function getType(Type $type, string $format = 'json', ?bool $readableLink
6983
'type' => 'array',
7084
'items' => $this->getType($subType, $format, $readableLink, $serializerContext, $schema),
7185
],
72-
$type
86+
$type,
87+
(array) $serializerContext
7388
);
7489
}
7590

7691
return $this->addNullabilityToTypeDefinition(
7792
$this->makeBasicType($type, $format, $readableLink, $serializerContext, $schema),
78-
$type
93+
$type,
94+
(array) $serializerContext
7995
);
8096
}
8197

@@ -98,7 +114,7 @@ private function makeBasicType(Type $type, string $format = 'json', ?bool $reada
98114
/**
99115
* Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided.
100116
*/
101-
private function getClassType(?string $className, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, ?Schema $schema = null): array
117+
private function getClassType(?string $className, string $format, ?bool $readableLink, ?array $serializerContext, ?Schema $schema): array
102118
{
103119
if (null === $className) {
104120
return ['type' => 'object'];
@@ -154,8 +170,12 @@ private function getClassType(?string $className, string $format = 'json', ?bool
154170
*
155171
* @return array<string, mixed>
156172
*/
157-
private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array
173+
private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type, array $serializerContext): array
158174
{
175+
if (\array_key_exists(self::CONTEXT_SERIALIZATION_FORMAT_OPENAPI_PRE_V3_0, $serializerContext)) {
176+
return $jsonSchema;
177+
}
178+
159179
if (!$type->isNullable()) {
160180
return $jsonSchema;
161181
}

src/Swagger/Serializer/DocumentationNormalizer.php

+12-1
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,10 @@ private function getJsonSchema(bool $v3, \ArrayObject $definitions, string $reso
594594
$schema = new Schema($v3 ? Schema::VERSION_OPENAPI : Schema::VERSION_SWAGGER);
595595
$schema->setDefinitions($definitions);
596596

597+
if (!$v3) {
598+
$serializerContext = array_merge([TypeFactory::CONTEXT_SERIALIZATION_FORMAT_OPENAPI_PRE_V3_0 => null], (array) $serializerContext);
599+
}
600+
597601
$this->jsonSchemaFactory->buildSchema($resourceClass, $format, $type, $operationType, $operationName, $schema, $serializerContext, $forceCollection);
598602

599603
return $schema;
@@ -720,7 +724,14 @@ private function getFiltersParameters(bool $v3, string $resourceClass, string $o
720724
'required' => $data['required'],
721725
];
722726

723-
$type = \in_array($data['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)) : ['type' => 'string'];
727+
$type = \in_array($data['type'], Type::$builtinTypes, true)
728+
? $this->jsonSchemaTypeFactory->getType(
729+
new Type($data['type'], false, null, $data['is_collection'] ?? false),
730+
'json',
731+
null,
732+
$v3 ? null : [TypeFactory::CONTEXT_SERIALIZATION_FORMAT_OPENAPI_PRE_V3_0 => null]
733+
)
734+
: ['type' => 'string'];
724735
$v3 ? $parameter['schema'] = $type : $parameter += $type;
725736

726737
if ($v3 && isset($data['schema'])) {

tests/JsonSchema/TypeFactoryTest.php

+107
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,113 @@ public function typeProvider(): iterable
154154
];
155155
}
156156

157+
/** @dataProvider openAPIV2typeProvider */
158+
public function testGetTypeWithOpenAPIV2Syntax(array $schema, Type $type): void
159+
{
160+
$typeFactory = new TypeFactory();
161+
$this->assertSame($schema, $typeFactory->getType($type, 'json', null, [TypeFactory::CONTEXT_SERIALIZATION_FORMAT_OPENAPI_PRE_V3_0 => null]));
162+
}
163+
164+
public function openAPIV2typeProvider(): iterable
165+
{
166+
yield [['type' => 'integer'], new Type(Type::BUILTIN_TYPE_INT)];
167+
yield [['type' => 'integer'], new Type(Type::BUILTIN_TYPE_INT, true)];
168+
yield [['type' => 'number'], new Type(Type::BUILTIN_TYPE_FLOAT)];
169+
yield [['type' => 'number'], new Type(Type::BUILTIN_TYPE_FLOAT, true)];
170+
yield [['type' => 'boolean'], new Type(Type::BUILTIN_TYPE_BOOL)];
171+
yield [['type' => 'boolean'], new Type(Type::BUILTIN_TYPE_BOOL, true)];
172+
yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_STRING)];
173+
yield [['type' => 'string'], new Type(Type::BUILTIN_TYPE_STRING, true)];
174+
yield [['type' => 'object'], new Type(Type::BUILTIN_TYPE_OBJECT)];
175+
yield [['type' => 'object'], new Type(Type::BUILTIN_TYPE_OBJECT, true)];
176+
yield [['type' => 'string', 'format' => 'date-time'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateTimeImmutable::class)];
177+
yield [['type' => 'string', 'format' => 'date-time'], new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTimeImmutable::class)];
178+
yield [['type' => 'string', 'format' => 'duration'], new Type(Type::BUILTIN_TYPE_OBJECT, false, \DateInterval::class)];
179+
yield [['type' => 'object'], new Type(Type::BUILTIN_TYPE_OBJECT, false, Dummy::class)];
180+
yield [['type' => 'object'], new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class)];
181+
yield [['type' => 'array', 'items' => ['type' => 'string']], new Type(Type::BUILTIN_TYPE_STRING, false, null, true)];
182+
yield 'array can be itself nullable, but ignored in OpenAPI V2' => [
183+
['type' => 'array', 'items' => ['type' => 'string']],
184+
new Type(Type::BUILTIN_TYPE_STRING, true, null, true),
185+
];
186+
187+
yield 'array can contain nullable values, but ignored in OpenAPI V2' => [
188+
[
189+
'type' => 'array',
190+
'items' => ['type' => 'string'],
191+
],
192+
new Type(Type::BUILTIN_TYPE_STRING, false, null, true, null, new Type(Type::BUILTIN_TYPE_STRING, true, null, false)),
193+
];
194+
195+
yield 'map with string keys becomes an object' => [
196+
['type' => 'object', 'additionalProperties' => ['type' => 'string']],
197+
new Type(
198+
Type::BUILTIN_TYPE_STRING,
199+
false,
200+
null,
201+
true,
202+
new Type(Type::BUILTIN_TYPE_STRING, false, null, false)
203+
),
204+
];
205+
206+
yield 'nullable map with string keys becomes a nullable object, but ignored in OpenAPI V2' => [
207+
[
208+
'type' => 'object',
209+
'additionalProperties' => ['type' => 'string'],
210+
],
211+
new Type(
212+
Type::BUILTIN_TYPE_STRING,
213+
true,
214+
null,
215+
true,
216+
new Type(Type::BUILTIN_TYPE_STRING, false, null, false),
217+
new Type(Type::BUILTIN_TYPE_STRING, false, null, false)
218+
),
219+
];
220+
221+
yield 'map value type will be considered' => [
222+
['type' => 'object', 'additionalProperties' => ['type' => 'integer']],
223+
new Type(
224+
Type::BUILTIN_TYPE_ARRAY,
225+
false,
226+
null,
227+
true,
228+
new Type(Type::BUILTIN_TYPE_STRING, false, null, false),
229+
new Type(Type::BUILTIN_TYPE_INT, false, null, false)
230+
),
231+
];
232+
233+
yield 'map value type nullability will be considered, but ignored in OpenAPI V2' => [
234+
[
235+
'type' => 'object',
236+
'additionalProperties' => ['type' => 'integer'],
237+
],
238+
new Type(
239+
Type::BUILTIN_TYPE_ARRAY,
240+
false,
241+
null,
242+
true,
243+
new Type(Type::BUILTIN_TYPE_STRING, false, null, false),
244+
new Type(Type::BUILTIN_TYPE_INT, true, null, false)
245+
),
246+
];
247+
248+
yield 'nullable map can contain nullable values, but ignored in OpenAPI V2' => [
249+
[
250+
'type' => 'object',
251+
'additionalProperties' => ['type' => 'integer'],
252+
],
253+
new Type(
254+
Type::BUILTIN_TYPE_ARRAY,
255+
true,
256+
null,
257+
true,
258+
new Type(Type::BUILTIN_TYPE_STRING, false, null, false),
259+
new Type(Type::BUILTIN_TYPE_INT, true, null, false)
260+
),
261+
];
262+
}
263+
157264
public function testGetClassType(): void
158265
{
159266
$schemaFactoryProphecy = $this->prophesize(SchemaFactoryInterface::class);

tests/Swagger/Serializer/DocumentationNormalizerV2Test.php

+16-22
Original file line numberDiff line numberDiff line change
@@ -343,13 +343,8 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth
343343
'description' => 'This is an initializable but not writable property.',
344344
]),
345345
'dummyDate' => new \ArrayObject([
346-
'oneOf' => [
347-
['type' => 'null'],
348-
[
349-
'type' => 'string',
350-
'format' => 'date-time',
351-
],
352-
],
346+
'type' => 'string',
347+
'format' => 'date-time',
353348
'description' => 'This is a \DateTimeInterface object.',
354349
]),
355350
],
@@ -386,13 +381,19 @@ private function doTestNormalizeWithNameConverter(bool $legacy = false): void
386381
$propertyMetadataFactoryProphecy->create(Dummy::class, 'name')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, null, null, false));
387382
$propertyMetadataFactoryProphecy->create(Dummy::class, 'nameConverted')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a converted name.', true, true, null, null, false));
388383

389-
$nameConverterProphecy = $this->prophesize(
390-
interface_exists(AdvancedNameConverterInterface::class)
391-
? AdvancedNameConverterInterface::class
392-
: NameConverterInterface::class
393-
);
394-
$nameConverterProphecy->normalize('name', Dummy::class, DocumentationNormalizer::FORMAT, [])->willReturn('name')->shouldBeCalled();
395-
$nameConverterProphecy->normalize('nameConverted', Dummy::class, DocumentationNormalizer::FORMAT, [])->willReturn('name_converted')->shouldBeCalled();
384+
if (interface_exists(AdvancedNameConverterInterface::class)) {
385+
$nameConverter = $this->createMock(AdvancedNameConverterInterface::class);
386+
} else {
387+
$nameConverter = $this->createMock(NameConverterInterface::class);
388+
}
389+
390+
$nameConverter->method('normalize')
391+
->with(self::logicalOr('name', 'nameConverted'))
392+
->willReturnCallback(static function (string $nameToNormalize): string {
393+
return 'nameConverted' === $nameToNormalize
394+
? 'name_converted'
395+
: $nameToNormalize;
396+
});
396397

397398
$operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator()));
398399

@@ -408,10 +409,6 @@ interface_exists(AdvancedNameConverterInterface::class)
408409
* @var PropertyMetadataFactoryInterface
409410
*/
410411
$propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal();
411-
/**
412-
* @var NameConverterInterface
413-
*/
414-
$nameConverter = $nameConverterProphecy->reveal();
415412

416413
/**
417414
* @var TypeFactoryInterface|null
@@ -2024,10 +2021,7 @@ public function testNormalizeWithNestedNormalizationGroups(): void
20242021
]),
20252022
'relatedDummy' => new \ArrayObject([
20262023
'description' => 'This is a related dummy \o/.',
2027-
'oneOf' => [
2028-
['type' => 'null'],
2029-
['$ref' => '#/definitions/'.$relatedDummyRef],
2030-
],
2024+
'$ref' => '#/definitions/'.$relatedDummyRef,
20312025
]),
20322026
],
20332027
]),

0 commit comments

Comments
 (0)