Skip to content

Commit 128783c

Browse files
committed
feat(openapi): add backed enum support
1 parent 1a3ae34 commit 128783c

File tree

10 files changed

+121
-78
lines changed

10 files changed

+121
-78
lines changed

features/openapi/docs.feature

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Feature: Documentation support
3636
And the OpenAPI class "OverriddenOperationDummy-overridden_operation_dummy_put" exists
3737
And the OpenAPI class "OverriddenOperationDummy-overridden_operation_dummy_read" exists
3838
And the OpenAPI class "OverriddenOperationDummy-overridden_operation_dummy_write" exists
39+
And the OpenAPI class "Person" exists
3940
And the OpenAPI class "RelatedDummy" exists
4041
And the OpenAPI class "NoCollectionDummy" exists
4142
And the OpenAPI class "RelatedToDummyFriend" exists
@@ -57,6 +58,21 @@ Feature: Documentation support
5758
# Properties
5859
And the "id" property exists for the OpenAPI class "Dummy"
5960
And the "name" property is required for the OpenAPI class "Dummy"
61+
And the "genderType" property exists for the OpenAPI class "Person"
62+
And the "genderType" property for the OpenAPI class "Person" should be equal to:
63+
"""
64+
{
65+
"default": "male",
66+
"example": "male",
67+
"type": "string",
68+
"enum": [
69+
"male",
70+
"female",
71+
null
72+
],
73+
"nullable": true
74+
}
75+
"""
6076
# Enable these tests when SF 4.4 / PHP 7.1 support is dropped
6177
#And the "isDummyBoolean" property exists for the OpenAPI class "DummyBoolean"
6278
#And the "isDummyBoolean" property is not read only for the OpenAPI class "DummyBoolean"

phpstan.neon.dist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,5 @@ parameters:
8383
-
8484
message: '#^Property .+ is unused.$#'
8585
path: tests/Doctrine/Odm/PropertyInfo/Fixtures/DoctrineDummy.php
86+
# Waiting for https://github.com/laminas/laminas-code/pull/150
87+
- '#Call to an undefined method ReflectionEnum::.+#'

src/JsonSchema/SchemaFactory.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,9 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str
175175
}
176176

177177
if (!isset($propertySchema['default']) && !empty($default = $propertyMetadata->getDefault())) {
178+
if ($default instanceof \BackedEnum) {
179+
$default = $default->value;
180+
}
178181
$propertySchema['default'] = $default;
179182
}
180183

src/JsonSchema/TypeFactory.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,15 @@ private function makeBasicType(Type $type, string $format = 'json', ?bool $reada
7272
Type::BUILTIN_TYPE_INT => ['type' => 'integer'],
7373
Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'],
7474
Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'],
75-
Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $format, $readableLink, $serializerContext, $schema),
75+
Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable(), $format, $readableLink, $serializerContext, $schema),
7676
default => ['type' => 'string'],
7777
};
7878
}
7979

8080
/**
8181
* 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.
8282
*/
83-
private function getClassType(?string $className, string $format, ?bool $readableLink, ?array $serializerContext, ?Schema $schema): array
83+
private function getClassType(?string $className, bool $nullable, string $format, ?bool $readableLink, ?array $serializerContext, ?Schema $schema): array
8484
{
8585
if (null === $className) {
8686
return ['type' => 'string'];
@@ -116,6 +116,18 @@ private function getClassType(?string $className, string $format, ?bool $readabl
116116
'format' => 'binary',
117117
];
118118
}
119+
if (is_a($className, \BackedEnum::class, true)) {
120+
$rEnum = new \ReflectionEnum($className);
121+
$enumCases = array_map(static fn (\ReflectionEnumBackedCase $rCase) => $rCase->getBackingValue(), $rEnum->getCases());
122+
if ($nullable) {
123+
$enumCases[] = null;
124+
}
125+
126+
return [
127+
'type' => (string) $rEnum->getBackingType(),
128+
'enum' => $enumCases,
129+
];
130+
}
119131

120132
// Skip if $schema is null (filters only support basic types)
121133
if (null === $schema) {

tests/Behat/OpenApiContext.php

Lines changed: 22 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
use Behat\Behat\Context\Context;
1717
use Behat\Behat\Context\Environment\InitializedContextEnvironment;
1818
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
19+
use Behat\Gherkin\Node\PyStringNode;
1920
use Behatch\Context\RestContext;
21+
use Behatch\Json\Json;
2022
use PHPUnit\Framework\Assert;
2123
use PHPUnit\Framework\ExpectationFailedException;
2224

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

45-
/**
46-
* @Then the Swagger class :class exists
47-
*/
48-
public function assertTheSwaggerClassExist(string $className): void
49-
{
50-
try {
51-
$this->getClassInfo($className);
52-
} catch (\InvalidArgumentException $e) {
53-
throw new ExpectationFailedException(sprintf('The class "%s" doesn\'t exist.', $className), null, $e);
54-
}
55-
}
56-
5747
/**
5848
* @Then the OpenAPI class :class exists
5949
*/
6050
public function assertTheOpenApiClassExist(string $className): void
6151
{
6252
try {
63-
$this->getClassInfo($className, 3);
53+
$this->getClassInfo($className);
6454
} catch (\InvalidArgumentException $e) {
6555
throw new ExpectationFailedException(sprintf('The class "%s" doesn\'t exist.', $className), null, $e);
6656
}
6757
}
6858

69-
/**
70-
* @Then the Swagger class :class doesn't exist
71-
*/
72-
public function assertTheSwaggerClassNotExist(string $className): void
73-
{
74-
try {
75-
$this->getClassInfo($className);
76-
} catch (\InvalidArgumentException) {
77-
return;
78-
}
79-
80-
throw new ExpectationFailedException(sprintf('The class "%s" exists.', $className));
81-
}
82-
8359
/**
8460
* @Then the OpenAPI class :class doesn't exist
8561
*/
8662
public function assertTheOpenAPIClassNotExist(string $className): void
8763
{
8864
try {
89-
$this->getClassInfo($className, 3);
65+
$this->getClassInfo($className);
9066
} catch (\InvalidArgumentException) {
9167
return;
9268
}
@@ -95,7 +71,6 @@ public function assertTheOpenAPIClassNotExist(string $className): void
9571
}
9672

9773
/**
98-
* @Then the Swagger path :arg1 exists
9974
* @Then the OpenAPI path :arg1 exists
10075
*/
10176
public function assertThePathExist(string $path): void
@@ -105,54 +80,32 @@ public function assertThePathExist(string $path): void
10580
Assert::assertTrue(isset($json->paths) && isset($json->paths->{$path}));
10681
}
10782

108-
/**
109-
* @Then the :prop property exists for the Swagger class :class
110-
*/
111-
public function assertThePropertyExistForTheSwaggerClass(string $propertyName, string $className): void
112-
{
113-
try {
114-
$this->getPropertyInfo($propertyName, $className);
115-
} catch (\InvalidArgumentException $e) {
116-
throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" doesn\'t exist.', $propertyName, $className), null, $e);
117-
}
118-
}
119-
12083
/**
12184
* @Then the :prop property exists for the OpenAPI class :class
12285
*/
12386
public function assertThePropertyExistForTheOpenApiClass(string $propertyName, string $className): void
12487
{
12588
try {
126-
$this->getPropertyInfo($propertyName, $className, 3);
89+
$this->getPropertyInfo($propertyName, $className);
12790
} catch (\InvalidArgumentException $e) {
12891
throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" doesn\'t exist.', $propertyName, $className), null, $e);
12992
}
13093
}
13194

132-
/**
133-
* @Then the :prop property is required for the Swagger class :class
134-
*/
135-
public function assertThePropertyIsRequiredForTheSwaggerClass(string $propertyName, string $className): void
136-
{
137-
if (!\in_array($propertyName, $this->getClassInfo($className)->required, true)) {
138-
throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should be required', $propertyName, $className));
139-
}
140-
}
141-
14295
/**
14396
* @Then the :prop property is required for the OpenAPI class :class
14497
*/
14598
public function assertThePropertyIsRequiredForTheOpenAPIClass(string $propertyName, string $className): void
14699
{
147-
if (!\in_array($propertyName, $this->getClassInfo($className, 3)->required, true)) {
100+
if (!\in_array($propertyName, $this->getClassInfo($className)->required, true)) {
148101
throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should be required', $propertyName, $className));
149102
}
150103
}
151104

152105
/**
153-
* @Then the :prop property is not read only for the Swagger class :class
106+
* @Then the :prop property is not read only for the OpenAPI class :class
154107
*/
155-
public function assertThePropertyIsNotReadOnlyForTheSwaggerClass(string $propertyName, string $className): void
108+
public function assertThePropertyIsNotReadOnlyForTheOpenAPIClass(string $propertyName, string $className): void
156109
{
157110
$propertyInfo = $this->getPropertyInfo($propertyName, $className);
158111
if (property_exists($propertyInfo, 'readOnly') && $propertyInfo->readOnly) {
@@ -161,13 +114,15 @@ public function assertThePropertyIsNotReadOnlyForTheSwaggerClass(string $propert
161114
}
162115

163116
/**
164-
* @Then the :prop property is not read only for the OpenAPI class :class
117+
* @Then the :prop property for the OpenAPI class :class should be equal to:
165118
*/
166-
public function assertThePropertyIsNotReadOnlyForTheOpenAPIClass(string $propertyName, string $className): void
119+
public function assertThePropertyForTheOpenAPIClassShouldBeEqualTo(string $propertyName, string $className, PyStringNode $propertyContent): void
167120
{
168-
$propertyInfo = $this->getPropertyInfo($propertyName, $className, 3);
169-
if (property_exists($propertyInfo, 'readOnly') && $propertyInfo->readOnly) {
170-
throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should not be read only', $propertyName, $className));
121+
$propertyInfo = $this->getPropertyInfo($propertyName, $className);
122+
$propertyInfoJson = new Json(json_encode($propertyInfo));
123+
124+
if (new Json($propertyContent) != $propertyInfoJson) {
125+
throw new ExpectationFailedException(sprintf("Property \"%s\" of class \"%s\" is '%s'", $propertyName, $className, $propertyInfoJson));
171126
}
172127
}
173128

@@ -176,12 +131,10 @@ public function assertThePropertyIsNotReadOnlyForTheOpenAPIClass(string $propert
176131
*
177132
* @throws \InvalidArgumentException
178133
*/
179-
private function getPropertyInfo(string $propertyName, string $className, int $specVersion = 2): \stdClass
134+
private function getPropertyInfo(string $propertyName, string $className): \stdClass
180135
{
181-
/**
182-
* @var iterable $properties
183-
*/
184-
$properties = $this->getProperties($className, $specVersion);
136+
/** @var iterable $properties */
137+
$properties = $this->getProperties($className);
185138
foreach ($properties as $classPropertyName => $property) {
186139
if ($classPropertyName === $propertyName) {
187140
return $property;
@@ -194,19 +147,19 @@ private function getPropertyInfo(string $propertyName, string $className, int $s
194147
/**
195148
* Gets all operations of a given class.
196149
*/
197-
private function getProperties(string $className, int $specVersion = 2): \stdClass
150+
private function getProperties(string $className): \stdClass
198151
{
199-
return $this->getClassInfo($className, $specVersion)->{'properties'} ?? new \stdClass();
152+
return $this->getClassInfo($className)->{'properties'} ?? new \stdClass();
200153
}
201154

202155
/**
203156
* Gets information about a class.
204157
*
205158
* @throws \InvalidArgumentException
206159
*/
207-
private function getClassInfo(string $className, int $specVersion = 2): \stdClass
160+
private function getClassInfo(string $className): \stdClass
208161
{
209-
$nodes = 2 === $specVersion ? $this->getLastJsonResponse()->{'definitions'} : $this->getLastJsonResponse()->{'components'}->{'schemas'};
162+
$nodes = $this->getLastJsonResponse()->{'components'}->{'schemas'};
210163
foreach ($nodes as $classTitle => $classData) {
211164
if ($classTitle === $className) {
212165
return $classData;

tests/Fixtures/TestBundle/Document/Person.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Tests\Fixtures\TestBundle\Document;
1515

1616
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum;
1718
use Doctrine\Common\Collections\ArrayCollection;
1819
use Doctrine\Common\Collections\Collection;
1920
use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM;
@@ -29,13 +30,19 @@
2930
class Person
3031
{
3132
#[ODM\Id(strategy: 'INCREMENT', type: 'int')]
32-
private $id;
33+
private ?int $id = null;
34+
3335
#[Groups(['people.pets'])]
3436
#[ODM\Field(type: 'string')]
35-
public $name;
37+
public string $name;
38+
39+
#[ODM\Field(type: 'string', enumType: GenderTypeEnum::class, nullable: true)]
40+
public ?GenderTypeEnum $genderType = GenderTypeEnum::MALE;
41+
3642
#[Groups(['people.pets'])]
3743
#[ODM\ReferenceMany(targetDocument: PersonToPet::class, mappedBy: 'person')]
3844
public Collection|iterable $pets;
45+
3946
#[ODM\ReferenceMany(targetDocument: Greeting::class, mappedBy: 'sender')]
4047
public Collection|iterable|null $sentGreetings = null;
4148

@@ -44,7 +51,7 @@ public function __construct()
4451
$this->pets = new ArrayCollection();
4552
}
4653

47-
public function getId()
54+
public function getId(): int
4855
{
4956
return $this->id;
5057
}

tests/Fixtures/TestBundle/Entity/Person.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
1515

1616
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum;
1718
use Doctrine\Common\Collections\ArrayCollection;
1819
use Doctrine\Common\Collections\Collection;
1920
use Doctrine\ORM\Mapping as ORM;
@@ -31,13 +32,19 @@ class Person
3132
#[ORM\Id]
3233
#[ORM\Column(type: 'integer')]
3334
#[ORM\GeneratedValue(strategy: 'AUTO')]
34-
private $id;
35+
private ?int $id = null;
36+
37+
#[ORM\Column(type: 'string', enumType: GenderTypeEnum::class, nullable: true)]
38+
public ?GenderTypeEnum $genderType = GenderTypeEnum::MALE;
39+
3540
#[ORM\Column(type: 'string')]
3641
#[Groups(['people.pets'])]
37-
public $name;
42+
public string $name;
43+
3844
#[ORM\OneToMany(targetEntity: PersonToPet::class, mappedBy: 'person')]
3945
#[Groups(['people.pets'])]
4046
public Collection|iterable $pets;
47+
4148
#[ORM\OneToMany(targetEntity: Greeting::class, mappedBy: 'sender')]
4249
public Collection|iterable|null $sentGreetings = null;
4350

@@ -46,7 +53,7 @@ public function __construct()
4653
$this->pets = new ArrayCollection();
4754
}
4855

49-
public function getId()
56+
public function getId(): int
5057
{
5158
return $this->id;
5259
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Enum;
15+
16+
enum GenderTypeEnum: string
17+
{
18+
case MALE = 'male';
19+
case FEMALE = 'female';
20+
}

0 commit comments

Comments
 (0)