diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b834b5e0..3b36aec2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1875,66 +1875,6 @@ parameters: count: 2 path: src/Support/Generator/ClassBasedReference.php - - - message: '#^Method Dedoc\\Scramble\\Support\\Generator\\Combined\\AllOf\:\:setItems\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/Support/Generator/Combined/AllOf.php - - - - message: '#^Method Dedoc\\Scramble\\Support\\Generator\\Combined\\AllOf\:\:setItems\(\) has parameter \$items with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: src/Support/Generator/Combined/AllOf.php - - - - message: '#^Method Dedoc\\Scramble\\Support\\Generator\\Combined\\AllOf\:\:toArray\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/Support/Generator/Combined/AllOf.php - - - - message: '#^Unable to resolve the template type TKey in call to function collect$#' - identifier: argument.templateType - count: 1 - path: src/Support/Generator/Combined/AllOf.php - - - - message: '#^Unable to resolve the template type TValue in call to function collect$#' - identifier: argument.templateType - count: 1 - path: src/Support/Generator/Combined/AllOf.php - - - - message: '#^Method Dedoc\\Scramble\\Support\\Generator\\Combined\\AnyOf\:\:setItems\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/Support/Generator/Combined/AnyOf.php - - - - message: '#^Method Dedoc\\Scramble\\Support\\Generator\\Combined\\AnyOf\:\:setItems\(\) has parameter \$items with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: src/Support/Generator/Combined/AnyOf.php - - - - message: '#^Method Dedoc\\Scramble\\Support\\Generator\\Combined\\AnyOf\:\:toArray\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: src/Support/Generator/Combined/AnyOf.php - - - - message: '#^Unable to resolve the template type TKey in call to function collect$#' - identifier: argument.templateType - count: 1 - path: src/Support/Generator/Combined/AnyOf.php - - - - message: '#^Unable to resolve the template type TValue in call to function collect$#' - identifier: argument.templateType - count: 1 - path: src/Support/Generator/Combined/AnyOf.php - - message: ''' #^Call to deprecated method uniqueSchemaName\(\) of class Dedoc\\Scramble\\Support\\Generator\\Components\: diff --git a/src/Attributes/Discriminator.php b/src/Attributes/Discriminator.php new file mode 100644 index 00000000..189dde2b --- /dev/null +++ b/src/Attributes/Discriminator.php @@ -0,0 +1,20 @@ + $mapping Class names of the documented types, optionally keyed by the values of the discriminator property. + */ + public function __construct( + public readonly string $propertyName, + public readonly array $mapping = [], + ) {} +} diff --git a/src/Diagnostics/Schema/Se002InvalidDiscriminatorMappingDiagnostic.php b/src/Diagnostics/Schema/Se002InvalidDiscriminatorMappingDiagnostic.php new file mode 100644 index 00000000..b1c99c04 --- /dev/null +++ b/src/Diagnostics/Schema/Se002InvalidDiscriminatorMappingDiagnostic.php @@ -0,0 +1,39 @@ + is_a($e, TypeToSchemaExtension::class, true), )); + // Resolved after the user extensions, so an explicit `#[Discriminator]` attribute wins over + // the extension that would otherwise document the class. + $typesToSchemaExtensions[] = DiscriminatedObjectToSchema::class; + $exceptionToResponseExtensions = array_values(array_filter( $extensions, fn ($e) => is_a($e, ExceptionToResponseExtension::class, true), diff --git a/src/Support/Generator/Combined/AllOf.php b/src/Support/Generator/Combined/AllOf.php index 8cf606cd..b57afd88 100644 --- a/src/Support/Generator/Combined/AllOf.php +++ b/src/Support/Generator/Combined/AllOf.php @@ -2,55 +2,10 @@ namespace Dedoc\Scramble\Support\Generator\Combined; -use Dedoc\Scramble\Support\Generator\Types\StringType; -use Dedoc\Scramble\Support\Generator\Types\Type; -use InvalidArgumentException; - -class AllOf extends Type +class AllOf extends CombinedType { - /** @var Type[] */ - public $items; - public function __construct() { parent::__construct('allOf'); - $this->items = [new StringType]; - } - - public function clone(): static - { - $clone = parent::clone(); - $clone->items = array_map( - fn (Type $item) => $item->clone(), - $clone->items, - ); - - return $clone; - } - - public function toArray() - { - $parentArray = parent::toArray(); - - unset($parentArray['type']); - - return [ - ...$parentArray, - 'allOf' => array_map( - fn ($item) => $item->toArray(), - $this->items, - ), - ]; - } - - public function setItems($items) - { - if (collect($items)->contains(fn ($item) => ! $item instanceof Type)) { - throw new InvalidArgumentException('All items should be instances of '.Type::class); - } - - $this->items = $items; - - return $this; } } diff --git a/src/Support/Generator/Combined/AnyOf.php b/src/Support/Generator/Combined/AnyOf.php index 16f57b54..1d813fca 100644 --- a/src/Support/Generator/Combined/AnyOf.php +++ b/src/Support/Generator/Combined/AnyOf.php @@ -2,55 +2,10 @@ namespace Dedoc\Scramble\Support\Generator\Combined; -use Dedoc\Scramble\Support\Generator\Types\StringType; -use Dedoc\Scramble\Support\Generator\Types\Type; -use InvalidArgumentException; - -class AnyOf extends Type +class AnyOf extends CombinedType { - /** @var Type[] */ - public $items; - public function __construct() { parent::__construct('anyOf'); - $this->items = [new StringType]; - } - - public function clone(): static - { - $clone = parent::clone(); - $clone->items = array_map( - fn (Type $item) => $item->clone(), - $clone->items, - ); - - return $clone; - } - - public function toArray() - { - $parentArray = parent::toArray(); - - unset($parentArray['type']); - - return [ - ...$parentArray, - 'anyOf' => array_map( - fn ($item) => $item->toArray(), - $this->items, - ), - ]; - } - - public function setItems($items) - { - if (collect($items)->contains(fn ($item) => ! $item instanceof Type)) { - throw new InvalidArgumentException('All items should be instances of '.Type::class); - } - - $this->items = $items; - - return $this; } } diff --git a/src/Support/Generator/Combined/CombinedType.php b/src/Support/Generator/Combined/CombinedType.php new file mode 100644 index 00000000..ce94189b --- /dev/null +++ b/src/Support/Generator/Combined/CombinedType.php @@ -0,0 +1,89 @@ +items = [new StringType]; + } + + public function clone(): static + { + $clone = parent::clone(); + + $clone->items = array_map( + fn (Type $item) => $item->clone(), + $clone->items, + ); + + $clone->discriminator = $clone->discriminator?->clone(); + + return $clone; + } + + /** + * @param array $items + * @return $this + */ + public function setItems($items) + { + foreach ($items as $item) { + if (! $item instanceof Type) { + throw new InvalidArgumentException('All items should be instances of '.Type::class); + } + } + + /** @var Type[] $items */ + $this->items = $items; + + return $this; + } + + public function setDiscriminator(?Discriminator $discriminator): static + { + $this->discriminator = $discriminator; + + return $this; + } + + /** + * @return array + */ + public function toArray() + { + $parentArray = parent::toArray(); + + unset($parentArray['type']); + + $result = [ + ...$parentArray, + $this->type => array_map( + fn (Type $item) => $item->toArray(), + $this->items, + ), + ]; + + if ($this->discriminator) { + $result['discriminator'] = $this->discriminator->toArray(); + } + + return $result; + } +} diff --git a/src/Support/Generator/Combined/OneOf.php b/src/Support/Generator/Combined/OneOf.php new file mode 100644 index 00000000..d8ad5aaf --- /dev/null +++ b/src/Support/Generator/Combined/OneOf.php @@ -0,0 +1,11 @@ + $mapping + */ + public function __construct( + public string $propertyName, + public array $mapping = [], + ) {} + + public function clone(): static + { + $clone = clone $this; + + $clone->mapping = array_map( + fn (Reference|string $value) => $value instanceof Reference ? $value->clone() : $value, + $clone->mapping, + ); + + return $clone; + } + + /** + * @return array + */ + public function toArray(): array + { + $result = ['propertyName' => $this->propertyName]; + + if ($this->mapping) { + $result['mapping'] = array_map( + fn (Reference|string $value) => $value instanceof Reference ? $value->getReferenceUri() : $value, + $this->mapping, + ); + } + + return $result; + } +} diff --git a/src/Support/Generator/Reference.php b/src/Support/Generator/Reference.php index bd586e61..d008db2e 100644 --- a/src/Support/Generator/Reference.php +++ b/src/Support/Generator/Reference.php @@ -44,6 +44,11 @@ public function getUniqueName() return $this->components->uniqueSchemaName($this->shortName ?: $this->fullName); } + public function getReferenceUri(): string + { + return "#/components/{$this->referenceType}/{$this->getUniqueName()}"; + } + public function setDescription(string $description): Type { $casesDescription = $this->getEnumReferenceCasesDescription(); @@ -85,7 +90,7 @@ public function toArray() return array_filter([ ...$parentArray, - '$ref' => "#/components/{$this->referenceType}/{$this->getUniqueName()}", + '$ref' => $this->getReferenceUri(), ]); } } diff --git a/src/Support/Generator/TypeTransformer.php b/src/Support/Generator/TypeTransformer.php index 90761d06..e1842f88 100644 --- a/src/Support/Generator/TypeTransformer.php +++ b/src/Support/Generator/TypeTransformer.php @@ -10,6 +10,7 @@ use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper; use Dedoc\Scramble\Support\Generator\Combined\AllOf; use Dedoc\Scramble\Support\Generator\Combined\AnyOf; +use Dedoc\Scramble\Support\Generator\Combined\CombinedType; use Dedoc\Scramble\Support\Generator\Types\ArrayType; use Dedoc\Scramble\Support\Generator\Types\BooleanType; use Dedoc\Scramble\Support\Generator\Types\IntegerType; @@ -202,10 +203,16 @@ private function registerReferences(OpenApiType $type): void return; } - if ($type instanceof AnyOf || $type instanceof AllOf) { + if ($type instanceof CombinedType) { foreach ($type->items as $item) { $this->registerReferences($item); } + + foreach ($type->discriminator?->mapping ?: [] as $mappedSchema) { + if ($mappedSchema instanceof Reference) { + $this->registerReferences($mappedSchema); + } + } } } diff --git a/src/Support/TypeToSchemaExtensions/DiscriminatedObjectToSchema.php b/src/Support/TypeToSchemaExtensions/DiscriminatedObjectToSchema.php new file mode 100644 index 00000000..f60112de --- /dev/null +++ b/src/Support/TypeToSchemaExtensions/DiscriminatedObjectToSchema.php @@ -0,0 +1,182 @@ + */ + private array $attributesCache = []; + + public function __construct( + Infer $infer, + TypeTransformer $openApiTransformer, + Components $components, + protected OpenApiContext $openApiContext, + ) { + parent::__construct($infer, $openApiTransformer, $components); + } + + public function shouldHandle(Type $type): bool + { + return $type instanceof ObjectType + && $this->getDiscriminatorAttribute($type->name) !== null; + } + + /** + * @param ObjectType $type + */ + public function toSchema(Type $type): ?OpenApiType + { + $className = $type->name; + + if (! $this->classOrInterfaceExists($className)) { + return null; + } + + if (! $attribute = $this->getDiscriminatorAttribute($className)) { + return null; + } + + $items = []; + /** @var array $mapping */ + $mapping = []; + $timesMapped = array_count_values(array_filter($attribute->mapping, is_string(...))); + + foreach ($attribute->mapping as $value => $mappedClass) { + if (! class_exists($mappedClass) && ! interface_exists($mappedClass)) { + $this->openApiContext->diagnostics->reportOnce( + Se002InvalidDiscriminatorMappingDiagnostic::forMappedType($className, $mappedClass) + ); + + continue; + } + + $items[] = $schema = $this->openApiTransformer->transform(new ObjectType($mappedClass)); + + if (! is_string($value)) { + continue; + } + + // Only the types documented as components schemas can be mapped to a discriminator value. + if ($schema instanceof Reference) { + $mapping[$value] = $schema; + } + + // Only a type mapped to a single value is known to always hold it. + if ($timesMapped[$mappedClass] === 1) { + $this->documentDiscriminatorValue($schema, $attribute->propertyName, $value); + } + } + + if (! $items) { + return null; + } + + return (new OneOf) + ->setItems($items) + ->setDiscriminator(new Discriminator($attribute->propertyName, $mapping)); + } + + /** Validators ignore the discriminator, so the value is documented as a const on the mapped type. */ + private function documentDiscriminatorValue(OpenApiType $schema, string $propertyName, string $value): void + { + $objectType = $this->findObjectTypeWithProperty($schema, $propertyName); + + if (! $property = $objectType?->getProperty($propertyName)) { + return; + } + + $property->const($value); + + $objectType->addRequired([$propertyName]); + } + + private function findObjectTypeWithProperty(?OpenApiType $schema, string $propertyName): ?OpenApiObjectType + { + if ($schema instanceof Reference) { + $resolved = $this->components->has($schema) ? $schema->resolve() : null; + + $schema = $resolved instanceof Schema ? $resolved->type : null; + } + + if ($schema instanceof OpenApiObjectType) { + return $schema->hasProperty($propertyName) ? $schema : null; + } + + // JSON resources are documented as a combination of schemas. + if ($schema instanceof CombinedType) { + foreach ($schema->items as $item) { + if ($found = $this->findObjectTypeWithProperty($item, $propertyName)) { + return $found; + } + } + } + + return null; + } + + public function reference(ObjectType $type): ?Reference + { + if (! $this->getDiscriminatorAttribute($type->name)) { + return null; + } + + return ClassBasedReference::create('schemas', $type->name, $this->components); + } + + private function getDiscriminatorAttribute(string $className): ?DiscriminatorAttribute + { + if (array_key_exists($className, $this->attributesCache)) { + return $this->attributesCache[$className] ?: null; + } + + return ($this->attributesCache[$className] = $this->getFreshDiscriminatorAttribute($className)) ?: null; + } + + private function getFreshDiscriminatorAttribute(string $className): DiscriminatorAttribute|false + { + if (! $this->classOrInterfaceExists($className)) { + return false; + } + + $attribute = ((new ReflectionClass($className))->getAttributes(DiscriminatorAttribute::class)[0] ?? null)?->newInstance(); + + // Without the mapped types there is nothing to document, so the type is handled as usual. + if (! $attribute || ! $attribute->mapping) { + return false; + } + + return $attribute; + } + + /** + * @phpstan-assert-if-true class-string $className + */ + private function classOrInterfaceExists(mixed $className): bool + { + return is_string($className) + && (class_exists($className) || interface_exists($className)); + } +} diff --git a/tests/Attributes/DiscriminatorTest.php b/tests/Attributes/DiscriminatorTest.php new file mode 100644 index 00000000..86c09b55 --- /dev/null +++ b/tests/Attributes/DiscriminatorTest.php @@ -0,0 +1,180 @@ + Route::get('test', PetController_DiscriminatorTest::class)); + + expect($openApiDocument['paths']['/test']['get']['responses'][200]['content']['application/json']['schema']) + ->toBe(['$ref' => '#/components/schemas/Pet_DiscriminatorTest']) + ->and($openApiDocument['components']['schemas']['Pet_DiscriminatorTest']) + ->toBe([ + 'oneOf' => [ + ['$ref' => '#/components/schemas/Cat_DiscriminatorTest'], + ['$ref' => '#/components/schemas/Dog_DiscriminatorTest'], + ], + 'discriminator' => [ + 'propertyName' => 'petType', + 'mapping' => [ + 'cat' => '#/components/schemas/Cat_DiscriminatorTest', + 'dog' => '#/components/schemas/Dog_DiscriminatorTest', + ], + ], + 'title' => 'Pet_DiscriminatorTest', + ]) + ->and($openApiDocument['components']['schemas']['Cat_DiscriminatorTest']) + ->toBe([ + 'type' => 'object', + 'properties' => [ + 'petType' => ['type' => 'string', 'const' => 'cat'], + 'huntingSkill' => ['type' => 'string'], + ], + 'required' => ['petType', 'huntingSkill'], + 'title' => 'Cat_DiscriminatorTest', + ]); +}); + +#[Discriminator('petType', ['cat' => Cat_DiscriminatorTest::class, 'dog' => Dog_DiscriminatorTest::class])] +abstract class Pet_DiscriminatorTest +{ + public string $petType; +} + +class Cat_DiscriminatorTest extends Pet_DiscriminatorTest +{ + public string $huntingSkill = 'lazy'; +} + +class Dog_DiscriminatorTest extends Pet_DiscriminatorTest +{ + public int $packSize = 1; +} + +class PetController_DiscriminatorTest +{ + public function __invoke(): Pet_DiscriminatorTest + { + return resolve_pet(); + } +} + +it('takes precedence over the extensions handling the annotated type', function () { + Scramble::registerExtension(PetLikeTypeToSchema_DiscriminatorTest::class); + + $openApiDocument = generateForRoute(fn () => Route::get('test', PetController_DiscriminatorTest::class)); + + expect($openApiDocument['components']['schemas']['Pet_DiscriminatorTest']) + ->toBe([ + 'oneOf' => [ + ['$ref' => '#/components/schemas/Cat_DiscriminatorTest'], + ['$ref' => '#/components/schemas/Dog_DiscriminatorTest'], + ], + 'discriminator' => [ + 'propertyName' => 'petType', + 'mapping' => [ + 'cat' => '#/components/schemas/Cat_DiscriminatorTest', + 'dog' => '#/components/schemas/Dog_DiscriminatorTest', + ], + ], + 'title' => 'Pet_DiscriminatorTest', + ]) + ->and($openApiDocument['components']['schemas']['Cat_DiscriminatorTest']) + ->toBe([ + 'type' => 'object', + 'properties' => ['handledByExtension' => ['type' => 'boolean']], + 'title' => 'Cat_DiscriminatorTest', + ]); +}); + +/** Stands in for an extension documenting a whole class hierarchy, Laravel Data objects for example. */ +class PetLikeTypeToSchema_DiscriminatorTest extends TypeToSchemaExtension +{ + public function shouldHandle(Type $type): bool + { + return $type instanceof ObjectType && is_a($type->name, Pet_DiscriminatorTest::class, true); + } + + public function toSchema(Type $type): OpenApiType + { + return (new OpenApiObjectType)->addProperty('handledByExtension', new BooleanType); + } + + public function reference(ObjectType $type): Reference + { + return ClassBasedReference::create('schemas', $type->name, $this->components); + } +} + +it('documents a polymorphic JSON resource response as oneOf with a discriminator', function () { + $openApiDocument = generateForRoute(fn () => Route::get('test', PetResourceController_DiscriminatorTest::class)); + + expect($openApiDocument['paths']['/test']['get']['responses'][200]['content']['application/json']['schema']) + ->toBe([ + 'type' => 'object', + 'properties' => [ + 'data' => ['$ref' => '#/components/schemas/PetResource_DiscriminatorTest'], + ], + 'required' => ['data'], + ]) + ->and($openApiDocument['components']['schemas']['PetResource_DiscriminatorTest']) + ->toBe([ + 'oneOf' => [ + ['$ref' => '#/components/schemas/CatResource_DiscriminatorTest'], + ['$ref' => '#/components/schemas/DogResource_DiscriminatorTest'], + ], + 'discriminator' => [ + 'propertyName' => 'petType', + 'mapping' => [ + 'cat' => '#/components/schemas/CatResource_DiscriminatorTest', + 'dog' => '#/components/schemas/DogResource_DiscriminatorTest', + ], + ], + 'title' => 'PetResource_DiscriminatorTest', + ]); +}); + +#[Discriminator('petType', ['cat' => CatResource_DiscriminatorTest::class, 'dog' => DogResource_DiscriminatorTest::class])] +abstract class PetResource_DiscriminatorTest extends JsonResource {} + +class CatResource_DiscriminatorTest extends PetResource_DiscriminatorTest +{ + public function toArray($request) + { + return [ + 'petType' => 'cat', + 'huntingSkill' => 'lazy', + ]; + } +} + +class DogResource_DiscriminatorTest extends PetResource_DiscriminatorTest +{ + public function toArray($request) + { + return [ + 'petType' => 'dog', + 'packSize' => 1, + ]; + } +} + +class PetResourceController_DiscriminatorTest +{ + public function __invoke(): PetResource_DiscriminatorTest + { + return resolve_pet_resource(); + } +} diff --git a/tests/Diagnostics/Schema/Se002InvalidDiscriminatorMappingDiagnosticTest.php b/tests/Diagnostics/Schema/Se002InvalidDiscriminatorMappingDiagnosticTest.php new file mode 100644 index 00000000..4c9ac04a --- /dev/null +++ b/tests/Diagnostics/Schema/Se002InvalidDiscriminatorMappingDiagnosticTest.php @@ -0,0 +1,61 @@ +toSchema(new ObjectType(Se002InvalidDiscriminatorMappingDiagnosticTest_Pet::class)); + + $diagnostic = $context->diagnostics->all()->sole(); + + expect($diagnostic)->toBeInstanceOf(Se002InvalidDiscriminatorMappingDiagnostic::class) + ->and($diagnostic->message())->toBe('Cannot document [App\Models\Missing] from the discriminator mapping') + ->and($diagnostic->context())->toBeInstanceOf(ClassContext::class) + ->and($diagnostic->context()->class)->toBe(Se002InvalidDiscriminatorMappingDiagnosticTest_Pet::class); +}); + +it('does not report SE002 when all the mapped types can be documented', function () { + [$context, $extension] = discriminatorDiagnosticFixture(); + + $extension->toSchema(new ObjectType(Se002InvalidDiscriminatorMappingDiagnosticTest_ValidPet::class)); + + expect($context->diagnostics->all())->toBeEmpty(); +}); + +/** + * @return array{0: OpenApiContext, 1: DiscriminatedObjectToSchema} + */ +function discriminatorDiagnosticFixture(): array +{ + $context = new OpenApiContext(new OpenApi('3.1.0'), new GeneratorConfig); + $infer = app(Infer::class); + $transformer = new TypeTransformer($infer, $context, [ + DiscriminatedObjectToSchema::class, + ]); + + return [ + $context, + new DiscriminatedObjectToSchema($infer, $transformer, $context->openApi->components, $context), + ]; +} + +#[Discriminator('petType', ['cat' => Se002InvalidDiscriminatorMappingDiagnosticTest_Cat::class, 'dog' => 'App\Models\Missing'])] +abstract class Se002InvalidDiscriminatorMappingDiagnosticTest_Pet {} + +#[Discriminator('petType', ['cat' => Se002InvalidDiscriminatorMappingDiagnosticTest_Cat::class])] +abstract class Se002InvalidDiscriminatorMappingDiagnosticTest_ValidPet {} + +class Se002InvalidDiscriminatorMappingDiagnosticTest_Cat +{ + public string $petType = 'cat'; +} diff --git a/tests/Support/Generator/CombinedTypesTest.php b/tests/Support/Generator/CombinedTypesTest.php new file mode 100644 index 00000000..f627fc56 --- /dev/null +++ b/tests/Support/Generator/CombinedTypesTest.php @@ -0,0 +1,91 @@ +setItems([new StringType, new IntegerType]); + + expect($type->toArray())->toBe([ + $key => [ + ['type' => 'string'], + ['type' => 'integer'], + ], + ]); +})->with([ + [OneOf::class, 'oneOf'], + [AnyOf::class, 'anyOf'], + [AllOf::class, 'allOf'], +]); + +it('serializes a discriminator', function () { + $components = new Components; + $components->addSchema('App\Dto\Cat', Schema::fromType(new ObjectType)); + + $type = (new OneOf) + ->setItems([$reference = $components->getSchemaReference('App\Dto\Cat')]) + ->setDiscriminator(new Discriminator('petType', [ + 'cat' => $reference, + 'dog' => 'Dog', + ])); + + expect($type->toArray())->toBe([ + 'oneOf' => [ + ['$ref' => '#/components/schemas/Cat'], + ], + 'discriminator' => [ + 'propertyName' => 'petType', + 'mapping' => [ + 'cat' => '#/components/schemas/Cat', + 'dog' => 'Dog', + ], + ], + ]); +}); + +it('always serializes the discriminator property name', function () { + expect((new Discriminator('petType'))->toArray()) + ->toBe(['propertyName' => 'petType']) + ->and((new Discriminator(''))->toArray()) + ->toBe(['propertyName' => '']); +}); + +it('serializes combined types description', function () { + $type = (new OneOf) + ->setItems([new StringType, new IntegerType]) + ->setDescription('Wow'); + + expect($type->toArray())->toBe([ + 'description' => 'Wow', + 'oneOf' => [ + ['type' => 'string'], + ['type' => 'integer'], + ], + ]); +}); + +it('clones the items and the discriminator', function () { + $type = (new OneOf) + ->setItems([new StringType]) + ->setDiscriminator($discriminator = new Discriminator('petType')); + + $clone = $type->clone(); + $clone->items[0]->format('date-time'); + $clone->discriminator->propertyName = 'type'; + + expect($type->items[0]->format)->toBe('') + ->and($discriminator->propertyName)->toBe('petType'); +}); + +it('does not allow non type items', function () { + (new OneOf)->setItems(['string']); +})->throws(\InvalidArgumentException::class); diff --git a/tests/Support/TypeToSchemaExtensions/DiscriminatedObjectToSchemaTest.php b/tests/Support/TypeToSchemaExtensions/DiscriminatedObjectToSchemaTest.php new file mode 100644 index 00000000..f672ca06 --- /dev/null +++ b/tests/Support/TypeToSchemaExtensions/DiscriminatedObjectToSchemaTest.php @@ -0,0 +1,221 @@ +components = new Components; + $this->context = new OpenApiContext((new OpenApi('3.1.0'))->setComponents($this->components), new GeneratorConfig); + $this->transformer = new TypeTransformer(app(Infer::class), $this->context, [ + PlainObjectToSchema::class, + EnumToSchema::class, + DiscriminatedObjectToSchema::class, + ]); +}); + +it('transforms a discriminated class to oneOf of the mapped schemas', function () { + $schema = $this->transformer->transform(new ObjectType(DiscriminatedObjectToSchemaTest_Pet::class)); + + expect($schema->toArray()) + ->toBe(['$ref' => '#/components/schemas/DiscriminatedObjectToSchemaTest_Pet']) + ->and($this->components->getSchema('DiscriminatedObjectToSchemaTest_Pet')->toArray()) + ->toBe([ + 'oneOf' => [ + ['$ref' => '#/components/schemas/DiscriminatedObjectToSchemaTest_Cat'], + ['$ref' => '#/components/schemas/DiscriminatedObjectToSchemaTest_Dog'], + ], + 'discriminator' => [ + 'propertyName' => 'petType', + 'mapping' => [ + 'cat' => '#/components/schemas/DiscriminatedObjectToSchemaTest_Cat', + 'dog' => '#/components/schemas/DiscriminatedObjectToSchemaTest_Dog', + ], + ], + ]); +}); + +it('produces the same schema when transformed more than once', function () { + $first = $this->transformer->transform(new ObjectType(DiscriminatedObjectToSchemaTest_Pet::class)); + $schemaAfterFirstTransform = $this->components->getSchema('DiscriminatedObjectToSchemaTest_Pet')->toArray(); + + $second = $this->transformer->transform(new ObjectType(DiscriminatedObjectToSchemaTest_Pet::class)); + + expect($second->toArray())->toBe($first->toArray()) + ->and($this->components->getSchema('DiscriminatedObjectToSchemaTest_Pet')->toArray()) + ->toBe($schemaAfterFirstTransform); +}); + +it('documents the mapped types as schemas', function () { + $this->transformer->transform(new ObjectType(DiscriminatedObjectToSchemaTest_Pet::class)); + + expect($this->components->getSchema('DiscriminatedObjectToSchemaTest_Cat')->toArray()) + ->toBe([ + 'type' => 'object', + 'properties' => [ + 'petType' => ['type' => 'string', 'const' => 'cat'], + 'huntingSkill' => ['type' => 'string'], + ], + 'required' => ['petType', 'huntingSkill'], + ]); +}); + +it('documents the const on a property documented as a reference', function () { + $this->transformer->transform(new ObjectType(DiscriminatedObjectToSchemaTest_EnumPet::class)); + $this->transformer->transform(new ObjectType(DiscriminatedObjectToSchemaTest_EnumOwner::class)); + + expect($this->components->getSchema('DiscriminatedObjectToSchemaTest_EnumCat')->toArray()['properties']['petType']) + ->toBe([ + 'const' => 'cat', + '$ref' => '#/components/schemas/DiscriminatedObjectToSchemaTest_PetType', + ]) + // The enum itself stays untouched. + ->and($this->components->getSchema('DiscriminatedObjectToSchemaTest_PetType')->toArray()) + ->toBe(['type' => 'string', 'enum' => ['cat', 'dog']]) + ->and($this->components->getSchema('DiscriminatedObjectToSchemaTest_EnumOwner')->toArray()['properties']['petType']) + ->toBe(['$ref' => '#/components/schemas/DiscriminatedObjectToSchemaTest_PetType']); +}); + +it('does not document a const for a type mapped to several values', function () { + $this->transformer->transform(new ObjectType(DiscriminatedObjectToSchemaTest_RepeatedPet::class)); + + expect($this->components->getSchema('DiscriminatedObjectToSchemaTest_Cat')->toArray()['properties']['petType']) + ->toBe(['type' => 'string']); +}); + +it('leaves a mapped type without the discriminator property alone', function () { + $this->transformer->transform(new ObjectType(DiscriminatedObjectToSchemaTest_UnpinnablePet::class)); + + expect($this->components->getSchema('DiscriminatedObjectToSchemaTest_Nameless')->toArray()) + ->toBe([ + 'type' => 'object', + 'properties' => ['name' => ['type' => 'string']], + 'required' => ['name'], + ]); +}); + +it('omits the mapping when the mapped types are not keyed', function () { + $this->transformer->transform(new ObjectType(DiscriminatedObjectToSchemaTest_ImplicitPet::class)); + + expect($this->components->getSchema('DiscriminatedObjectToSchemaTest_ImplicitPet')->toArray()) + ->toBe([ + 'oneOf' => [ + ['$ref' => '#/components/schemas/DiscriminatedObjectToSchemaTest_Cat'], + ['$ref' => '#/components/schemas/DiscriminatedObjectToSchemaTest_Dog'], + ], + 'discriminator' => [ + 'propertyName' => 'petType', + ], + ]) + ->and($this->components->getSchema('DiscriminatedObjectToSchemaTest_Cat')->toArray()['properties']['petType']) + ->toBe(['type' => 'string']); +}); + +it('supports interfaces', function () { + $this->transformer->transform(new ObjectType(DiscriminatedObjectToSchemaTest_PetContract::class)); + + expect($this->components->getSchema('DiscriminatedObjectToSchemaTest_PetContract')->toArray()) + ->toBe([ + 'oneOf' => [ + ['$ref' => '#/components/schemas/DiscriminatedObjectToSchemaTest_Cat'], + ], + 'discriminator' => [ + 'propertyName' => 'petType', + 'mapping' => [ + 'cat' => '#/components/schemas/DiscriminatedObjectToSchemaTest_Cat', + ], + ], + ]); +}); + +it('does not handle the classes without the mapped types', function () { + $extension = new DiscriminatedObjectToSchema(app(Infer::class), $this->transformer, $this->components, $this->context); + + expect($extension->shouldHandle(new ObjectType(DiscriminatedObjectToSchemaTest_Cat::class)))->toBeFalse() + ->and($extension->shouldHandle(new ObjectType(DiscriminatedObjectToSchemaTest_EmptyPet::class)))->toBeFalse() + ->and($extension->reference(new ObjectType(DiscriminatedObjectToSchemaTest_EmptyPet::class)))->toBeNull(); +}); + +it('documents a class without the mapped types the usual way', function () { + $schema = $this->transformer->transform(new ObjectType(DiscriminatedObjectToSchemaTest_EmptyPet::class)); + + expect($schema->toArray()) + ->toBe(['$ref' => '#/components/schemas/DiscriminatedObjectToSchemaTest_EmptyPet']) + ->and($this->components->getSchema('DiscriminatedObjectToSchemaTest_EmptyPet')->toArray()) + ->toBe([ + 'type' => 'object', + 'properties' => ['petType' => ['type' => 'string']], + 'required' => ['petType'], + ]); +}); + +#[Discriminator('petType', ['cat' => DiscriminatedObjectToSchemaTest_Cat::class, 'dog' => DiscriminatedObjectToSchemaTest_Dog::class])] +abstract class DiscriminatedObjectToSchemaTest_Pet +{ + public string $petType; +} + +#[Discriminator('petType', [DiscriminatedObjectToSchemaTest_Cat::class, DiscriminatedObjectToSchemaTest_Dog::class])] +abstract class DiscriminatedObjectToSchemaTest_ImplicitPet +{ + public string $petType; +} + +#[Discriminator('petType')] +class DiscriminatedObjectToSchemaTest_EmptyPet +{ + public string $petType; +} + +#[Discriminator('petType', ['cat' => DiscriminatedObjectToSchemaTest_Cat::class])] +interface DiscriminatedObjectToSchemaTest_PetContract {} + +class DiscriminatedObjectToSchemaTest_Cat extends DiscriminatedObjectToSchemaTest_Pet implements DiscriminatedObjectToSchemaTest_PetContract +{ + public string $huntingSkill = 'lazy'; +} + +class DiscriminatedObjectToSchemaTest_Dog extends DiscriminatedObjectToSchemaTest_Pet +{ + public int $packSize = 1; +} + +enum DiscriminatedObjectToSchemaTest_PetType: string +{ + case Cat = 'cat'; + case Dog = 'dog'; +} + +#[Discriminator('petType', ['cat' => DiscriminatedObjectToSchemaTest_EnumCat::class])] +abstract class DiscriminatedObjectToSchemaTest_EnumPet +{ + public DiscriminatedObjectToSchemaTest_PetType $petType; +} + +class DiscriminatedObjectToSchemaTest_EnumCat extends DiscriminatedObjectToSchemaTest_EnumPet {} + +class DiscriminatedObjectToSchemaTest_EnumOwner +{ + public DiscriminatedObjectToSchemaTest_PetType $petType; +} + +#[Discriminator('petType', ['cat' => DiscriminatedObjectToSchemaTest_Cat::class, 'kitten' => DiscriminatedObjectToSchemaTest_Cat::class])] +abstract class DiscriminatedObjectToSchemaTest_RepeatedPet {} + +#[Discriminator('petType', ['nameless' => DiscriminatedObjectToSchemaTest_Nameless::class])] +abstract class DiscriminatedObjectToSchemaTest_UnpinnablePet {} + +class DiscriminatedObjectToSchemaTest_Nameless +{ + public string $name = 'x'; +}