Skip to content

Commit 72e0bc5

Browse files
authored
Merge pull request #33 from sitegeist/feature/unionTypeAndInterfaceSupport
FEATURE: Union type and interface support for DTO properties
2 parents 872998a + e620b6e commit 72e0bc5

26 files changed

+698
-61
lines changed

Classes/Domain/OpenApiDocumentFactory.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use Sitegeist\SchemeOnYou\Domain\Schema\IsSupportedInSchema;
3030
use Sitegeist\SchemeOnYou\Domain\Schema\OpenApiSchemaCollection;
3131
use Neos\Flow\Mvc\Routing\Router;
32+
use Sitegeist\SchemeOnYou\Infrastructure\InterfaceImplementationDetector;
3233

3334
class OpenApiDocumentFactory
3435
{
@@ -37,6 +38,7 @@ public function __construct(
3738
private readonly Router $router,
3839
private readonly ObjectManager $objectManager,
3940
private readonly UriFactoryInterface $uriFactory,
41+
private readonly InterfaceImplementationDetector $interfaceImplementationDetector,
4042
) {
4143
}
4244

@@ -269,6 +271,15 @@ private function addConstructorArgumentTypesToRequiredSchemaClasses(array $requi
269271
// no need to look for constructor arguments in here
270272
continue;
271273
}
274+
if ($classReflection->isInterface()) {
275+
$subclasses = $this->interfaceImplementationDetector->detect($className);
276+
foreach ($subclasses as $subclass) {
277+
$classesToCheckStack[] = $subclass;
278+
$requiredSchemaClasses[ $subclass ] = $subclass;
279+
}
280+
$requiredSchemaClasses[ $className ] = $className;
281+
continue;
282+
}
272283
$constructorReflection = $classReflection->getConstructor();
273284
foreach ($constructorReflection->getParameters() as $constructorParameter) {
274285
$parameterType = $constructorParameter->getType();
@@ -281,6 +292,9 @@ private function addConstructorArgumentTypesToRequiredSchemaClasses(array $requi
281292
continue;
282293
}
283294
if (class_exists($parameterTypeName) && IsSupportedInSchema::isSatisfiedByReflectionType($parameterType)) {
295+
$requiredSchemaClasses[ $parameterTypeName ] = $parameterTypeName;
296+
$classesToCheckStack[] = $parameterTypeName;
297+
} elseif (interface_exists($parameterTypeName) && IsSupportedInSchema::isSatisfiedByReflectionType($parameterType)) {
284298
$requiredSchemaClasses[$parameterTypeName] = $parameterTypeName;
285299
$classesToCheckStack[] = $parameterTypeName;
286300
} else {

Classes/Domain/Schema/IsDataTransferObjectCollection.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ private static function evaluateReflectionClass(\ReflectionClass $reflectionClas
5858
return false;
5959
}
6060
$collectionParameterType = $collectionParameter->getType();
61-
if ($collectionParameterType instanceof \ReflectionNamedType) {
61+
if ($collectionParameterType instanceof \ReflectionType) {
6262
if (!IsSupportedInSchema::isSatisfiedByReflectionType($collectionParameterType)) {
6363
return false;
6464
}

Classes/Domain/Schema/IsSupportedInSchema.php

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,46 @@
99
#[Flow\Proxy(false)]
1010
final class IsSupportedInSchema
1111
{
12+
public static function isSatisfiedByTypeName(string $name): bool
13+
{
14+
return self::isSatisfiedByClassName($name) || self::isSatisfiedByInterfaceName($name);
15+
}
16+
1217
public static function isSatisfiedByClassName(string $className): bool
1318
{
14-
if (class_exists($className) || interface_exists($className)) {
19+
if (class_exists($className)) {
1520
return self::isSatisfiedByReflectionClass(new \ReflectionClass($className));
1621
}
1722
return false;
1823
}
1924

25+
public static function isSatisfiedByInterfaceName(string $interfaceName): bool
26+
{
27+
if (interface_exists($interfaceName, true)) {
28+
// we accept all interfaces ... for now
29+
return true;
30+
}
31+
return false;
32+
}
33+
2034
public static function isSatisfiedByReflectionType(\ReflectionType $reflection): bool
2135
{
2236
if ($reflection instanceof \ReflectionNamedType) {
2337
if (in_array($reflection->getName(), ['string', 'bool', 'int', 'float'])) {
2438
return true;
2539
}
26-
return self::isSatisfiedByClassName($reflection->getName());
40+
return self::isSatisfiedByTypeName($reflection->getName());
41+
} elseif ($reflection instanceof \ReflectionUnionType) {
42+
foreach ($reflection->getTypes() as $type) {
43+
if ($type instanceof \ReflectionNamedType) {
44+
if (self::isSatisfiedByReflectionType($type) === false) {
45+
return false; // every part of a union has to be a named type that matched the conditions
46+
}
47+
} else {
48+
return false; // only named types in unions are allowed
49+
}
50+
}
51+
return true;
2752
}
2853
return false;
2954
}

Classes/Domain/Schema/OpenApiReference.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ public function __construct(
1717
) {
1818
}
1919

20+
public static function fromClassName(string $className): self
21+
{
22+
return new self('#/components/schemas/' . str_replace('\\', '_', $className));
23+
}
24+
25+
public function getName(): string
26+
{
27+
return str_replace('#/components/schemas/', '', $this->ref);
28+
}
29+
2030
/**
2131
* @return array<string,mixed>
2232
*/

Classes/Domain/Schema/OpenApiSchema.php

Lines changed: 101 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
namespace Sitegeist\SchemeOnYou\Domain\Schema;
66

77
use Neos\Flow\Annotations as Flow;
8+
use Neos\Flow\Core\Bootstrap;
9+
use Neos\Flow\Reflection\ReflectionService;
810
use Sitegeist\SchemeOnYou\Domain\Metadata\Schema as SchemaMetadata;
911
use Sitegeist\SchemeOnYou\Domain\Metadata\StringProperty;
12+
use Sitegeist\SchemeOnYou\Infrastructure\InterfaceImplementationDetector;
1013

1114
#[Flow\Proxy(false)]
1215
final readonly class OpenApiSchema implements \JsonSerializable
@@ -27,22 +30,29 @@ public function __construct(
2730
public ?array $required = null,
2831
public ?string $format = null,
2932
public ?OpenApiReference $items = null,
33+
public ?OpenApiSchemaOrReferenceCollection $oneOf = null,
34+
public ?OpenApiSchemaOrReferenceCollection $anyOf = null,
35+
public ?OpenApiSchemaOrReferenceCollection $allOf = null,
36+
public ?OpenApiSchemaDiscriminator $discriminator = null,
3037
) {
3138
}
3239

3340
/**
34-
* @phpstan-param class-string $className
41+
* @phpstan-param string $typeName
3542
*/
36-
public static function fromClassName(string $className): self
43+
public static function fromTypeName(string $typeName): self
3744
{
38-
if (enum_exists($className)) {
39-
return self::fromReflectionEnum(new \ReflectionEnum($className));
40-
} elseif (class_exists($className)) {
41-
return self::fromReflectionClass(new \ReflectionClass($className));
45+
if (enum_exists($typeName)) {
46+
return self::fromReflectionEnum(new \ReflectionEnum($typeName));
47+
} elseif (class_exists($typeName)) {
48+
return self::fromReflectionClass(new \ReflectionClass($typeName));
49+
} elseif (interface_exists($typeName)) {
50+
return self::fromInterfaceReflectionClass(new \ReflectionClass($typeName));
4251
}
43-
throw new \DomainException('Cannot create definition from incomprehensible type ' . $className, 1709500131);
52+
throw new \DomainException('Cannot create definition from incomprehensible type ' . $typeName, 1709500131);
4453
}
4554

55+
4656
private static function fromReflectionEnum(\ReflectionEnum $reflection): self
4757
{
4858
$definitionMetadata = SchemaMetadata::fromReflectionClass($reflection);
@@ -103,13 +113,10 @@ public static function fromReflectionParameter(\ReflectionParameter $reflection)
103113
type: 'string',
104114
format: 'duration',
105115
);
106-
} elseif (class_exists($typeName)) {
107-
return self::fromClassName($typeName);
108-
} else {
109-
throw new \DomainException(sprintf('Schema can only be created for collection, value objects and backed enums "%s" is neither.', $reflection->getName()));
110116
}
117+
return self::fromReflectionNamedType($reflectionType);
111118
} elseif ($reflectionType instanceof \ReflectionUnionType) {
112-
throw new \DomainException(sprintf('Schema can only be created for collection, value objects and backed enums "%s" is neither.', $reflection->getName()));
119+
return self::fromReflectionUnionType($reflectionType);
113120
} else {
114121
throw new \DomainException(sprintf('Schema can only be created for collection, value objects and backed enums "%s" is neither.', $reflection->getName()));
115122
}
@@ -136,6 +143,9 @@ public static function fromReflectionClass(\ReflectionClass $reflection): self
136143
return self::fromCollectionReflectionClass($reflection);
137144
} elseif (IsDataTransferObject::isSatisfiedByReflectionClass($reflection)) {
138145
return self::fromObjectReflectionClass($reflection);
146+
} elseif (interface_exists($reflection->getName())) {
147+
// @todo is this still used
148+
return self::fromInterfaceReflectionClass($reflection);
139149
}
140150
throw new \DomainException(sprintf('Schema can only be created for collection, value objects and backed enums "%s" is neither.', $reflection->getName()));
141151
}
@@ -215,26 +225,10 @@ private static function fromObjectReflectionClass(\ReflectionClass $reflectionCl
215225
$type,
216226
$reflectionParameter
217227
),
218-
\ReflectionUnionType::class => [
219-
'oneOf' => array_map(
220-
fn (\ReflectionType $singleType): SchemaType|OpenApiReference
221-
=> match (get_class($singleType)) {
222-
\ReflectionIntersectionType::class,
223-
=> throw new \DomainException(
224-
'Cannot resolve schema reference from intersection type'
225-
. ' given for constructor parameter'
226-
. $reflectionParameter->name . ' of class ' . $reflectionClass->name,
227-
1709560366
228-
),
229-
\ReflectionNamedType::class => SchemaType::selfOrReferenceFromReflectionNamedType(
230-
$singleType,
231-
$reflectionParameter,
232-
),
233-
default => throw new \DomainException('wat')
234-
},
235-
$type->getTypes()
236-
)
237-
],
228+
\ReflectionUnionType::class => SchemaType::fromReflectionUnionType(
229+
$type,
230+
$reflectionParameter
231+
),
238232
\ReflectionIntersectionType::class => throw new \DomainException(
239233
'Cannot resolve schema reference from intersection type given for constructor parameter'
240234
. $reflectionParameter->name . ' of class ' . $reflectionClass->name,
@@ -263,6 +257,81 @@ private static function fromObjectReflectionClass(\ReflectionClass $reflectionCl
263257
);
264258
}
265259

260+
private static function fromReflectionNamedType(\ReflectionNamedType $reflectionType): self
261+
{
262+
return self::fromTypeName($reflectionType->getName());
263+
}
264+
265+
private static function fromReflectionUnionType(\ReflectionUnionType $reflection): self
266+
{
267+
$subSchemas = [];
268+
foreach ($reflection->getTypes() as $type) {
269+
if ($type instanceof \ReflectionNamedType) {
270+
$subSchemas[] = new OpenApiSchema(
271+
type: 'object',
272+
allOf: new OpenApiSchemaOrReferenceCollection(
273+
self::discriminatorForClassName($type->getName()),
274+
OpenApiReference::fromClassName($type->getName())
275+
)
276+
);
277+
} else {
278+
throw new \DomainException('Union types are only supported for named types. ' . get_class($type) . ' given');
279+
}
280+
}
281+
282+
return new self(
283+
type: 'object',
284+
oneOf: new OpenApiSchemaOrReferenceCollection(...$subSchemas),
285+
discriminator: new OpenApiSchemaDiscriminator()
286+
);
287+
}
288+
289+
/**
290+
* @param \ReflectionClass<object> $reflectionClass
291+
*/
292+
private static function fromInterfaceReflectionClass(\ReflectionClass $reflectionClass): self
293+
{
294+
$schemaMetadata = SchemaMetadata::fromReflectionClass($reflectionClass);
295+
296+
$detector = new InterfaceImplementationDetector();
297+
$implementationClasses = $detector->detect($reflectionClass->name);
298+
299+
$implementationSchemas = [];
300+
foreach ($implementationClasses as $implementationClass) {
301+
$implementationSchemas[] = new OpenApiSchema(
302+
type: 'object',
303+
allOf: new OpenApiSchemaOrReferenceCollection(
304+
self::discriminatorForClassName($implementationClass),
305+
OpenApiReference::fromClassName($implementationClass)
306+
)
307+
);
308+
}
309+
310+
return new self(
311+
type: 'object',
312+
name: $schemaMetadata->name ?: $reflectionClass->getShortName(),
313+
description: $schemaMetadata->description,
314+
oneOf: new OpenApiSchemaOrReferenceCollection(...$implementationSchemas),
315+
discriminator: new OpenApiSchemaDiscriminator()
316+
);
317+
}
318+
319+
public static function discriminatorForClassName(string $className): self
320+
{
321+
return new self(
322+
type: 'object',
323+
properties: [
324+
OpenApiSchemaDiscriminator::DISCRIMINATOR_NAME => new SchemaType(
325+
[
326+
'type' => 'string',
327+
'enum' => [str_replace('\\', '_', $className)]
328+
]
329+
)
330+
],
331+
required: [OpenApiSchemaDiscriminator::DISCRIMINATOR_NAME]
332+
);
333+
}
334+
266335
public function toReference(): OpenApiReference
267336
{
268337
return new OpenApiReference('#/components/schemas/' . $this->name);

Classes/Domain/Schema/OpenApiSchemaCollection.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public function __construct(
2424
public static function fromClassNames(array $classNames): self
2525
{
2626
return new self(...array_map(
27-
fn (string $className): OpenApiSchema => OpenApiSchema::fromClassName($className),
27+
fn (string $className): OpenApiSchema => OpenApiSchema::fromTypeName($className),
2828
$classNames
2929
));
3030
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sitegeist\SchemeOnYou\Domain\Schema;
6+
7+
use Neos\Flow\Annotations as Flow;
8+
9+
/**
10+
* @see https://swagger.io/specification/#reference-object
11+
*/
12+
#[Flow\Proxy(false)]
13+
final readonly class OpenApiSchemaDiscriminator implements \JsonSerializable
14+
{
15+
public const DISCRIMINATOR_NAME = '__type__';
16+
17+
public function __construct(
18+
public string $propertyName = self::DISCRIMINATOR_NAME,
19+
) {
20+
}
21+
22+
/**
23+
* @return array<string,mixed>
24+
*/
25+
public function jsonSerialize(): array
26+
{
27+
return [
28+
'propertyName' => $this->propertyName
29+
];
30+
}
31+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sitegeist\SchemeOnYou\Domain\Schema;
6+
7+
use Neos\Flow\Annotations as Flow;
8+
9+
#[Flow\Proxy(false)]
10+
final readonly class OpenApiSchemaOrReferenceCollection implements \JsonSerializable
11+
{
12+
/** @var array<OpenApiSchema|OpenApiReference> */
13+
private array $items;
14+
15+
public function __construct(
16+
OpenApiSchema|OpenApiReference ...$items
17+
) {
18+
$this->items = $items;
19+
}
20+
21+
/**
22+
* @return array<string, OpenApiSchema|OpenApiReference>
23+
*/
24+
public function jsonSerialize(): array
25+
{
26+
return $this->items;
27+
}
28+
}

0 commit comments

Comments
 (0)