Add support for php attributes (#1932)

* Add support for php attributes

* Fix tests for php 8.1

* Simplify the annotations

* Revert the changes to the tests

* CS

* Test FOSRest parsing of attributes

* CS

* typo

* CS

* Test fetchArticle php 8.1 attributes

* Fix namespaces

Co-authored-by: Guilhem Niot <guilhem@gniot.fr>
This commit is contained in:
Alex Kalineskou 2021-12-21 17:16:14 +02:00 committed by GitHub
parent 3d263a525d
commit cc97b0ba45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 624 additions and 270 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@
/.phpunit.result.cache /.phpunit.result.cache
/Tests/Functional/cache /Tests/Functional/cache
/Tests/Functional/logs /Tests/Functional/logs
.idea

View File

@ -14,6 +14,7 @@ namespace Nelmio\ApiDocBundle\Annotation;
/** /**
* @Annotation * @Annotation
*/ */
#[\Attribute(\Attribute::TARGET_METHOD)]
final class Areas final class Areas
{ {
/** @var string[] */ /** @var string[] */

View File

@ -13,10 +13,12 @@ namespace Nelmio\ApiDocBundle\Annotation;
use OpenApi\Annotations\AbstractAnnotation; use OpenApi\Annotations\AbstractAnnotation;
use OpenApi\Annotations\Parameter; use OpenApi\Annotations\Parameter;
use OpenApi\Generator;
/** /**
* @Annotation * @Annotation
*/ */
#[\Attribute(\Attribute::TARGET_METHOD)]
final class Model extends AbstractAnnotation final class Model extends AbstractAnnotation
{ {
/** {@inheritdoc} */ /** {@inheritdoc} */
@ -46,4 +48,22 @@ final class Model extends AbstractAnnotation
* @var mixed[] * @var mixed[]
*/ */
public $options; public $options;
/**
* @param mixed[] $properties
* @param string[] $groups
* @param mixed[] $options
*/
public function __construct(
array $properties = [],
string $type = Generator::UNDEFINED,
array $groups = null,
array $options = null
) {
parent::__construct($properties + [
'type' => $type,
'groups' => $groups,
'options' => $options,
]);
}
} }

View File

@ -16,6 +16,7 @@ use OpenApi\Annotations\Operation as BaseOperation;
/** /**
* @Annotation * @Annotation
*/ */
#[\Attribute(\Attribute::TARGET_METHOD)]
class Operation extends BaseOperation class Operation extends BaseOperation
{ {
} }

View File

@ -16,6 +16,7 @@ use OpenApi\Annotations\AbstractAnnotation;
/** /**
* @Annotation * @Annotation
*/ */
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class Security extends AbstractAnnotation class Security extends AbstractAnnotation
{ {
/** {@inheritdoc} */ /** {@inheritdoc} */
@ -35,4 +36,15 @@ class Security extends AbstractAnnotation
* @var string[] * @var string[]
*/ */
public $scopes = []; public $scopes = [];
public function __construct(
array $properties = [],
string $name = null,
array $scopes = []
) {
parent::__construct($properties + [
'name' => $name,
'scopes' => $scopes,
]);
}
} }

View File

@ -67,12 +67,14 @@ final class OpenApiPhpDescriber
$classAnnotations = array_filter($this->annotationReader->getClassAnnotations($declaringClass), function ($v) { $classAnnotations = array_filter($this->annotationReader->getClassAnnotations($declaringClass), function ($v) {
return $v instanceof OA\AbstractAnnotation; return $v instanceof OA\AbstractAnnotation;
}); });
$classAnnotations = array_merge($classAnnotations, $this->getAttributesAsAnnotation($declaringClass, OA\AbstractAnnotation::class));
$classAnnotations[$declaringClass->getName()] = $classAnnotations; $classAnnotations[$declaringClass->getName()] = $classAnnotations;
} }
$annotations = array_filter($this->annotationReader->getMethodAnnotations($method), function ($v) { $annotations = array_filter($this->annotationReader->getMethodAnnotations($method), function ($v) {
return $v instanceof OA\AbstractAnnotation; return $v instanceof OA\AbstractAnnotation;
}); });
$annotations = array_merge($annotations, $this->getAttributesAsAnnotation($method, OA\AbstractAnnotation::class));
if (0 === count($annotations) && 0 === count($classAnnotations[$declaringClass->getName()])) { if (0 === count($annotations) && 0 === count($classAnnotations[$declaringClass->getName()])) {
continue; continue;
@ -190,4 +192,23 @@ final class OpenApiPhpDescriber
return $path; return $path;
} }
/**
* @param \ReflectionClass|\ReflectionMethod $reflection
*
* @return OA\AbstractAnnotation[]
*/
private function getAttributesAsAnnotation($reflection, string $className): array
{
$annotations = [];
if (\PHP_VERSION_ID < 80100) {
return $annotations;
}
foreach ($reflection->getAttributes($className, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$annotations[] = $attribute->newInstance();
}
return $annotations;
}
} }

View File

@ -39,8 +39,8 @@ class OpenApiAnnotationsReader
public function updateSchema(\ReflectionClass $reflectionClass, OA\Schema $schema): void public function updateSchema(\ReflectionClass $reflectionClass, OA\Schema $schema): void
{ {
/** @var OA\Schema $oaSchema */ /** @var OA\Schema|null $oaSchema */
if (!$oaSchema = $this->annotationsReader->getClassAnnotation($reflectionClass, OA\Schema::class)) { if (!$oaSchema = $this->getAnnotation($reflectionClass, OA\Schema::class)) {
return; return;
} }
@ -56,10 +56,8 @@ class OpenApiAnnotationsReader
public function getPropertyName($reflection, string $default): string public function getPropertyName($reflection, string $default): string
{ {
/** @var OA\Property $oaProperty */ /** @var OA\Property|null $oaProperty */
if ($reflection instanceof \ReflectionProperty && !$oaProperty = $this->annotationsReader->getPropertyAnnotation($reflection, OA\Property::class)) { if (!$oaProperty = $this->getAnnotation($reflection, OA\Property::class)) {
return $default;
} elseif ($reflection instanceof \ReflectionMethod && !$oaProperty = $this->annotationsReader->getMethodAnnotation($reflection, OA\Property::class)) {
return $default; return $default;
} }
@ -78,10 +76,8 @@ class OpenApiAnnotationsReader
'filename' => $declaringClass->getFileName(), 'filename' => $declaringClass->getFileName(),
])); ]));
/** @var OA\Property $oaProperty */ /** @var OA\Property|null $oaProperty */
if ($reflection instanceof \ReflectionProperty && !$oaProperty = $this->annotationsReader->getPropertyAnnotation($reflection, OA\Property::class)) { if (!$oaProperty = $this->getAnnotation($reflection, OA\Property::class)) {
return;
} elseif ($reflection instanceof \ReflectionMethod && !$oaProperty = $this->annotationsReader->getMethodAnnotation($reflection, OA\Property::class)) {
return; return;
} }
$this->setContext(null); $this->setContext(null);
@ -95,4 +91,28 @@ class OpenApiAnnotationsReader
$property->mergeProperties($oaProperty); $property->mergeProperties($oaProperty);
} }
/**
* @param \ReflectionClass|\ReflectionProperty|\ReflectionMethod $reflection
*
* @return mixed
*/
private function getAnnotation($reflection, string $className)
{
if (\PHP_VERSION_ID >= 80100) {
if (null !== $attribute = $reflection->getAttributes($className, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null) {
return $attribute->newInstance();
}
}
if ($reflection instanceof \ReflectionClass) {
return $this->annotationsReader->getClassAnnotation($reflection, $className);
} elseif ($reflection instanceof \ReflectionProperty) {
return $this->annotationsReader->getPropertyAnnotation($reflection, $className);
} elseif ($reflection instanceof \ReflectionMethod) {
return $this->annotationsReader->getMethodAnnotation($reflection, $className);
}
return null;
}
} }

View File

@ -72,7 +72,7 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar
$annotationsReader = new AnnotationsReader($this->doctrineReader, $this->modelRegistry, $this->mediaTypes); $annotationsReader = new AnnotationsReader($this->doctrineReader, $this->modelRegistry, $this->mediaTypes);
$annotationsReader->updateDefinition($reflClass, $schema); $annotationsReader->updateDefinition($reflClass, $schema);
$discriminatorMap = $this->doctrineReader->getClassAnnotation($reflClass, DiscriminatorMap::class); $discriminatorMap = $this->getAnnotation($reflClass, DiscriminatorMap::class);
if ($discriminatorMap && Generator::UNDEFINED === $schema->discriminator) { if ($discriminatorMap && Generator::UNDEFINED === $schema->discriminator) {
$this->applyOpenApiDiscriminator( $this->applyOpenApiDiscriminator(
$model, $model,
@ -183,6 +183,20 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar
throw new \Exception(sprintf('Type "%s" is not supported in %s::$%s. You may use the `@OA\Property(type="")` annotation to specify it manually.', $types[0]->getBuiltinType(), $model->getType()->getClassName(), $propertyName)); throw new \Exception(sprintf('Type "%s" is not supported in %s::$%s. You may use the `@OA\Property(type="")` annotation to specify it manually.', $types[0]->getBuiltinType(), $model->getType()->getClassName(), $propertyName));
} }
/**
* @return mixed
*/
private function getAnnotation(\ReflectionClass $reflection, string $className)
{
if (\PHP_VERSION_ID >= 80000) {
if (null !== $attribute = $reflection->getAttributes($className, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null) {
return $attribute->newInstance();
}
}
return $this->doctrineReader->getClassAnnotation($reflection, $className);
}
public function supports(Model $model): bool public function supports(Model $model): bool
{ {
return Type::BUILTIN_TYPE_OBJECT === $model->getType()->getBuiltinType() && class_exists($model->getType()->getClassName()); return Type::BUILTIN_TYPE_OBJECT === $model->getType()->getBuiltinType() && class_exists($model->getType()->getClassName());

View File

@ -44,6 +44,8 @@ final class FosRestDescriber implements RouteDescriberInterface
$annotations = array_filter($annotations, static function ($value) { $annotations = array_filter($annotations, static function ($value) {
return $value instanceof RequestParam || $value instanceof QueryParam; return $value instanceof RequestParam || $value instanceof QueryParam;
}); });
$annotations = array_merge($annotations, $this->getAttributesAsAnnotation($reflectionMethod, RequestParam::class));
$annotations = array_merge($annotations, $this->getAttributesAsAnnotation($reflectionMethod, QueryParam::class));
foreach ($this->getOperations($api, $route) as $operation) { foreach ($this->getOperations($api, $route) as $operation) {
foreach ($annotations as $annotation) { foreach ($annotations as $annotation) {
@ -185,4 +187,21 @@ final class FosRestDescriber implements RouteDescriberInterface
$schema->format = $format; $schema->format = $format;
} }
} }
/**
* @return OA\AbstractAnnotation[]
*/
private function getAttributesAsAnnotation(\ReflectionMethod $reflection, string $className): array
{
$annotations = [];
if (\PHP_VERSION_ID < 80100) {
return $annotations;
}
foreach ($reflection->getAttributes($className, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$annotations[] = $attribute->newInstance();
}
return $annotations;
}
} }

View File

@ -130,13 +130,23 @@ final class FilteredRouteCollectionBuilder
} }
/** @var Areas|null $areas */ /** @var Areas|null $areas */
$areas = $this->annotationReader->getMethodAnnotation( $areas = $this->getAttributesAsAnnotation($reflectionMethod, Areas::class)[0] ?? null;
$reflectionMethod,
Areas::class
);
if (null === $areas) { if (null === $areas) {
$areas = $this->annotationReader->getClassAnnotation($reflectionMethod->getDeclaringClass(), Areas::class); /** @var Areas|null $areas */
$areas = $this->getAttributesAsAnnotation($reflectionMethod->getDeclaringClass(), Areas::class)[0] ?? null;
if (null === $areas) {
/** @var Areas|null $areas */
$areas = $this->annotationReader->getMethodAnnotation(
$reflectionMethod,
Areas::class
);
if (null === $areas) {
$areas = $this->annotationReader->getClassAnnotation($reflectionMethod->getDeclaringClass(), Areas::class);
}
}
} }
return (null !== $areas) ? $areas->has($this->area) : false; return (null !== $areas) ? $areas->has($this->area) : false;
@ -168,4 +178,23 @@ final class FilteredRouteCollectionBuilder
return false; return false;
} }
/**
* @param \ReflectionClass|\ReflectionMethod $reflection
*
* @return Areas[]
*/
private function getAttributesAsAnnotation($reflection, string $className): array
{
$annotations = [];
if (\PHP_VERSION_ID < 80100) {
return $annotations;
}
foreach ($reflection->getAttributes($className, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$annotations[] = $attribute->newInstance();
}
return $annotations;
}
} }

View File

@ -11,244 +11,20 @@
namespace Nelmio\ApiDocBundle\Tests\Functional\Controller; namespace Nelmio\ApiDocBundle\Tests\Functional\Controller;
use Nelmio\ApiDocBundle\Annotation\Areas;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Operation;
use Nelmio\ApiDocBundle\Annotation\Security;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\Article;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\CompoundEntity;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyConstraints;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyDiscriminator;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\User;
use Nelmio\ApiDocBundle\Tests\Functional\Form\DummyType;
use Nelmio\ApiDocBundle\Tests\Functional\Form\UserType;
use OpenApi\Annotations as OA;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
/** if (\PHP_VERSION_ID >= 80100) {
* @Route("/api", name="api_", host="api.example.com")
*/
class ApiController
{
/** /**
* @OA\Get( * @Route("/api", name="api_", host="api.example.com")
* @OA\Response(
* response="200",
* description="Success",
* @Model(type=Article::class, groups={"light"}))
* )
* )
* @OA\Parameter(ref="#/components/parameters/test")
* @Route("/article/{id}", methods={"GET"})
* @OA\Parameter(name="Accept-Version", in="header", @OA\Schema(type="string"))
* @OA\Parameter(name="Application-Name", in="header", @OA\Schema(type="string"))
*/ */
public function fetchArticleAction() class ApiController extends ApiController81
{ {
} }
} else {
/** /**
* The method LINK is not supported by OpenAPI so the method will be ignored. * @Route("/api", name="api_", host="api.example.com")
*
* @Route("/swagger", methods={"GET", "LINK"})
* @Route("/swagger2", methods={"GET"})
* @Operation(
* @OA\Response(response="201", description="An example resource")
* )
* @OA\Get(
* path="/api/swagger2",
* @OA\Parameter(name="Accept-Version", in="header", @OA\Schema(type="string"))
* )
* @OA\Post(
* path="/api/swagger2",
* @OA\Response(response="203", description="but 203 is not actually allowed (wrong method)")
* )
*/ */
public function swaggerAction() class ApiController extends ApiController80
{
}
/**
* @Route("/swagger/implicit", methods={"GET", "POST"})
* @OA\Response(
* response="201",
* description="Operation automatically detected",
* @Model(type=User::class)
* ),
* @OA\RequestBody(
* description="This is a request body",
* @OA\JsonContent(
* type="array",
* @OA\Items(ref=@Model(type=User::class))
* )
* )
* @OA\Tag(name="implicit")
*/
public function implicitSwaggerAction()
{
}
/**
* @Route("/test/users/{user}", methods={"POST"}, schemes={"https"}, requirements={"user"="/foo/"})
* @OA\Response(
* response="201",
* description="Operation automatically detected",
* @Model(type=User::class)
* ),
* @OA\RequestBody(
* description="This is a request body",
* @Model(type=UserType::class, options={"bar": "baz"}))
* )
*/
public function submitUserTypeAction()
{
}
/**
* @Route("/test/{user}", methods={"GET"}, schemes={"https"}, requirements={"user"="/foo/"})
* @OA\Response(response=200, description="sucessful")
*/
public function userAction()
{
}
/**
* This action is deprecated.
*
* Please do not use this action.
*
* @Route("/deprecated", methods={"GET"})
*
* @deprecated
*/
public function deprecatedAction()
{
}
/**
* This action is not documented. It is excluded by the config.
*
* @Route("/admin", methods={"GET"})
*/
public function adminAction()
{
}
/**
* @OA\Get(
* path="/filtered",
* @OA\Response(response="201", description="")
* )
*/
public function filteredAction()
{
}
/**
* @Route("/form", methods={"POST"})
* @OA\RequestBody(
* description="Request content",
* @Model(type=DummyType::class))
* )
* @OA\Response(response="201", description="")
*/
public function formAction()
{
}
/**
* @Route("/security")
* @OA\Response(response="201", description="")
* @Security(name="api_key")
* @Security(name="basic")
* @Security(name="oauth2", scopes={"scope_1"})
*/
public function securityAction()
{
}
/**
* @Route("/swagger/symfonyConstraints", methods={"GET"})
* @OA\Response(
* response="201",
* description="Used for symfony constraints test",
* @Model(type=SymfonyConstraints::class)
* )
*/
public function symfonyConstraintsAction()
{
}
/**
* @OA\Response(
* response="200",
* description="Success",
* ref="#/components/schemas/Test"
* ),
* @OA\Response(
* response="201",
* ref="#/components/responses/201"
* )
* @Route("/configReference", methods={"GET"})
*/
public function configReferenceAction()
{
}
/**
* @Route("/multi-annotations", methods={"GET", "POST"})
* @OA\Get(description="This is the get operation")
* @OA\Post(description="This is post")
*
* @OA\Response(response=200, description="Worked well!", @Model(type=DummyType::class))
*/
public function operationsWithOtherAnnotations()
{
}
/**
* @Route("/areas/new", methods={"GET", "POST"})
*
* @Areas({"area", "area2"})
*/
public function newAreaAction()
{
}
/**
* @Route("/compound", methods={"GET", "POST"})
*
* @OA\Response(response=200, description="Worked well!", @Model(type=CompoundEntity::class))
*/
public function compoundEntityAction()
{
}
/**
* @Route("/discriminator-mapping", methods={"GET", "POST"})
*
* @OA\Response(response=200, description="Worked well!", @Model(type=SymfonyDiscriminator::class))
*/
public function discriminatorMappingAction()
{
}
/**
* @Route("/named_route-operation-id", name="named_route_operation_id", methods={"GET", "POST"})
*
* @OA\Response(response=200, description="success")
*/
public function namedRouteOperationIdAction()
{
}
/**
* @Route("/custom-operation-id", methods={"GET", "POST"})
*
* @Operation(operationId="custom-operation-id")
* @OA\Response(response=200, description="success")
*/
public function customOperationIdAction()
{ {
} }
} }

View File

@ -0,0 +1,251 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Tests\Functional\Controller;
use Nelmio\ApiDocBundle\Annotation\Areas;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Operation;
use Nelmio\ApiDocBundle\Annotation\Security;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\Article;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\CompoundEntity;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyConstraints;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyDiscriminator;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\User;
use Nelmio\ApiDocBundle\Tests\Functional\Form\DummyType;
use Nelmio\ApiDocBundle\Tests\Functional\Form\UserType;
use OpenApi\Annotations as OA;
use Symfony\Component\Routing\Annotation\Route;
class ApiController80
{
/**
* @OA\Get(
* @OA\Response(
* response="200",
* description="Success",
* @Model(type=Article::class, groups={"light"}))
* )
* )
* @OA\Parameter(ref="#/components/parameters/test")
* @Route("/article/{id}", methods={"GET"})
* @OA\Parameter(name="Accept-Version", in="header", @OA\Schema(type="string"))
* @OA\Parameter(name="Application-Name", in="header", @OA\Schema(type="string"))
*/
public function fetchArticleAction()
{
}
/**
* The method LINK is not supported by OpenAPI so the method will be ignored.
*
* @Route("/swagger", methods={"GET", "LINK"})
* @Route("/swagger2", methods={"GET"})
* @Operation(
* @OA\Response(response="201", description="An example resource")
* )
* @OA\Get(
* path="/api/swagger2",
* @OA\Parameter(name="Accept-Version", in="header", @OA\Schema(type="string"))
* )
* @OA\Post(
* path="/api/swagger2",
* @OA\Response(response="203", description="but 203 is not actually allowed (wrong method)")
* )
*/
public function swaggerAction()
{
}
/**
* @Route("/swagger/implicit", methods={"GET", "POST"})
* @OA\Response(
* response="201",
* description="Operation automatically detected",
* @Model(type=User::class)
* ),
* @OA\RequestBody(
* description="This is a request body",
* @OA\JsonContent(
* type="array",
* @OA\Items(ref=@Model(type=User::class))
* )
* )
* @OA\Tag(name="implicit")
*/
public function implicitSwaggerAction()
{
}
/**
* @Route("/test/users/{user}", methods={"POST"}, schemes={"https"}, requirements={"user"="/foo/"})
* @OA\Response(
* response="201",
* description="Operation automatically detected",
* @Model(type=User::class)
* ),
* @OA\RequestBody(
* description="This is a request body",
* @Model(type=UserType::class, options={"bar": "baz"}))
* )
*/
public function submitUserTypeAction()
{
}
/**
* @Route("/test/{user}", methods={"GET"}, schemes={"https"}, requirements={"user"="/foo/"})
* @OA\Response(response=200, description="sucessful")
*/
public function userAction()
{
}
/**
* This action is deprecated.
*
* Please do not use this action.
*
* @Route("/deprecated", methods={"GET"})
*
* @deprecated
*/
public function deprecatedAction()
{
}
/**
* This action is not documented. It is excluded by the config.
*
* @Route("/admin", methods={"GET"})
*/
public function adminAction()
{
}
/**
* @OA\Get(
* path="/filtered",
* @OA\Response(response="201", description="")
* )
*/
public function filteredAction()
{
}
/**
* @Route("/form", methods={"POST"})
* @OA\RequestBody(
* description="Request content",
* @Model(type=DummyType::class))
* )
* @OA\Response(response="201", description="")
*/
public function formAction()
{
}
/**
* @Route("/security")
* @OA\Response(response="201", description="")
* @Security(name="api_key")
* @Security(name="basic")
* @Security(name="oauth2", scopes={"scope_1"})
*/
public function securityAction()
{
}
/**
* @Route("/swagger/symfonyConstraints", methods={"GET"})
* @OA\Response(
* response="201",
* description="Used for symfony constraints test",
* @Model(type=SymfonyConstraints::class)
* )
*/
public function symfonyConstraintsAction()
{
}
/**
* @OA\Response(
* response="200",
* description="Success",
* ref="#/components/schemas/Test"
* ),
* @OA\Response(
* response="201",
* ref="#/components/responses/201"
* )
* @Route("/configReference", methods={"GET"})
*/
public function configReferenceAction()
{
}
/**
* @Route("/multi-annotations", methods={"GET", "POST"})
* @OA\Get(description="This is the get operation")
* @OA\Post(description="This is post")
*
* @OA\Response(response=200, description="Worked well!", @Model(type=DummyType::class))
*/
public function operationsWithOtherAnnotations()
{
}
/**
* @Route("/areas/new", methods={"GET", "POST"})
*
* @Areas({"area", "area2"})
*/
public function newAreaAction()
{
}
/**
* @Route("/compound", methods={"GET", "POST"})
*
* @OA\Response(response=200, description="Worked well!", @Model(type=CompoundEntity::class))
*/
public function compoundEntityAction()
{
}
/**
* @Route("/discriminator-mapping", methods={"GET", "POST"})
*
* @OA\Response(response=200, description="Worked well!", @Model(type=SymfonyDiscriminator::class))
*/
public function discriminatorMappingAction()
{
}
/**
* @Route("/named_route-operation-id", name="named_route_operation_id", methods={"GET", "POST"})
*
* @OA\Response(response=200, description="success")
*/
public function namedRouteOperationIdAction()
{
}
/**
* @Route("/custom-operation-id", methods={"GET", "POST"})
*
* @Operation(operationId="custom-operation-id")
* @OA\Response(response=200, description="success")
*/
public function customOperationIdAction()
{
}
}

View File

@ -0,0 +1,36 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Tests\Functional\Controller;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\Article;
use OpenApi\Annotations as OA;
use Symfony\Component\Routing\Annotation\Route;
class ApiController81 extends ApiController80
{
#[OA\Get([
'value' => new OA\Response(
response: '200',
description: 'Success',
properties: [
'value' => new Model(type: Article::class, groups: ['light']),
],
),
])]
#[OA\Parameter(ref: '#/components/parameters/test')]
#[Route('/article_attributes/{id}', methods: ['GET'])]
#[OA\Parameter(name: 'Accept-Version', in: 'header', properties: ['value' => new OA\Schema(type: 'string')])]
public function fetchArticleActionWithAttributes()
{
}
}

View File

@ -11,30 +11,20 @@
namespace Nelmio\ApiDocBundle\Tests\Functional\Controller; namespace Nelmio\ApiDocBundle\Tests\Functional\Controller;
use FOS\RestBundle\Controller\Annotations\QueryParam;
use FOS\RestBundle\Controller\Annotations\RequestParam;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Constraints\DateTime;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\Regex;
/** if (\PHP_VERSION_ID >= 80100) {
* @Route("/api", host="api.example.com")
*/
class FOSRestController
{
/** /**
* @Route("/fosrest.{_format}", methods={"POST"}) * @Route("/api", host="api.example.com")
* @QueryParam(name="foo", requirements=@Regex("/^\d+$/"))
* @QueryParam(name="mapped", map=true)
* @RequestParam(name="Barraa", key="bar", requirements="\d+")
* @RequestParam(name="baz", requirements=@IsTrue)
* @RequestParam(name="datetime", requirements=@DateTime("Y-m-d\TH:i:sP"))
* @RequestParam(name="datetimeAlt", requirements=@DateTime("c"))
* @RequestParam(name="datetimeNoFormat", requirements=@DateTime())
* @RequestParam(name="date", requirements=@DateTime("Y-m-d"))
*/ */
public function fosrestAction() class FOSRestController extends FOSRestController81
{
}
} else {
/**
* @Route("/api", host="api.example.com")
*/
class FOSRestController extends FOSRestController80
{ {
} }
} }

View File

@ -0,0 +1,37 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Tests\Functional\Controller;
use FOS\RestBundle\Controller\Annotations\QueryParam;
use FOS\RestBundle\Controller\Annotations\RequestParam;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Constraints\DateTime;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\Regex;
class FOSRestController80
{
/**
* @Route("/fosrest.{_format}", methods={"POST"})
* @QueryParam(name="foo", requirements=@Regex("/^\d+$/"))
* @QueryParam(name="mapped", map=true)
* @RequestParam(name="Barraa", key="bar", requirements="\d+")
* @RequestParam(name="baz", requirements=@IsTrue)
* @RequestParam(name="datetime", requirements=@DateTime("Y-m-d\TH:i:sP"))
* @RequestParam(name="datetimeAlt", requirements=@DateTime("c"))
* @RequestParam(name="datetimeNoFormat", requirements=@DateTime())
* @RequestParam(name="date", requirements=@DateTime("Y-m-d"))
*/
public function fosrestAction()
{
}
}

View File

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Tests\Functional\Controller;
use FOS\RestBundle\Controller\Annotations\QueryParam;
use FOS\RestBundle\Controller\Annotations\RequestParam;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Constraints\DateTime;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\Regex;
class FOSRestController81 extends FOSRestController80
{
#[Route('/fosrest_attributes.{_format}', methods: ['POST'])]
#[QueryParam(name: 'foo', requirements: new Regex('/^\d+$/'))]
#[QueryParam(name: 'mapped', map: true)]
#[RequestParam(name: 'Barraa', key: 'bar', requirements: '\d+')]
#[RequestParam(name: 'baz', requirements: new IsTrue())]
#[RequestParam(name: 'datetime', requirements: new DateTime('Y-m-d\TH:i:sP'))]
#[RequestParam(name: 'datetimeAlt', requirements: new DateTime('c'))]
#[RequestParam(name: 'datetimeNoFormat', requirements: new DateTime())]
#[RequestParam(name: 'date', requirements: new DateTime('Y-m-d'))]
public function fosrestAttributesAction()
{
}
}

View File

@ -23,9 +23,12 @@ class FOSRestTest extends WebTestCase
static::createClient([], ['HTTP_HOST' => 'api.example.com']); static::createClient([], ['HTTP_HOST' => 'api.example.com']);
} }
public function testFOSRestAction() /**
* @dataProvider provideRoute
*/
public function testFOSRestAction(string $route)
{ {
$operation = $this->getOperation('/api/fosrest', 'post'); $operation = $this->getOperation($route, 'post');
$this->assertHasParameter('foo', 'query', $operation); $this->assertHasParameter('foo', 'query', $operation);
$this->assertInstanceOf(OA\RequestBody::class, $operation->requestBody); $this->assertInstanceOf(OA\RequestBody::class, $operation->requestBody);
@ -66,4 +69,13 @@ class FOSRestTest extends WebTestCase
// The _format path attribute should be removed // The _format path attribute should be removed
$this->assertNotHasParameter('_format', 'path', $operation); $this->assertNotHasParameter('_format', 'path', $operation);
} }
public function provideRoute(): iterable
{
yield 'Annotations' => ['/api/fosrest'];
if (\PHP_VERSION_ID >= 80100) {
yield 'Attributes' => ['/api/fosrest_attributes'];
}
}
} }

View File

@ -40,9 +40,12 @@ class FunctionalTest extends WebTestCase
$this->assertNotHasPath('/api/admin', $api); $this->assertNotHasPath('/api/admin', $api);
} }
public function testFetchArticleAction() /**
* @dataProvider provideArticleRoute
*/
public function testFetchArticleAction(string $articleRoute)
{ {
$operation = $this->getOperation('/api/article/{id}', 'get'); $operation = $this->getOperation($articleRoute, 'get');
$this->assertHasResponse('200', $operation); $this->assertHasResponse('200', $operation);
$response = $this->getOperationResponse($operation, '200'); $response = $this->getOperationResponse($operation, '200');
@ -56,6 +59,15 @@ class FunctionalTest extends WebTestCase
$this->assertNotHasProperty('author', Util::getProperty($articleModel, 'author')); $this->assertNotHasProperty('author', Util::getProperty($articleModel, 'author'));
} }
public function provideArticleRoute(): iterable
{
yield 'Annotations' => ['/api/article/{id}'];
if (\PHP_VERSION_ID >= 80100) {
yield 'Attributes' => ['/api/article_attributes/{id}'];
}
}
public function testFilteredAction() public function testFilteredAction()
{ {
$openApi = $this->getOpenApiDefinition(); $openApi = $this->getOpenApiDefinition();

View File

@ -0,0 +1,67 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Tests\ModelDescriber\Annotations;
use Doctrine\Common\Annotations\AnnotationReader;
use Nelmio\ApiDocBundle\Model\ModelRegistry;
use Nelmio\ApiDocBundle\ModelDescriber\Annotations\OpenApiAnnotationsReader;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use PHPUnit\Framework\TestCase;
class AnnotationReaderTest extends TestCase
{
/**
* @param object $entity
* @dataProvider provideProperty
*/
public function testProperty($entity)
{
$schema = new OA\Schema([]);
$schema->merge([new OA\Property(['property' => 'property1'])]);
$schema->merge([new OA\Property(['property' => 'property2'])]);
$registry = new ModelRegistry([], new OA\OpenApi([]), []);
$symfonyConstraintAnnotationReader = new OpenApiAnnotationsReader(new AnnotationReader(), $registry, ['json']);
$symfonyConstraintAnnotationReader->updateProperty(new \ReflectionProperty($entity, 'property1'), $schema->properties[0]);
$symfonyConstraintAnnotationReader->updateProperty(new \ReflectionProperty($entity, 'property2'), $schema->properties[1]);
$this->assertEquals($schema->properties[0]->example, 1);
$this->assertEquals($schema->properties[0]->description, Generator::UNDEFINED);
$this->assertEquals($schema->properties[1]->example, 'some example');
$this->assertEquals($schema->properties[1]->description, 'some description');
}
public function provideProperty(): iterable
{
yield 'Annotations' => [new class() {
/**
* @OA\Property(example=1)
*/
private $property1;
/**
* @OA\Property(example="some example", description="some description")
*/
private $property2;
}];
if (\PHP_VERSION_ID >= 80100) {
yield 'Attributes' => [new class() {
#[OA\Property(example: 1)]
private $property1;
#[OA\Property(example: 'some example', description: 'some description')]
private $property2;
}];
}
}
}