Skip to content

Commit

Permalink
feat(openapi): add backed enum support (#5120)
Browse files Browse the repository at this point in the history
  • Loading branch information
alanpoulain committed Nov 7, 2022
1 parent 114b31e commit f1ecc30
Show file tree
Hide file tree
Showing 15 changed files with 221 additions and 82 deletions.
16 changes: 16 additions & 0 deletions features/openapi/docs.feature
Expand Up @@ -36,6 +36,7 @@ Feature: Documentation support
And the OpenAPI class "OverriddenOperationDummy-overridden_operation_dummy_put" exists
And the OpenAPI class "OverriddenOperationDummy-overridden_operation_dummy_read" exists
And the OpenAPI class "OverriddenOperationDummy-overridden_operation_dummy_write" exists
And the OpenAPI class "Person" exists
And the OpenAPI class "RelatedDummy" exists
And the OpenAPI class "NoCollectionDummy" exists
And the OpenAPI class "RelatedToDummyFriend" exists
Expand All @@ -57,6 +58,21 @@ Feature: Documentation support
# Properties
And the "id" property exists for the OpenAPI class "Dummy"
And the "name" property is required for the OpenAPI class "Dummy"
And the "genderType" property exists for the OpenAPI class "Person"
And the "genderType" property for the OpenAPI class "Person" should be equal to:
"""
{
"default": "male",
"example": "male",
"type": "string",
"enum": [
"male",
"female",
null
],
"nullable": true
}
"""
# Enable these tests when SF 4.4 / PHP 7.1 support is dropped
#And the "isDummyBoolean" property exists for the OpenAPI class "DummyBoolean"
#And the "isDummyBoolean" property is not read only for the OpenAPI class "DummyBoolean"
Expand Down
2 changes: 2 additions & 0 deletions phpstan.neon.dist
Expand Up @@ -83,3 +83,5 @@ parameters:
-
message: '#^Property .+ is unused.$#'
path: tests/Doctrine/Odm/PropertyInfo/Fixtures/DoctrineDummy.php
# Waiting for https://github.com/laminas/laminas-code/pull/150
- '#Call to an undefined method ReflectionEnum::.+#'
17 changes: 13 additions & 4 deletions src/Doctrine/Odm/PropertyInfo/DoctrineExtractor.php
Expand Up @@ -92,6 +92,10 @@ public function getTypes($class, $property, array $context = []): ?array
if ($metadata->hasField($property)) {
$typeOfField = $metadata->getTypeOfField($property);
$nullable = $metadata instanceof MongoDbClassMetadata && $metadata->isNullable($property);
$enumType = null;
if (null !== $enumClass = $metadata instanceof MongoDbClassMetadata ? $metadata->getFieldMapping($property)['enumType'] ?? null : null) {
$enumType = new Type(Type::BUILTIN_TYPE_OBJECT, $nullable, $enumClass);
}

switch ($typeOfField) {
case MongoDbType::DATE:
Expand All @@ -102,11 +106,16 @@ public function getTypes($class, $property, array $context = []): ?array
return [new Type(Type::BUILTIN_TYPE_ARRAY, $nullable, null, true)];
case MongoDbType::COLLECTION:
return [new Type(Type::BUILTIN_TYPE_ARRAY, $nullable, null, true, new Type(Type::BUILTIN_TYPE_INT))];
default:
$builtinType = $this->getPhpType($typeOfField);

return $builtinType ? [new Type($builtinType, $nullable)] : null;
case MongoDbType::INT:
case MongoDbType::STRING:
if ($enumType) {
return [$enumType];
}
}

$builtinType = $this->getPhpType($typeOfField);

return $builtinType ? [new Type($builtinType, $nullable)] : null;
}

return null;
Expand Down
3 changes: 3 additions & 0 deletions src/JsonSchema/SchemaFactory.php
Expand Up @@ -175,6 +175,9 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str
}

if (!isset($propertySchema['default']) && !empty($default = $propertyMetadata->getDefault())) {
if ($default instanceof \BackedEnum) {
$default = $default->value;
}
$propertySchema['default'] = $default;
}

Expand Down
16 changes: 14 additions & 2 deletions src/JsonSchema/TypeFactory.php
Expand Up @@ -72,15 +72,15 @@ private function makeBasicType(Type $type, string $format = 'json', ?bool $reada
Type::BUILTIN_TYPE_INT => ['type' => 'integer'],
Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'],
Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'],
Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $format, $readableLink, $serializerContext, $schema),
Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable(), $format, $readableLink, $serializerContext, $schema),
default => ['type' => 'string'],
};
}

/**
* 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.
*/
private function getClassType(?string $className, string $format, ?bool $readableLink, ?array $serializerContext, ?Schema $schema): array
private function getClassType(?string $className, bool $nullable, string $format, ?bool $readableLink, ?array $serializerContext, ?Schema $schema): array
{
if (null === $className) {
return ['type' => 'string'];
Expand Down Expand Up @@ -116,6 +116,18 @@ private function getClassType(?string $className, string $format, ?bool $readabl
'format' => 'binary',
];
}
if (is_a($className, \BackedEnum::class, true)) {
$rEnum = new \ReflectionEnum($className);
$enumCases = array_map(static fn (\ReflectionEnumBackedCase $rCase) => $rCase->getBackingValue(), $rEnum->getCases());
if ($nullable) {
$enumCases[] = null;
}

return [
'type' => (string) $rEnum->getBackingType(),
'enum' => $enumCases,
];
}

// Skip if $schema is null (filters only support basic types)
if (null === $schema) {
Expand Down
91 changes: 22 additions & 69 deletions tests/Behat/OpenApiContext.php
Expand Up @@ -16,7 +16,9 @@
use Behat\Behat\Context\Context;
use Behat\Behat\Context\Environment\InitializedContextEnvironment;
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
use Behat\Gherkin\Node\PyStringNode;
use Behatch\Context\RestContext;
use Behatch\Json\Json;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\ExpectationFailedException;

Expand All @@ -42,51 +44,25 @@ public function gatherContexts(BeforeScenarioScope $scope): void
$this->restContext = $restContext;
}

/**
* @Then the Swagger class :class exists
*/
public function assertTheSwaggerClassExist(string $className): void
{
try {
$this->getClassInfo($className);
} catch (\InvalidArgumentException $e) {
throw new ExpectationFailedException(sprintf('The class "%s" doesn\'t exist.', $className), null, $e);
}
}

/**
* @Then the OpenAPI class :class exists
*/
public function assertTheOpenApiClassExist(string $className): void
{
try {
$this->getClassInfo($className, 3);
$this->getClassInfo($className);
} catch (\InvalidArgumentException $e) {
throw new ExpectationFailedException(sprintf('The class "%s" doesn\'t exist.', $className), null, $e);
}
}

/**
* @Then the Swagger class :class doesn't exist
*/
public function assertTheSwaggerClassNotExist(string $className): void
{
try {
$this->getClassInfo($className);
} catch (\InvalidArgumentException) {
return;
}

throw new ExpectationFailedException(sprintf('The class "%s" exists.', $className));
}

/**
* @Then the OpenAPI class :class doesn't exist
*/
public function assertTheOpenAPIClassNotExist(string $className): void
{
try {
$this->getClassInfo($className, 3);
$this->getClassInfo($className);
} catch (\InvalidArgumentException) {
return;
}
Expand All @@ -95,7 +71,6 @@ public function assertTheOpenAPIClassNotExist(string $className): void
}

/**
* @Then the Swagger path :arg1 exists
* @Then the OpenAPI path :arg1 exists
*/
public function assertThePathExist(string $path): void
Expand All @@ -105,54 +80,32 @@ public function assertThePathExist(string $path): void
Assert::assertTrue(isset($json->paths) && isset($json->paths->{$path}));
}

/**
* @Then the :prop property exists for the Swagger class :class
*/
public function assertThePropertyExistForTheSwaggerClass(string $propertyName, string $className): void
{
try {
$this->getPropertyInfo($propertyName, $className);
} catch (\InvalidArgumentException $e) {
throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" doesn\'t exist.', $propertyName, $className), null, $e);
}
}

/**
* @Then the :prop property exists for the OpenAPI class :class
*/
public function assertThePropertyExistForTheOpenApiClass(string $propertyName, string $className): void
{
try {
$this->getPropertyInfo($propertyName, $className, 3);
$this->getPropertyInfo($propertyName, $className);
} catch (\InvalidArgumentException $e) {
throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" doesn\'t exist.', $propertyName, $className), null, $e);
}
}

/**
* @Then the :prop property is required for the Swagger class :class
*/
public function assertThePropertyIsRequiredForTheSwaggerClass(string $propertyName, string $className): void
{
if (!\in_array($propertyName, $this->getClassInfo($className)->required, true)) {
throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should be required', $propertyName, $className));
}
}

/**
* @Then the :prop property is required for the OpenAPI class :class
*/
public function assertThePropertyIsRequiredForTheOpenAPIClass(string $propertyName, string $className): void
{
if (!\in_array($propertyName, $this->getClassInfo($className, 3)->required, true)) {
if (!\in_array($propertyName, $this->getClassInfo($className)->required, true)) {
throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should be required', $propertyName, $className));
}
}

/**
* @Then the :prop property is not read only for the Swagger class :class
* @Then the :prop property is not read only for the OpenAPI class :class
*/
public function assertThePropertyIsNotReadOnlyForTheSwaggerClass(string $propertyName, string $className): void
public function assertThePropertyIsNotReadOnlyForTheOpenAPIClass(string $propertyName, string $className): void
{
$propertyInfo = $this->getPropertyInfo($propertyName, $className);
if (property_exists($propertyInfo, 'readOnly') && $propertyInfo->readOnly) {
Expand All @@ -161,13 +114,15 @@ public function assertThePropertyIsNotReadOnlyForTheSwaggerClass(string $propert
}

/**
* @Then the :prop property is not read only for the OpenAPI class :class
* @Then the :prop property for the OpenAPI class :class should be equal to:
*/
public function assertThePropertyIsNotReadOnlyForTheOpenAPIClass(string $propertyName, string $className): void
public function assertThePropertyForTheOpenAPIClassShouldBeEqualTo(string $propertyName, string $className, PyStringNode $propertyContent): void
{
$propertyInfo = $this->getPropertyInfo($propertyName, $className, 3);
if (property_exists($propertyInfo, 'readOnly') && $propertyInfo->readOnly) {
throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should not be read only', $propertyName, $className));
$propertyInfo = $this->getPropertyInfo($propertyName, $className);
$propertyInfoJson = new Json(json_encode($propertyInfo));

if (new Json($propertyContent) != $propertyInfoJson) {
throw new ExpectationFailedException(sprintf("Property \"%s\" of class \"%s\" is '%s'", $propertyName, $className, $propertyInfoJson));
}
}

Expand All @@ -176,12 +131,10 @@ public function assertThePropertyIsNotReadOnlyForTheOpenAPIClass(string $propert
*
* @throws \InvalidArgumentException
*/
private function getPropertyInfo(string $propertyName, string $className, int $specVersion = 2): \stdClass
private function getPropertyInfo(string $propertyName, string $className): \stdClass
{
/**
* @var iterable $properties
*/
$properties = $this->getProperties($className, $specVersion);
/** @var iterable $properties */
$properties = $this->getProperties($className);
foreach ($properties as $classPropertyName => $property) {
if ($classPropertyName === $propertyName) {
return $property;
Expand All @@ -194,19 +147,19 @@ private function getPropertyInfo(string $propertyName, string $className, int $s
/**
* Gets all operations of a given class.
*/
private function getProperties(string $className, int $specVersion = 2): \stdClass
private function getProperties(string $className): \stdClass
{
return $this->getClassInfo($className, $specVersion)->{'properties'} ?? new \stdClass();
return $this->getClassInfo($className)->{'properties'} ?? new \stdClass();
}

/**
* Gets information about a class.
*
* @throws \InvalidArgumentException
*/
private function getClassInfo(string $className, int $specVersion = 2): \stdClass
private function getClassInfo(string $className): \stdClass
{
$nodes = 2 === $specVersion ? $this->getLastJsonResponse()->{'definitions'} : $this->getLastJsonResponse()->{'components'}->{'schemas'};
$nodes = $this->getLastJsonResponse()->{'components'}->{'schemas'};
foreach ($nodes as $classTitle => $classData) {
if ($classTitle === $className) {
return $classData;
Expand Down
10 changes: 10 additions & 0 deletions tests/Doctrine/Odm/PropertyInfo/DoctrineExtractorTest.php
Expand Up @@ -17,10 +17,13 @@
use ApiPlatform\Test\DoctrineMongoDbOdmSetup;
use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineDummy;
use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineEmbeddable;
use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineEnum;
use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineFooType;
use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineGeneratedValue;
use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineRelation;
use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\DoctrineWithEmbedded;
use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\EnumInt;
use ApiPlatform\Tests\Doctrine\Odm\PropertyInfo\Fixtures\EnumString;
use Doctrine\Common\Collections\Collection;
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Types\Type as MongoDbType;
Expand Down Expand Up @@ -128,6 +131,13 @@ public function testExtractWithEmbedMany(): void
$this->assertEquals($expectedTypes, $actualTypes);
}

public function testExtractEnum(): void
{
$this->assertEquals([new Type(Type::BUILTIN_TYPE_OBJECT, false, EnumString::class)], $this->createExtractor()->getTypes(DoctrineEnum::class, 'enumString'));
$this->assertEquals([new Type(Type::BUILTIN_TYPE_OBJECT, false, EnumInt::class)], $this->createExtractor()->getTypes(DoctrineEnum::class, 'enumInt'));
$this->assertNull($this->createExtractor()->getTypes(DoctrineEnum::class, 'enumCustom'));
}

public function typesProvider(): array
{
return [
Expand Down
37 changes: 37 additions & 0 deletions tests/Doctrine/Odm/PropertyInfo/Fixtures/DoctrineEnum.php
@@ -0,0 +1,37 @@
<?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\Doctrine\Odm\PropertyInfo\Fixtures;

use Doctrine\ODM\MongoDB\Mapping\Annotations\Document;
use Doctrine\ODM\MongoDB\Mapping\Annotations\Field;
use Doctrine\ODM\MongoDB\Mapping\Annotations\Id;

/**
* @author Alan Poulain <contact@alanpoulain.eu>
*/
#[Document]
class DoctrineEnum
{
#[Id]
public int $id;

#[Field(enumType: EnumString::class)]
protected EnumString $enumString;

#[Field(type: 'int', enumType: EnumInt::class)]
protected EnumInt $enumInt;

#[Field(type: 'custom_foo', enumType: EnumInt::class)]
protected EnumInt $enumCustom;
}
20 changes: 20 additions & 0 deletions tests/Doctrine/Odm/PropertyInfo/Fixtures/EnumInt.php
@@ -0,0 +1,20 @@
<?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\Doctrine\Odm\PropertyInfo\Fixtures;

enum EnumInt: int
{
case Foo = 0;
case Bar = 1;
}

0 comments on commit f1ecc30

Please sign in to comment.