From 78664ef9ec16688ed27937f412b441ea32bb3588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Ben=C4=8Do?= Date: Thu, 28 May 2020 13:19:11 +0200 Subject: [PATCH] OpenApi 3 Support (#1623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial pass for OA3 upgrade * Fix Util Tests * Fix first batch of Unit Tests. Up to Model * Another batch of fixed tests * Update annotations * Convert Model & Property Describers * Update tests, Fix RouteDescribers, FIx additional bugs * Another batch of updates * Another batch of fixed Functional Tests * Fix FunctionalTest tests * Fix Bazinga Tests * FIx FOS Rest * Fix JMS TEsts & describers * Fix all Tests * Fix few stuff from own CR * CS Fixes * CS Fixes 2 * CS Fixes 3 * CS Fixes 4 * Remove collection bug * Updates after first CRs * CS * Drop support for SF3 * Update the docs * Add an upgrade guide * misc doc fixes * Configurable media types * Code Style Fixes * Don't use ::$ref for @Response and @RequestBody * Fix upgrading guide * Fix OA case Co-authored-by: Filip BenĨo Co-authored-by: Guilhem Niot Co-authored-by: Mantis Development --- .travis.yml | 2 - Annotation/Model.php | 6 +- Annotation/Operation.php | 2 +- Annotation/Security.php | 2 +- ApiDocGenerator.php | 34 +- CHANGELOG.md | 10 +- Controller/DocumentationController.php | 11 +- Controller/SwaggerUiController.php | 13 +- DependencyInjection/Configuration.php | 5 + DependencyInjection/NelmioApiDocExtension.php | 13 +- Describer/ApiPlatformDescriber.php | 8 +- Describer/DefaultDescriber.php | 34 +- Describer/DescriberInterface.php | 4 +- Describer/ExternalDocDescriber.php | 10 +- Describer/OpenApiPhpDescriber.php | 212 +++++ Describer/RouteDescriber.php | 5 +- Describer/SwaggerPhpDescriber.php | 276 ------ Model/ModelRegistry.php | 29 +- .../Annotations/AnnotationsReader.php | 18 +- .../Annotations/OpenApiAnnotationsReader.php | 87 ++ .../Annotations/PropertyPhpDocReader.php | 13 +- .../Annotations/SwgAnnotationsReader.php | 89 -- .../SymfonyConstraintAnnotationReader.php | 46 +- .../BazingaHateoasModelDescriber.php | 49 +- .../FallbackObjectModelDescriber.php | 4 +- ModelDescriber/FormModelDescriber.php | 83 +- ModelDescriber/JMSModelDescriber.php | 66 +- ModelDescriber/ModelDescriberInterface.php | 2 +- ModelDescriber/ObjectModelDescriber.php | 30 +- OpenApiPhp/AddDefaults.php | 39 + OpenApiPhp/ModelRegister.php | 171 ++++ OpenApiPhp/Util.php | 519 +++++++++++ PropertyDescriber/ArrayPropertyDescriber.php | 9 +- .../BooleanPropertyDescriber.php | 6 +- .../DateTimePropertyDescriber.php | 8 +- PropertyDescriber/FloatPropertyDescriber.php | 8 +- .../IntegerPropertyDescriber.php | 6 +- PropertyDescriber/ObjectPropertyDescriber.php | 8 +- .../PropertyDescriberInterface.php | 2 +- PropertyDescriber/StringPropertyDescriber.php | 6 +- README.md | 6 + Resources/config/fos_rest.xml | 1 + Resources/config/services.xml | 1 + Resources/doc/alternative_names.rst | 2 +- Resources/doc/faq.rst | 56 +- Resources/doc/index.rst | 81 +- RouteDescriber/FosRestDescriber.php | 130 ++- RouteDescriber/PhpDocDescriber.php | 16 +- RouteDescriber/RouteDescriberInterface.php | 4 +- RouteDescriber/RouteDescriberTrait.php | 17 +- RouteDescriber/RouteMetadataDescriber.php | 46 +- SwaggerPhp/AddDefaults.php | 37 - SwaggerPhp/ModelRegister.php | 130 --- Tests/ApiDocGeneratorTest.php | 2 +- Tests/Describer/AbstractDescriberTest.php | 6 +- Tests/Describer/ApiPlatformDescriberTest.php | 10 +- Tests/Describer/RouteDescriberTest.php | 4 +- Tests/Functional/BazingaFunctionalTest.php | 11 +- Tests/Functional/Controller/ApiController.php | 104 +-- .../Controller/BazingaController.php | 6 +- .../Controller/BazingaTypedController.php | 4 +- .../Controller/ClassApiController.php | 4 +- Tests/Functional/Controller/JMSController.php | 20 +- .../Functional/Controller/TestController.php | 8 +- Tests/Functional/Entity/JMSComplex.php | 8 +- Tests/Functional/Entity/JMSDualComplex.php | 6 +- Tests/Functional/Entity/JMSUser.php | 16 +- Tests/Functional/Entity/User.php | 16 +- Tests/Functional/FOSRestTest.php | 37 +- Tests/Functional/FunctionalTest.php | 140 +-- Tests/Functional/JMSFunctionalTest.php | 68 +- ...ssDoesNotExistsHandlerDefinedDescriber.php | 10 +- Tests/Functional/SwaggerUiTest.php | 34 +- Tests/Functional/TestKernel.php | 28 +- Tests/Functional/WebTestCase.php | 137 ++- Tests/Model/ModelRegistryTest.php | 22 +- .../SymfonyConstraintAnnotationReaderTest.php | 22 +- Tests/Swagger/ModelRegisterTest.php | 67 -- Tests/SwaggerPhp/UtilTest.php | 842 ++++++++++++++++++ UPGRADE-4.0.md | 28 + composer.json | 42 +- 81 files changed, 2845 insertions(+), 1329 deletions(-) create mode 100644 Describer/OpenApiPhpDescriber.php delete mode 100644 Describer/SwaggerPhpDescriber.php create mode 100644 ModelDescriber/Annotations/OpenApiAnnotationsReader.php delete mode 100644 ModelDescriber/Annotations/SwgAnnotationsReader.php create mode 100644 OpenApiPhp/AddDefaults.php create mode 100644 OpenApiPhp/ModelRegister.php create mode 100644 OpenApiPhp/Util.php delete mode 100644 SwaggerPhp/AddDefaults.php delete mode 100644 SwaggerPhp/ModelRegister.php delete mode 100644 Tests/Swagger/ModelRegisterTest.php create mode 100644 Tests/SwaggerPhp/UtilTest.php create mode 100644 UPGRADE-4.0.md diff --git a/.travis.yml b/.travis.yml index 3672859..754c0c6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,8 +18,6 @@ matrix: include: - php: 7.1 env: COMPOSER_FLAGS="--prefer-lowest" - - php: 7.2 - env: SYMFONY_VERSION=^3.4 - php: 7.3 env: SYMFONY_VERSION=^4.0 - php: 7.3 diff --git a/Annotation/Model.php b/Annotation/Model.php index 3130928..5a25829 100644 --- a/Annotation/Model.php +++ b/Annotation/Model.php @@ -11,7 +11,8 @@ namespace Nelmio\ApiDocBundle\Annotation; -use Swagger\Annotations\AbstractAnnotation; +use OpenApi\Annotations\AbstractAnnotation; +use OpenApi\Annotations\Parameter; /** * @Annotation @@ -28,8 +29,7 @@ final class Model extends AbstractAnnotation public static $_required = ['type']; public static $_parents = [ - 'Swagger\Annotations\Parameter', - 'Swagger\Annotations\Response', + Parameter::class, ]; /** diff --git a/Annotation/Operation.php b/Annotation/Operation.php index 10f581a..3a8e9f7 100644 --- a/Annotation/Operation.php +++ b/Annotation/Operation.php @@ -11,7 +11,7 @@ namespace Nelmio\ApiDocBundle\Annotation; -use Swagger\Annotations\Operation as BaseOperation; +use OpenApi\Annotations\Operation as BaseOperation; /** * @Annotation diff --git a/Annotation/Security.php b/Annotation/Security.php index 1b51085..fad83bc 100644 --- a/Annotation/Security.php +++ b/Annotation/Security.php @@ -11,7 +11,7 @@ namespace Nelmio\ApiDocBundle\Annotation; -use Swagger\Annotations\AbstractAnnotation; +use OpenApi\Annotations\AbstractAnnotation; /** * @Annotation diff --git a/ApiDocGenerator.php b/ApiDocGenerator.php index 4000be1..480cdf9 100644 --- a/ApiDocGenerator.php +++ b/ApiDocGenerator.php @@ -11,23 +11,31 @@ namespace Nelmio\ApiDocBundle; -use EXSyst\Component\Swagger\Swagger; use Nelmio\ApiDocBundle\Describer\DescriberInterface; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\Model\ModelRegistry; use Nelmio\ApiDocBundle\ModelDescriber\ModelDescriberInterface; +use OpenApi\Annotations\OpenApi; use Psr\Cache\CacheItemPoolInterface; final class ApiDocGenerator { - private $swagger; + /** @var OpenApi */ + private $openApi; + /** @var iterable|DescriberInterface[] */ private $describers; + /** @var iterable|ModelDescriberInterface[] */ private $modelDescribers; + /** @var CacheItemPoolInterface|null */ private $cacheItemPool; + /** @var string|null */ + private $cacheItemId; + + /** @var string[] */ private $alternativeNames = []; /** @@ -47,34 +55,34 @@ final class ApiDocGenerator $this->alternativeNames = $alternativeNames; } - public function generate(): Swagger + public function generate(): OpenApi { - if (null !== $this->swagger) { - return $this->swagger; + if (null !== $this->openApi) { + return $this->openApi; } if ($this->cacheItemPool) { - $item = $this->cacheItemPool->getItem($this->cacheItemId ?? 'swagger_doc'); + $item = $this->cacheItemPool->getItem($this->cacheItemId ?? 'openapi_doc'); if ($item->isHit()) { - return $this->swagger = $item->get(); + return $this->openApi = $item->get(); } } - $this->swagger = new Swagger(); - $modelRegistry = new ModelRegistry($this->modelDescribers, $this->swagger, $this->alternativeNames); + $this->openApi = new OpenApi([]); + $modelRegistry = new ModelRegistry($this->modelDescribers, $this->openApi, $this->alternativeNames); foreach ($this->describers as $describer) { if ($describer instanceof ModelRegistryAwareInterface) { $describer->setModelRegistry($modelRegistry); } - $describer->describe($this->swagger); + $describer->describe($this->openApi); } - $modelRegistry->registerDefinitions(); + $modelRegistry->registerSchemas(); if (isset($item)) { - $this->cacheItemPool->save($item->set($this->swagger)); + $this->cacheItemPool->save($item->set($this->openApi)); } - return $this->swagger; + return $this->openApi; } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 41334a3..0f25072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,14 @@ CHANGELOG ========= -3.3.0 (unreleased) ------------------- +4.0.0 +----- +* Added support of OpenAPI 3.0. The internals were completely reworked and this version introduces BC breaks. -* Usage of Google Fonts was removed. System fonts `serif` / `sans` will be used instead. +3.3.0 +----- + +* Usage of Google Fonts was removed. System fonts `serif` / `sans` will be used instead. This can lead to a different look on different operating systems. You can [re-add Google Fonts again manually by overriding the template](https://symfony.com/doc/current/bundles/NelmioApiDocBundle/faq.html#re-add-google-fonts). diff --git a/Controller/DocumentationController.php b/Controller/DocumentationController.php index 2a9ed3c..dd28427 100644 --- a/Controller/DocumentationController.php +++ b/Controller/DocumentationController.php @@ -12,6 +12,8 @@ namespace Nelmio\ApiDocBundle\Controller; use Nelmio\ApiDocBundle\ApiDocGenerator; +use OpenApi\Annotations\OpenApi; +use OpenApi\Annotations\Server; use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\HttpFoundation\JsonResponse; @@ -47,14 +49,11 @@ final class DocumentationController throw new BadRequestHttpException(sprintf('Area "%s" is not supported as it isn\'t defined in config.', $area)); } - $spec = $this->generatorLocator->get($area)->generate()->toArray(); + /** @var OpenApi $spec */ + $spec = $this->generatorLocator->get($area)->generate(); if ('' !== $request->getBaseUrl()) { - $spec['basePath'] = $request->getBaseUrl(); - } - - if (empty($spec['host'])) { - $spec['host'] = $request->getHost(); + $spec->servers = [new Server(['url' => $request->getSchemeAndHttpHost().$request->getBaseUrl()])]; } return new JsonResponse($spec); diff --git a/Controller/SwaggerUiController.php b/Controller/SwaggerUiController.php index 8c88e3b..ebff95b 100644 --- a/Controller/SwaggerUiController.php +++ b/Controller/SwaggerUiController.php @@ -12,6 +12,8 @@ namespace Nelmio\ApiDocBundle\Controller; use Nelmio\ApiDocBundle\ApiDocGenerator; +use OpenApi\Annotations\OpenApi; +use OpenApi\Annotations\Server; use Psr\Container\ContainerInterface; use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\HttpFoundation\Request; @@ -60,13 +62,18 @@ final class SwaggerUiController throw new BadRequestHttpException(sprintf('Area "%s" is not supported as it isn\'t defined in config.%s', $area, $advice)); } - $spec = $this->generatorLocator->get($area)->generate()->toArray(); + /** @var OpenApi $spec */ + $spec = $this->generatorLocator->get($area)->generate(); + if ('' !== $request->getBaseUrl()) { - $spec['basePath'] = $request->getBaseUrl(); + $spec->servers = [new Server(['url' => $request->getSchemeAndHttpHost().$request->getBaseUrl()])]; } return new Response( - $this->twig->render('@NelmioApiDoc/SwaggerUi/index.html.twig', ['swagger_data' => ['spec' => $spec]]), + $this->twig->render( + '@NelmioApiDoc/SwaggerUi/index.html.twig', + ['swagger_data' => ['spec' => json_decode($spec->toJson(), true)]] + ), Response::HTTP_OK, ['Content-Type' => 'text/html'] ); diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index affdbb6..1b97a9f 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -53,6 +53,11 @@ final class Configuration implements ConfigurationInterface ->example(['info' => ['title' => 'My App']]) ->prototype('variable')->end() ->end() + ->arrayNode('media_types') + ->info('List of enabled Media Types') + ->defaultValue(['json']) + ->prototype('scalar')->end() + ->end() ->arrayNode('areas') ->info('Filter the routes that are documented') ->defaultValue( diff --git a/DependencyInjection/NelmioApiDocExtension.php b/DependencyInjection/NelmioApiDocExtension.php index 8633e96..8db40fa 100644 --- a/DependencyInjection/NelmioApiDocExtension.php +++ b/DependencyInjection/NelmioApiDocExtension.php @@ -15,8 +15,8 @@ use FOS\RestBundle\Controller\Annotations\ParamInterface; use JMS\Serializer\Visitor\SerializationVisitorInterface; use Nelmio\ApiDocBundle\ApiDocGenerator; use Nelmio\ApiDocBundle\Describer\ExternalDocDescriber; +use Nelmio\ApiDocBundle\Describer\OpenApiPhpDescriber; use Nelmio\ApiDocBundle\Describer\RouteDescriber; -use Nelmio\ApiDocBundle\Describer\SwaggerPhpDescriber; use Nelmio\ApiDocBundle\ModelDescriber\BazingaHateoasModelDescriber; use Nelmio\ApiDocBundle\ModelDescriber\JMSModelDescriber; use Nelmio\ApiDocBundle\Routing\FilteredRouteCollectionBuilder; @@ -82,13 +82,14 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI ]) ->addTag(sprintf('nelmio_api_doc.describer.%s', $area), ['priority' => -400]); - $container->register(sprintf('nelmio_api_doc.describers.swagger_php.%s', $area), SwaggerPhpDescriber::class) + $container->register(sprintf('nelmio_api_doc.describers.openapi_php.%s', $area), OpenApiPhpDescriber::class) ->setPublic(false) ->setArguments([ new Reference(sprintf('nelmio_api_doc.routes.%s', $area)), new Reference('nelmio_api_doc.controller_reflector'), new Reference('annotation_reader'), new Reference('logger'), + $config['media_types'], ]) ->addTag(sprintf('nelmio_api_doc.describer.%s', $area), ['priority' => -200]); @@ -135,11 +136,16 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI array_map(function ($area) { return new Reference(sprintf('nelmio_api_doc.generator.%s', $area)); }, array_keys($config['areas'])) )); + $container->getDefinition('nelmio_api_doc.model_describers.object') + ->setArgument(3, $config['media_types']); + // Import services needed for each library $loader->load('php_doc.xml'); if (interface_exists(ParamInterface::class)) { $loader->load('fos_rest.xml'); + $container->getDefinition('nelmio_api_doc.route_describers.fos_rest') + ->setArgument(1, $config['media_types']); } // ApiPlatform support @@ -159,8 +165,9 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI ->setPublic(false) ->setArguments([ new Reference('jms_serializer.metadata_factory'), - $jmsNamingStrategy, new Reference('annotation_reader'), + $config['media_types'], + $jmsNamingStrategy, ]) ->addTag('nelmio_api_doc.model_describer', ['priority' => 50]); diff --git a/Describer/ApiPlatformDescriber.php b/Describer/ApiPlatformDescriber.php index 70d2cc3..2b837ad 100644 --- a/Describer/ApiPlatformDescriber.php +++ b/Describer/ApiPlatformDescriber.php @@ -12,6 +12,7 @@ namespace Nelmio\ApiDocBundle\Describer; use ApiPlatform\Core\Documentation\Documentation; +use ApiPlatform\Core\Swagger\Serializer\DocumentationNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; final class ApiPlatformDescriber extends ExternalDocDescriber @@ -23,7 +24,12 @@ final class ApiPlatformDescriber extends ExternalDocDescriber } parent::__construct(function () use ($documentation, $normalizer) { - $documentation = (array) $normalizer->normalize($documentation); + $documentation = (array) $normalizer->normalize( + $documentation, + null, + [DocumentationNormalizer::SPEC_VERSION => 3] + ); + unset($documentation['basePath']); return $documentation; diff --git a/Describer/DefaultDescriber.php b/Describer/DefaultDescriber.php index 0b7c8ff..50d6a54 100644 --- a/Describer/DefaultDescriber.php +++ b/Describer/DefaultDescriber.php @@ -11,7 +11,8 @@ namespace Nelmio\ApiDocBundle\Describer; -use EXSyst\Component\Swagger\Swagger; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; +use OpenApi\Annotations as OA; /** * Makes the swagger documentation valid even if there are missing fields. @@ -20,27 +21,28 @@ use EXSyst\Component\Swagger\Swagger; */ final class DefaultDescriber implements DescriberInterface { - public function describe(Swagger $api) + public function describe(OA\OpenApi $api) { // Info - $info = $api->getInfo(); - if (null === $info->getTitle()) { - $info->setTitle(''); + /** @var OA\Info $info */ + $info = Util::getChild($api, OA\Info::class); + if (OA\UNDEFINED === $info->title) { + $info->title = ''; } - if (null === $info->getVersion()) { - $info->setVersion('0.0.0'); + if (OA\UNDEFINED === $info->version) { + $info->version = '0.0.0'; } // Paths - $paths = $api->getPaths(); - foreach ($paths as $uri => $path) { - foreach ($path->getMethods() as $method) { - $operation = $path->getOperation($method); - - // Default Response - if (0 === iterator_count($operation->getResponses())) { - $defaultResponse = $operation->getResponses()->get('default'); - $defaultResponse->setDescription(''); + $paths = OA\UNDEFINED === $api->paths ? [] : $api->paths; + foreach ($paths as $path) { + foreach (Util::OPERATIONS as $method) { + /** @var OA\Operation $operation */ + $operation = $path->{$method}; + if (OA\UNDEFINED !== $operation && null !== $operation && empty($operation->responses ?? [])) { + /** @var OA\Response $response */ + $response = Util::getIndexedCollectionItem($operation, OA\Response::class, 'default'); + $response->description = ''; } } } diff --git a/Describer/DescriberInterface.php b/Describer/DescriberInterface.php index f4301d0..ab92a5c 100644 --- a/Describer/DescriberInterface.php +++ b/Describer/DescriberInterface.php @@ -11,9 +11,9 @@ namespace Nelmio\ApiDocBundle\Describer; -use EXSyst\Component\Swagger\Swagger; +use OpenApi\Annotations\OpenApi; interface DescriberInterface { - public function describe(Swagger $api); + public function describe(OpenApi $api); } diff --git a/Describer/ExternalDocDescriber.php b/Describer/ExternalDocDescriber.php index d322c44..d5fc337 100644 --- a/Describer/ExternalDocDescriber.php +++ b/Describer/ExternalDocDescriber.php @@ -11,7 +11,8 @@ namespace Nelmio\ApiDocBundle\Describer; -use EXSyst\Component\Swagger\Swagger; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; +use OpenApi\Annotations as OA; class ExternalDocDescriber implements DescriberInterface { @@ -29,10 +30,13 @@ class ExternalDocDescriber implements DescriberInterface $this->overwrite = $overwrite; } - public function describe(Swagger $api) + public function describe(OA\OpenApi $api) { $externalDoc = $this->getExternalDoc(); - $api->merge($externalDoc, $this->overwrite); + + if (!empty($externalDoc)) { + Util::merge($api, $externalDoc, $this->overwrite); + } } private function getExternalDoc() diff --git a/Describer/OpenApiPhpDescriber.php b/Describer/OpenApiPhpDescriber.php new file mode 100644 index 0000000..f9d27b5 --- /dev/null +++ b/Describer/OpenApiPhpDescriber.php @@ -0,0 +1,212 @@ +routeCollection = $routeCollection; + $this->controllerReflector = $controllerReflector; + $this->annotationReader = $annotationReader; + $this->logger = $logger; + $this->mediaTypes = $mediaTypes; + $this->overwrite = $overwrite; + } + + public function describe(OA\OpenApi $api) + { + $analysis = $this->getAnnotations($api); + $analysis->process($this->getProcessors()); + $analysis->validate(); + } + + private function getProcessors(): array + { + $processors = [ + new AddDefaults(), + new ModelRegister($this->modelRegistry, $this->mediaTypes), + ]; + + return array_merge($processors, Analysis::processors()); + } + + private function getAnnotations(OA\OpenApi $api): Analysis + { + $analysis = new Analysis(); + $analysis->openapi = $api; + + $classAnnotations = []; + + /** @var \ReflectionMethod $method */ + foreach ($this->getMethodsToParse() as $method => list($path, $httpMethods)) { + $declaringClass = $method->getDeclaringClass(); + if (!array_key_exists($declaringClass->getName(), $classAnnotations)) { + $classAnnotations = array_filter($this->annotationReader->getClassAnnotations($declaringClass), function ($v) { + return $v instanceof OA\AbstractAnnotation; + }); + $classAnnotations[$declaringClass->getName()] = $classAnnotations; + } + + $annotations = array_filter($this->annotationReader->getMethodAnnotations($method), function ($v) { + return $v instanceof OA\AbstractAnnotation; + }); + + if (0 === count($annotations)) { + continue; + } + + $path = Util::getPath($api, $path); + $path->_context->namespace = $method->getNamespaceName(); + $path->_context->class = $declaringClass->getShortName(); + $path->_context->method = $method->name; + $path->_context->filename = $method->getFileName(); + + $nestedContext = Util::createContext(['nested' => $path], $path->_context); + $implicitAnnotations = []; + $mergeProperties = new \stdClass(); + + foreach (array_merge($annotations, $classAnnotations[$declaringClass->getName()]) as $annotation) { + $annotation->_context = $nestedContext; + + if ($annotation instanceof Operation) { + foreach ($httpMethods as $httpMethod) { + $operation = Util::getOperation($path, $httpMethod); + $operation->mergeProperties($annotation); + } + + continue; + } + + if ($annotation instanceof OA\Operation) { + $operation = Util::getOperation($path, $annotation->method); + $operation->mergeProperties($annotation); + + continue; + } + + if ($annotation instanceof Security) { + $annotation->validate(); + $mergeProperties->security[] = [$annotation->name => []]; + + continue; + } + + if ($annotation instanceof OA\Tag) { + $annotation->validate(); + $mergeProperties->tags[] = $annotation->name; + + continue; + } + + if ( + !$annotation instanceof OA\Response && + !$annotation instanceof OA\RequestBody && + !$annotation instanceof OA\Parameter && + !$annotation instanceof OA\ExternalDocumentation + ) { + throw new \LogicException(sprintf('Using the annotation "%s" as a root annotation in "%s::%s()" is not allowed.', get_class($annotation), $method->getDeclaringClass()->name, $method->name)); + } + + foreach ($annotation->_unmerged as $unmergedAnnotation) { + if (!$unmergedAnnotation instanceof OA\JsonContent && !$unmergedAnnotation instanceof OA\XmlContent) { + continue; + } + $unmergedAnnotation->_context->nested = $annotation; + } + + $implicitAnnotations[] = $annotation; + } + + if (empty($implicitAnnotations) && empty(get_object_vars($mergeProperties))) { + continue; + } + + // Registers new annotations + $analysis->addAnnotations($implicitAnnotations, null); + + foreach ($httpMethods as $httpMethod) { + $operation = Util::getOperation($path, $httpMethod); + $operation->merge($implicitAnnotations); + $operation->mergeProperties($mergeProperties); + } + } + + return $analysis; + } + + private function getMethodsToParse(): \Generator + { + foreach ($this->routeCollection->all() as $route) { + if (!$route->hasDefault('_controller')) { + continue; + } + $controller = $route->getDefault('_controller'); + $reflectedMethod = $this->controllerReflector->getReflectionMethod($controller); + if (null === $reflectedMethod) { + continue; + } + $path = $this->normalizePath($route->getPath()); + $supportedHttpMethods = $this->getSupportedHttpMethods($route); + if (empty($supportedHttpMethods)) { + $this->logger->warning('None of the HTTP methods specified for path {path} are supported by swagger-ui, skipping this path', [ + 'path' => $path, + ]); + + continue; + } + yield $reflectedMethod => [$path, $supportedHttpMethods]; + } + } + + private function getSupportedHttpMethods(Route $route): array + { + $allMethods = Util::OPERATIONS; + $methods = array_map('strtolower', $route->getMethods()); + + return array_intersect($methods ?: $allMethods, $allMethods); + } + + private function normalizePath(string $path): string + { + if ('.{_format}' === substr($path, -10)) { + $path = substr($path, 0, -10); + } + + return $path; + } +} diff --git a/Describer/RouteDescriber.php b/Describer/RouteDescriber.php index aefc63d..703a885 100644 --- a/Describer/RouteDescriber.php +++ b/Describer/RouteDescriber.php @@ -11,10 +11,9 @@ namespace Nelmio\ApiDocBundle\Describer; -use EXSyst\Component\Swagger\Swagger; use Nelmio\ApiDocBundle\RouteDescriber\RouteDescriberInterface; use Nelmio\ApiDocBundle\Util\ControllerReflector; -use Symfony\Component\Routing\Route; +use OpenApi\Annotations as OA; use Symfony\Component\Routing\RouteCollection; final class RouteDescriber implements DescriberInterface, ModelRegistryAwareInterface @@ -39,7 +38,7 @@ final class RouteDescriber implements DescriberInterface, ModelRegistryAwareInte $this->routeDescribers = $routeDescribers; } - public function describe(Swagger $api) + public function describe(OA\OpenApi $api) { if (0 === count($this->routeDescribers)) { return; diff --git a/Describer/SwaggerPhpDescriber.php b/Describer/SwaggerPhpDescriber.php deleted file mode 100644 index a850273..0000000 --- a/Describer/SwaggerPhpDescriber.php +++ /dev/null @@ -1,276 +0,0 @@ -routeCollection = $routeCollection; - $this->controllerReflector = $controllerReflector; - $this->annotationReader = $annotationReader; - $this->logger = $logger; - $this->overwrite = $overwrite; - } - - public function describe(Swagger $api) - { - $analysis = $this->getAnnotations($api); - - $analysis->process($this->getProcessors()); - $analysis->validate(); - - $api->merge(json_decode(json_encode($analysis->swagger), true), $this->overwrite); - } - - private function getProcessors(): array - { - $processors = [ - new AddDefaults(), - new ModelRegister($this->modelRegistry), - ]; - - return array_merge($processors, Analysis::processors()); - } - - private function getAnnotations(Swagger $api): Analysis - { - $analysis = new Analysis(); - $analysis->addAnnotation(new class($api) extends SWG\Swagger { - private $api; - - public function __construct(Swagger $api) - { - $this->api = $api; - parent::__construct([]); - } - - /** - * Support definitions from the config and reference to models. - */ - public function ref($ref) - { - if (0 === strpos($ref, '#/definitions/') && $this->api->getDefinitions()->has(substr($ref, 14))) { - return; - } - if (0 === strpos($ref, '#/parameters/') && isset($this->api->getParameters()[substr($ref, 13)])) { - return; - } - if (0 === strpos($ref, '#/responses/') && $this->api->getResponses()->has(substr($ref, 12))) { - return; - } - - parent::ref($ref); - } - }, null); - - $operationAnnotations = [ - 'get' => SWG\Get::class, - 'post' => SWG\Post::class, - 'put' => SWG\Put::class, - 'patch' => SWG\Patch::class, - 'delete' => SWG\Delete::class, - 'options' => SWG\Options::class, - 'head' => SWG\Head::class, - ]; - - $classAnnotations = []; - - foreach ($this->getMethodsToParse() as $method => list($path, $httpMethods)) { - $declaringClass = $method->getDeclaringClass(); - if (!array_key_exists($declaringClass->getName(), $classAnnotations)) { - $classAnnotations = array_filter($this->annotationReader->getClassAnnotations($declaringClass), function ($v) { - return $v instanceof SWG\AbstractAnnotation; - }); - $classAnnotations[$declaringClass->getName()] = $classAnnotations; - } - - $annotations = array_filter($this->annotationReader->getMethodAnnotations($method), function ($v) { - return $v instanceof SWG\AbstractAnnotation; - }); - - if (0 === count($annotations)) { - continue; - } - - $context = new Context([ - 'namespace' => $method->getNamespaceName(), - 'class' => $declaringClass->getShortName(), - 'method' => $method->name, - 'filename' => $method->getFileName(), - ]); - $nestedContext = clone $context; - $nestedContext->nested = true; - $implicitAnnotations = []; - $operations = []; - $tags = []; - $security = []; - foreach (array_merge($annotations, $classAnnotations[$declaringClass->getName()]) as $annotation) { - $annotation->_context = $context; - $this->updateNestedAnnotations($annotation, $nestedContext); - - if ($annotation instanceof Operation) { - foreach ($httpMethods as $httpMethod) { - $annotationClass = $operationAnnotations[$httpMethod]; - $operation = new $annotationClass(['_context' => $context]); - $operation->path = $path; - $operation->mergeProperties($annotation); - - $operations[$httpMethod] = $operation; - $analysis->addAnnotation($operation, null); - } - - continue; - } - - if ($annotation instanceof SWG\Operation) { - if (null === $annotation->path) { - $annotation = clone $annotation; - $annotation->path = $path; - } - - $operations[$annotation->method] = $annotation; - $analysis->addAnnotation($annotation, null); - - continue; - } - - if ($annotation instanceof Security) { - $annotation->validate(); - $security[] = [$annotation->name => []]; - - continue; - } - - if ($annotation instanceof SWG\Tag) { - $annotation->validate(); - $tags[] = $annotation->name; - - continue; - } - - if (!$annotation instanceof SWG\Response && !$annotation instanceof SWG\Parameter && !$annotation instanceof SWG\ExternalDocumentation) { - throw new \LogicException(sprintf('Using the annotation "%s" as a root annotation in "%s::%s()" is not allowed. It should probably be nested in a `@SWG\Response` or `@SWG\Parameter` annotation.', get_class($annotation), $method->getDeclaringClass()->name, $method->name)); - } - - $implicitAnnotations[] = $annotation; - } - - if (0 === count($implicitAnnotations) && 0 === count($tags) && 0 === count($security)) { - continue; - } - - // Registers new annotations - $analysis->addAnnotations($implicitAnnotations, null); - - foreach ($httpMethods as $httpMethod) { - $annotationClass = $operationAnnotations[$httpMethod]; - $constructorArg = [ - '_context' => $context, - 'path' => $path, - 'value' => $implicitAnnotations, - ]; - - if (0 !== count($tags)) { - $constructorArg['tags'] = $tags; - } - if (0 !== count($security)) { - $constructorArg['security'] = $security; - } - - $operation = new $annotationClass($constructorArg); - if (isset($operations[$httpMethod])) { - $operations[$httpMethod]->mergeProperties($operation); - } else { - $analysis->addAnnotation($operation, null); - } - } - } - - return $analysis; - } - - private function getMethodsToParse(): \Generator - { - foreach ($this->routeCollection->all() as $route) { - if (!$route->hasDefault('_controller')) { - continue; - } - - $controller = $route->getDefault('_controller'); - if ($method = $this->controllerReflector->getReflectionMethod($controller)) { - $path = $this->normalizePath($route->getPath()); - $httpMethods = $route->getMethods() ?: Swagger::$METHODS; - $httpMethods = array_map('strtolower', $httpMethods); - $supportedHttpMethods = array_intersect($httpMethods, Swagger::$METHODS); - - if (empty($supportedHttpMethods)) { - $this->logger->warning('None of the HTTP methods specified for path {path} are supported by swagger-ui, skipping this path', [ - 'path' => $path, - 'methods' => $httpMethods, - ]); - - continue; - } - - yield $method => [$path, $supportedHttpMethods]; - } - } - } - - private function normalizePath(string $path): string - { - if ('.{_format}' === substr($path, -10)) { - $path = substr($path, 0, -10); - } - - return $path; - } - - private function updateNestedAnnotations($value, Context $context) - { - if ($value instanceof AbstractAnnotation) { - $value->_context = $context; - } elseif (!is_array($value)) { - return; - } - - foreach ($value as $v) { - $this->updateNestedAnnotations($v, $context); - } - } -} diff --git a/Model/ModelRegistry.php b/Model/ModelRegistry.php index 7cc20f8..2a5bbe8 100644 --- a/Model/ModelRegistry.php +++ b/Model/ModelRegistry.php @@ -11,10 +11,10 @@ namespace Nelmio\ApiDocBundle\Model; -use EXSyst\Component\Swagger\Schema; -use EXSyst\Component\Swagger\Swagger; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\ModelDescriber\ModelDescriberInterface; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; +use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\Type; final class ModelRegistry @@ -36,7 +36,7 @@ final class ModelRegistry * * @internal */ - public function __construct($modelDescribers, Swagger $api, array $alternativeNames = []) + public function __construct($modelDescribers, OA\OpenApi $api, array $alternativeNames = []) { $this->modelDescribers = $modelDescribers; $this->api = $api; @@ -45,7 +45,7 @@ final class ModelRegistry foreach (array_reverse($alternativeNames) as $alternativeName => $criteria) { $this->alternativeNames[] = $model = new Model(new Type('object', false, $criteria['type']), $criteria['groups']); $this->names[$model->getHash()] = $alternativeName; - $this->api->getDefinitions()->get($alternativeName); + Util::getSchema($this->api, $alternativeName); } } @@ -61,15 +61,15 @@ final class ModelRegistry } // Reserve the name - $this->api->getDefinitions()->get($this->names[$hash]); + Util::getSchema($this->api, $this->names[$hash]); - return '#/definitions/'.$this->names[$hash]; + return OA\Components::SCHEMA_REF.$this->names[$hash]; } /** * @internal */ - public function registerDefinitions() + public function registerSchemas(): void { while (count($this->unregistered)) { $tmp = []; @@ -85,7 +85,7 @@ final class ModelRegistry $modelDescriber->setModelRegistry($this); } if ($modelDescriber->supports($model)) { - $schema = new Schema(); + $schema = Util::getSchema($this->api, $name); $modelDescriber->describe($model, $schema); break; @@ -95,8 +95,6 @@ final class ModelRegistry if (null === $schema) { throw new \LogicException(sprintf('Schema of type "%s" can\'t be generated, no describer supports it.', $this->typeToString($model->getType()))); } - - $this->api->getDefinitions()->set($name, $schema); } } @@ -105,18 +103,19 @@ final class ModelRegistry $this->register($model); } $this->alternativeNames = []; - $this->registerDefinitions(); + $this->registerSchemas(); } } private function generateModelName(Model $model): string { - $definitions = $this->api->getDefinitions(); - $name = $base = $this->getTypeShortName($model->getType()); - + $names = array_column( + $this->api->components instanceof OA\Components && is_array($this->api->components->schemas) ? $this->api->components->schemas : [], + 'schema' + ); $i = 1; - while ($definitions->has($name)) { + while (\in_array($name, $names, true)) { ++$i; $name = $base.$i; } diff --git a/ModelDescriber/Annotations/AnnotationsReader.php b/ModelDescriber/Annotations/AnnotationsReader.php index 5c9f92c..1aa018d 100644 --- a/ModelDescriber/Annotations/AnnotationsReader.php +++ b/ModelDescriber/Annotations/AnnotationsReader.php @@ -12,8 +12,8 @@ namespace Nelmio\ApiDocBundle\ModelDescriber\Annotations; use Doctrine\Common\Annotations\Reader; -use EXSyst\Component\Swagger\Schema; use Nelmio\ApiDocBundle\Model\ModelRegistry; +use OpenApi\Annotations as OA; /** * @internal @@ -24,34 +24,34 @@ class AnnotationsReader private $modelRegistry; private $phpDocReader; - private $swgAnnotationsReader; + private $openApiAnnotationsReader; private $symfonyConstraintAnnotationReader; - public function __construct(Reader $annotationsReader, ModelRegistry $modelRegistry) + public function __construct(Reader $annotationsReader, ModelRegistry $modelRegistry, array $mediaTypes) { $this->annotationsReader = $annotationsReader; $this->modelRegistry = $modelRegistry; $this->phpDocReader = new PropertyPhpDocReader(); - $this->swgAnnotationsReader = new SwgAnnotationsReader($annotationsReader, $modelRegistry); + $this->openApiAnnotationsReader = new OpenApiAnnotationsReader($annotationsReader, $modelRegistry, $mediaTypes); $this->symfonyConstraintAnnotationReader = new SymfonyConstraintAnnotationReader($annotationsReader); } - public function updateDefinition(\ReflectionClass $reflectionClass, Schema $schema) + public function updateDefinition(\ReflectionClass $reflectionClass, OA\Schema $schema): void { - $this->swgAnnotationsReader->updateDefinition($reflectionClass, $schema); + $this->openApiAnnotationsReader->updateSchema($reflectionClass, $schema); $this->symfonyConstraintAnnotationReader->setSchema($schema); } public function getPropertyName(\ReflectionProperty $reflectionProperty, string $default): string { - return $this->swgAnnotationsReader->getPropertyName($reflectionProperty, $default); + return $this->openApiAnnotationsReader->getPropertyName($reflectionProperty, $default); } - public function updateProperty(\ReflectionProperty $reflectionProperty, Schema $property, array $serializationGroups = null) + public function updateProperty(\ReflectionProperty $reflectionProperty, OA\Property $property, array $serializationGroups = null): void { $this->phpDocReader->updateProperty($reflectionProperty, $property); - $this->swgAnnotationsReader->updateProperty($reflectionProperty, $property, $serializationGroups); + $this->openApiAnnotationsReader->updateProperty($reflectionProperty, $property, $serializationGroups); $this->symfonyConstraintAnnotationReader->updateProperty($reflectionProperty, $property); } } diff --git a/ModelDescriber/Annotations/OpenApiAnnotationsReader.php b/ModelDescriber/Annotations/OpenApiAnnotationsReader.php new file mode 100644 index 0000000..7f09f0e --- /dev/null +++ b/ModelDescriber/Annotations/OpenApiAnnotationsReader.php @@ -0,0 +1,87 @@ +annotationsReader = $annotationsReader; + $this->modelRegister = new ModelRegister($modelRegistry, $mediaTypes); + } + + public function updateSchema(\ReflectionClass $reflectionClass, OA\Schema $schema): void + { + /** @var OA\Schema $oaSchema */ + if (!$oaSchema = $this->annotationsReader->getClassAnnotation($reflectionClass, OA\Schema::class)) { + return; + } + + // Read @Model annotations + $this->modelRegister->__invoke(new Analysis([$oaSchema])); + + if (!$oaSchema->validate()) { + return; + } + + $schema->mergeProperties($oaSchema); + } + + public function getPropertyName(\ReflectionProperty $reflectionProperty, string $default): string + { + /** @var OA\Property $oaProperty */ + if (!$oaProperty = $this->annotationsReader->getPropertyAnnotation($reflectionProperty, OA\Property::class)) { + return $default; + } + + return OA\UNDEFINED !== $oaProperty->property ? $oaProperty->property : $default; + } + + public function updateProperty(\ReflectionProperty $reflectionProperty, OA\Property $property, array $serializationGroups = null): void + { + /** @var OA\Property $oaProperty */ + if (!$oaProperty = $this->annotationsReader->getPropertyAnnotation($reflectionProperty, OA\Property::class)) { + return; + } + + $declaringClass = $reflectionProperty->getDeclaringClass(); + $context = new Context([ + 'namespace' => $declaringClass->getNamespaceName(), + 'class' => $declaringClass->getShortName(), + 'property' => $reflectionProperty->name, + 'filename' => $declaringClass->getFileName(), + ]); + $oaProperty->_context = $context; + + // Read @Model annotations + $this->modelRegister->__invoke(new Analysis([$oaProperty]), $serializationGroups); + + if (!$oaProperty->validate()) { + return; + } + + $property->mergeProperties($oaProperty); + } +} diff --git a/ModelDescriber/Annotations/PropertyPhpDocReader.php b/ModelDescriber/Annotations/PropertyPhpDocReader.php index ff823c7..e70d1c2 100644 --- a/ModelDescriber/Annotations/PropertyPhpDocReader.php +++ b/ModelDescriber/Annotations/PropertyPhpDocReader.php @@ -11,8 +11,7 @@ namespace Nelmio\ApiDocBundle\ModelDescriber\Annotations; -use EXSyst\Component\Swagger\Schema; -use Nelmio\ApiDocBundle\Model\Model; +use OpenApi\Annotations as OA; use phpDocumentor\Reflection\DocBlock\Tags\Var_; use phpDocumentor\Reflection\DocBlockFactory; @@ -33,7 +32,7 @@ class PropertyPhpDocReader /** * Update the Swagger information with information from the DocBlock comment. */ - public function updateProperty(\ReflectionProperty $reflectionProperty, Schema $property) + public function updateProperty(\ReflectionProperty $reflectionProperty, OA\Property $property): void { try { $docBlock = $this->docBlockFactory->create($reflectionProperty); @@ -54,11 +53,11 @@ class PropertyPhpDocReader } } } - if (null === $property->getTitle() && $title) { - $property->setTitle($title); + if (OA\UNDEFINED === $property->title && $title) { + $property->title = $title; } - if (null === $property->getDescription() && $docBlock->getDescription() && $docBlock->getDescription()->render()) { - $property->setDescription($docBlock->getDescription()->render()); + if (OA\UNDEFINED === $property->description && $docBlock->getDescription() && $docBlock->getDescription()->render()) { + $property->description = $docBlock->getDescription()->render(); } } } diff --git a/ModelDescriber/Annotations/SwgAnnotationsReader.php b/ModelDescriber/Annotations/SwgAnnotationsReader.php deleted file mode 100644 index 9d365df..0000000 --- a/ModelDescriber/Annotations/SwgAnnotationsReader.php +++ /dev/null @@ -1,89 +0,0 @@ -annotationsReader = $annotationsReader; - $this->modelRegister = new ModelRegister($modelRegistry); - } - - public function updateDefinition(\ReflectionClass $reflectionClass, Schema $schema) - { - /** @var SwgDefinition $swgDefinition */ - if (!$swgDefinition = $this->annotationsReader->getClassAnnotation($reflectionClass, SwgDefinition::class)) { - return; - } - - // Read @Model annotations - $this->modelRegister->__invoke(new Analysis([$swgDefinition])); - - if (!$swgDefinition->validate()) { - return; - } - - $schema->merge(json_decode(json_encode($swgDefinition))); - } - - public function getPropertyName(\ReflectionProperty $reflectionProperty, string $default): string - { - /** @var SwgProperty $swgProperty */ - if (!$swgProperty = $this->annotationsReader->getPropertyAnnotation($reflectionProperty, SwgProperty::class)) { - return $default; - } - - return $swgProperty->property ?? $default; - } - - public function updateProperty(\ReflectionProperty $reflectionProperty, Schema $property, array $serializationGroups = null) - { - if (!$swgProperty = $this->annotationsReader->getPropertyAnnotation($reflectionProperty, SwgProperty::class)) { - return; - } - - $declaringClass = $reflectionProperty->getDeclaringClass(); - $context = new Context([ - 'namespace' => $declaringClass->getNamespaceName(), - 'class' => $declaringClass->getShortName(), - 'property' => $reflectionProperty->name, - 'filename' => $declaringClass->getFileName(), - ]); - $swgProperty->_context = $context; - - // Read @Model annotations - $this->modelRegister->__invoke(new Analysis([$swgProperty]), $serializationGroups); - - if (!$swgProperty->validate()) { - return; - } - - $property->merge(json_decode(json_encode($swgProperty))); - } -} diff --git a/ModelDescriber/Annotations/SymfonyConstraintAnnotationReader.php b/ModelDescriber/Annotations/SymfonyConstraintAnnotationReader.php index 73f8449..58f28ab 100644 --- a/ModelDescriber/Annotations/SymfonyConstraintAnnotationReader.php +++ b/ModelDescriber/Annotations/SymfonyConstraintAnnotationReader.php @@ -12,7 +12,7 @@ namespace Nelmio\ApiDocBundle\ModelDescriber\Annotations; use Doctrine\Common\Annotations\Reader; -use EXSyst\Component\Swagger\Schema; +use OpenApi\Annotations as OA; use Symfony\Component\Validator\Constraints as Assert; /** @@ -26,7 +26,7 @@ class SymfonyConstraintAnnotationReader private $annotationsReader; /** - * @var Schema + * @var OA\Schema */ private $schema; @@ -38,7 +38,7 @@ class SymfonyConstraintAnnotationReader /** * Update the given property and schema with defined Symfony constraints. */ - public function updateProperty(\ReflectionProperty $reflectionProperty, Schema $property) + public function updateProperty(\ReflectionProperty $reflectionProperty, OA\Property $property): void { $annotations = $this->annotationsReader->getPropertyAnnotations($reflectionProperty); @@ -54,35 +54,35 @@ class SymfonyConstraintAnnotationReader continue; } - $existingRequiredFields = $this->schema->getRequired() ?? []; + $existingRequiredFields = OA\UNDEFINED !== $this->schema->required ? $this->schema->required : []; $existingRequiredFields[] = $propertyName; - $this->schema->setRequired(array_values(array_unique($existingRequiredFields))); + $this->schema->required = array_values(array_unique($existingRequiredFields)); } elseif ($annotation instanceof Assert\Length) { - $property->setMinLength($annotation->min); - $property->setMaxLength($annotation->max); + $property->minLength = $annotation->min; + $property->maxLength = $annotation->max; } elseif ($annotation instanceof Assert\Regex) { $this->appendPattern($property, $annotation->getHtmlPattern()); } elseif ($annotation instanceof Assert\Count) { - $property->setMinItems($annotation->min); - $property->setMaxItems($annotation->max); + $property->minItems = $annotation->min; + $property->maxItems = $annotation->max; } elseif ($annotation instanceof Assert\Choice) { $values = $annotation->callback ? call_user_func(is_array($annotation->callback) ? $annotation->callback : [$reflectionProperty->class, $annotation->callback]) : $annotation->choices; - $property->setEnum(array_values($values)); + $property->enum = array_values($values); } elseif ($annotation instanceof Assert\Expression) { $this->appendPattern($property, $annotation->message); } elseif ($annotation instanceof Assert\Range) { - $property->setMinimum($annotation->min); - $property->setMaximum($annotation->max); + $property->minimum = $annotation->min; + $property->maximum = $annotation->max; } elseif ($annotation instanceof Assert\LessThan) { - $property->setExclusiveMaximum($annotation->value); + $property->exclusiveMaximum= $annotation->value; } elseif ($annotation instanceof Assert\LessThanOrEqual) { - $property->setMaximum($annotation->value); + $property->maximum = $annotation->value; } } } - public function setSchema($schema) + public function setSchema($schema): void { $this->schema = $schema; } @@ -90,15 +90,14 @@ class SymfonyConstraintAnnotationReader /** * Get assigned property name for property schema. */ - private function getSchemaPropertyName(Schema $property) + private function getSchemaPropertyName(OA\Schema $property): ?string { if (null === $this->schema) { return null; } - - foreach ($this->schema->getProperties() as $name => $schemaProperty) { + foreach ($this->schema->properties as $schemaProperty) { if ($schemaProperty === $property) { - return $name; + return OA\UNDEFINED !== $schemaProperty->property ? $schemaProperty->property : null; } } @@ -108,16 +107,15 @@ class SymfonyConstraintAnnotationReader /** * Append the pattern from the constraint to the existing pattern. */ - private function appendPattern(Schema $property, $newPattern) + private function appendPattern(OA\Schema $property, $newPattern): void { if (null === $newPattern) { return; } - - if (null !== $property->getPattern()) { - $property->setPattern(sprintf('%s, %s', $property->getPattern(), $newPattern)); + if (OA\UNDEFINED !== $property->pattern) { + $property->pattern = sprintf('%s, %s', $property->pattern, $newPattern); } else { - $property->setPattern($newPattern); + $property->pattern = $newPattern; } } } diff --git a/ModelDescriber/BazingaHateoasModelDescriber.php b/ModelDescriber/BazingaHateoasModelDescriber.php index 21e584c..35e0eee 100644 --- a/ModelDescriber/BazingaHateoasModelDescriber.php +++ b/ModelDescriber/BazingaHateoasModelDescriber.php @@ -11,7 +11,6 @@ namespace Nelmio\ApiDocBundle\ModelDescriber; -use EXSyst\Component\Swagger\Schema; use Hateoas\Configuration\Metadata\ClassMetadata; use Hateoas\Configuration\Relation; use Hateoas\Serializer\Metadata\RelationPropertyMetadata; @@ -20,6 +19,8 @@ use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait; use Nelmio\ApiDocBundle\Model\Model; use Nelmio\ApiDocBundle\Model\ModelRegistry; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; +use OpenApi\Annotations as OA; class BazingaHateoasModelDescriber implements ModelDescriberInterface, ModelRegistryAwareInterface { @@ -43,7 +44,7 @@ class BazingaHateoasModelDescriber implements ModelDescriberInterface, ModelRegi /** * {@inheritdoc} */ - public function describe(Model $model, Schema $schema) + public function describe(Model $model, OA\Schema $schema): void { $this->JMSModelDescriber->describe($model, $schema); @@ -55,9 +56,10 @@ class BazingaHateoasModelDescriber implements ModelDescriberInterface, ModelRegi return; } - $schema->setType('object'); + $schema->type = 'object'; $context = $this->JMSModelDescriber->getSerializationContext($model); + /** @var Relation $relation */ foreach ($metadata->getRelations() as $relation) { if (!$relation->getEmbedded() && !$relation->getHref()) { continue; @@ -71,26 +73,19 @@ class BazingaHateoasModelDescriber implements ModelDescriberInterface, ModelRegi $context->pushPropertyMetadata($item); $embedded = $relation->getEmbedded(); - $relationSchema = $schema->getProperties()->get($embedded ? '_embedded' : '_links'); - - $properties = $relationSchema->getProperties(); - $relationSchema->setReadOnly(true); - - $name = $relation->getName(); - $property = $properties->get($name); + $relationSchema = Util::getProperty($schema, $relation->getEmbedded() ? '_embedded' : '_links'); + $relationSchema->readOnly = true; + $property = Util::getProperty($relationSchema, $relation->getName()); if ($embedded && method_exists($embedded, 'getType') && $embedded->getType()) { $this->JMSModelDescriber->describeItem($embedded->getType(), $property, $context); } else { - $property->setType('object'); + $property->type = 'object'; } if ($relation->getHref()) { - $subProperties = $property->getProperties(); - - $hrefProp = $subProperties->get('href'); - $hrefProp->setType('string'); - - $this->setAttributeProperties($relation, $subProperties); + $hrefProp = Util::getProperty($property, 'href'); + $hrefProp->type = 'string'; + $this->setAttributeProperties($relation, $property); } $context->popPropertyMetadata(); @@ -119,30 +114,30 @@ class BazingaHateoasModelDescriber implements ModelDescriberInterface, ModelRegi return $this->JMSModelDescriber->supports($model) || null !== $this->getHateoasMetadata($model); } - private function setAttributeProperties(Relation $relation, $subProperties) + private function setAttributeProperties(Relation $relation, OA\Property $subProperty): void { foreach ($relation->getAttributes() as $attribute => $value) { - $subSubProp = $subProperties->get($attribute); + $subSubProp = Util::getProperty($subProperty, $attribute); switch (gettype($value)) { case 'integer': - $subSubProp->setType('integer'); - $subSubProp->setDefault($value); + $subSubProp->type = 'integer'; + $subSubProp->default = $value; break; case 'double': case 'float': - $subSubProp->setType('number'); - $subSubProp->setDefault($value); + $subSubProp->type = 'number'; + $subSubProp->default = $value; break; case 'boolean': - $subSubProp->setType('boolean'); - $subSubProp->setDefault($value); + $subSubProp->type = 'boolean'; + $subSubProp->default = $value; break; case 'string': - $subSubProp->setType('string'); - $subSubProp->setDefault($value); + $subSubProp->type = 'string'; + $subSubProp->default = $value; break; } diff --git a/ModelDescriber/FallbackObjectModelDescriber.php b/ModelDescriber/FallbackObjectModelDescriber.php index e437d0d..19ff1fc 100644 --- a/ModelDescriber/FallbackObjectModelDescriber.php +++ b/ModelDescriber/FallbackObjectModelDescriber.php @@ -11,13 +11,13 @@ namespace Nelmio\ApiDocBundle\ModelDescriber; -use EXSyst\Component\Swagger\Schema; use Nelmio\ApiDocBundle\Model\Model; +use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\Type; class FallbackObjectModelDescriber implements ModelDescriberInterface { - public function describe(Model $model, Schema $schema) + public function describe(Model $model, OA\Schema $schema) { } diff --git a/ModelDescriber/FormModelDescriber.php b/ModelDescriber/FormModelDescriber.php index 0255df2..542d432 100644 --- a/ModelDescriber/FormModelDescriber.php +++ b/ModelDescriber/FormModelDescriber.php @@ -11,13 +11,14 @@ namespace Nelmio\ApiDocBundle\ModelDescriber; -use EXSyst\Component\Swagger\Schema; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait; use Nelmio\ApiDocBundle\Model\Model; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; +use OpenApi\Annotations as OA; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\FormType; -use Symfony\Component\Form\FormConfigBuilderInterface; +use Symfony\Component\Form\FormConfigInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormTypeInterface; @@ -38,7 +39,7 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry $this->formFactory = $formFactory; } - public function describe(Model $model, Schema $schema) + public function describe(Model $model, OA\Schema $schema) { if (method_exists(AbstractType::class, 'setDefaultOptions')) { throw new \LogicException('symfony/form < 3.0 is not supported, please upgrade to an higher version to use a form as a model.'); @@ -47,7 +48,7 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry throw new \LogicException('You need to enable forms in your application to use a form as a model.'); } - $schema->setType('object'); + $schema->type = 'object'; $class = $model->getType()->getClassName(); @@ -60,26 +61,24 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry return is_a($model->getType()->getClassName(), FormTypeInterface::class, true); } - private function parseForm(Schema $schema, FormInterface $form) + private function parseForm(OA\Schema $schema, FormInterface $form) { - $properties = $schema->getProperties(); - foreach ($form as $name => $child) { $config = $child->getConfig(); - $property = $properties->get($name); + $property = Util::getProperty($schema, $name); if ($config->getRequired()) { - $required = $schema->getRequired() ?? []; + $required = OA\UNDEFINED !== $schema->required ? $schema->required : []; $required[] = $name; - $schema->setRequired($required); + $schema->required = $required; } if ($config->hasOption('documentation')) { - $property->merge($config->getOption('documentation')); + $property->mergeProperties($config->getOption('documentation')); } - if (null !== $property->getType()) { + if (OA\UNDEFINED !== $property->type) { continue; // Type manually defined } @@ -90,12 +89,9 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry /** * Finds and sets the schema type on $property based on $config info. * - * Returns true if a native Swagger type was found, false otherwise - * - * @param FormConfigBuilderInterface $config - * @param $property + * Returns true if a native OpenAPi type was found, false otherwise */ - private function findFormType(FormConfigBuilderInterface $config, $property) + private function findFormType(FormConfigInterface $config, OA\Schema $property) { $type = $config->getType(); @@ -106,7 +102,7 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry null, $config->getOptions() ); - $property->setRef($this->modelRegistry->register($model)); + $property->ref = $this->modelRegistry->register($model); return; } @@ -115,42 +111,42 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry $blockPrefix = $builtinFormType->getBlockPrefix(); if ('text' === $blockPrefix) { - $property->setType('string'); + $property->type = 'string'; break; } if ('number' === $blockPrefix) { - $property->setType('number'); + $property->type = 'number'; break; } if ('integer' === $blockPrefix) { - $property->setType('integer'); + $property->type = 'integer'; break; } if ('date' === $blockPrefix) { - $property->setType('string'); - $property->setFormat('date'); + $property->type = 'string'; + $property->format = 'date'; break; } if ('datetime' === $blockPrefix) { - $property->setType('string'); - $property->setFormat('date-time'); + $property->type = 'string'; + $property->format = 'date-time'; break; } if ('choice' === $blockPrefix) { if ($config->getOption('multiple')) { - $property->setType('array'); + $property->type = 'array'; } else { - $property->setType('string'); + $property->type = 'string'; } if (($choices = $config->getOption('choices')) && is_array($choices) && count($choices)) { $enums = array_values($choices); @@ -163,9 +159,10 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry } if ($config->getOption('multiple')) { - $property->getItems()->setType($type)->setEnum($enums); + $property->items = Util::createChild($property, OA\Items::class, ['type' => $type, 'enum' => $enums]); } else { - $property->setType($type)->setEnum($enums); + $property->type = $type; + $property->enum = $enums; } } @@ -173,28 +170,28 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry } if ('checkbox' === $blockPrefix) { - $property->setType('boolean'); + $property->type= 'boolean'; break; } if ('password' === $blockPrefix) { - $property->setType('string'); - $property->setFormat('password'); + $property->type = 'string'; + $property->format = 'password'; break; } if ('repeated' === $blockPrefix) { - $property->setType('object'); - $property->setRequired([$config->getOption('first_name'), $config->getOption('second_name')]); + $property->type = 'object'; + $property->required = [$config->getOption('first_name'), $config->getOption('second_name')]; $subType = $config->getOption('type'); foreach (['first', 'second'] as $subField) { $subName = $config->getOption($subField.'_name'); $subForm = $this->formFactory->create($subType, null, array_merge($config->getOption('options'), $config->getOption($subField.'_options'))); - $this->findFormType($subForm->getConfig(), $property->getProperties()->get($subName)); + $this->findFormType($subForm->getConfig(), Util::getProperty($property, $subName)); } break; @@ -205,10 +202,10 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry $subOptions = $config->getOption('entry_options'); $subForm = $this->formFactory->create($subType, null, $subOptions); - $property->setType('array'); - $itemsProp = $property->getItems(); + $property->type = 'array'; + $property->items = Util::createChild($property, OA\Items::class); - $this->findFormType($subForm->getConfig(), $itemsProp); + $this->findFormType($subForm->getConfig(), $property->items); break; } @@ -218,12 +215,12 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry $entityClass = $config->getOption('class'); if ($config->getOption('multiple')) { - $property->setFormat(sprintf('[%s id]', $entityClass)); - $property->setType('array'); - $property->getItems()->setType('string'); + $property->format = sprintf('[%s id]', $entityClass); + $property->type = 'array'; + $property->items = Util::createChild($property, OA\Items::class, ['type' => 'string']); } else { - $property->setType('string'); - $property->setFormat(sprintf('%s id', $entityClass)); + $property->type = 'string'; + $property->format = sprintf('%s id', $entityClass); } break; diff --git a/ModelDescriber/JMSModelDescriber.php b/ModelDescriber/JMSModelDescriber.php index ec661e8..39b6a2f 100644 --- a/ModelDescriber/JMSModelDescriber.php +++ b/ModelDescriber/JMSModelDescriber.php @@ -12,7 +12,6 @@ namespace Nelmio\ApiDocBundle\ModelDescriber; use Doctrine\Common\Annotations\Reader; -use EXSyst\Component\Swagger\Schema; use JMS\Serializer\Context; use JMS\Serializer\Exclusion\GroupsExclusionStrategy; use JMS\Serializer\Naming\PropertyNamingStrategyInterface; @@ -22,6 +21,8 @@ use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait; use Nelmio\ApiDocBundle\Model\Model; use Nelmio\ApiDocBundle\ModelDescriber\Annotations\AnnotationsReader; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; +use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\Type; /** @@ -41,6 +42,8 @@ class JMSModelDescriber implements ModelDescriberInterface, ModelRegistryAwareIn private $metadataStacks = []; + private $mediaTypes; + /** * @var array */ @@ -48,18 +51,20 @@ class JMSModelDescriber implements ModelDescriberInterface, ModelRegistryAwareIn public function __construct( MetadataFactoryInterface $factory, - PropertyNamingStrategyInterface $namingStrategy = null, - Reader $reader + Reader $reader, + array $mediaTypes, + ?PropertyNamingStrategyInterface $namingStrategy = null ) { $this->factory = $factory; $this->namingStrategy = $namingStrategy; $this->doctrineReader = $reader; + $this->mediaTypes = $mediaTypes; } /** * {@inheritdoc} */ - public function describe(Model $model, Schema $schema) + public function describe(Model $model, OA\Schema $schema) { $className = $model->getType()->getClassName(); $metadata = $this->factory->getMetadataForClass($className); @@ -67,12 +72,11 @@ class JMSModelDescriber implements ModelDescriberInterface, ModelRegistryAwareIn throw new \InvalidArgumentException(sprintf('No metadata found for class %s.', $className)); } - $schema->setType('object'); - $annotationsReader = new AnnotationsReader($this->doctrineReader, $this->modelRegistry); + $schema->type = 'object'; + $annotationsReader = new AnnotationsReader($this->doctrineReader, $this->modelRegistry, $this->mediaTypes); $annotationsReader->updateDefinition(new \ReflectionClass($className), $schema); $isJmsV1 = null !== $this->namingStrategy; - $properties = $schema->getProperties(); $context = $this->getSerializationContext($model); $context->pushClassMetadata($metadata); @@ -106,25 +110,26 @@ class JMSModelDescriber implements ModelDescriberInterface, ModelRegistryAwareIn continue; } - $property = $properties->get($annotationsReader->getPropertyName($reflection, $name)); + $property = Util::getProperty($schema, $annotationsReader->getPropertyName($reflection, $name)); $annotationsReader->updateProperty($reflection, $property, $groups); } catch (\ReflectionException $e) { - $property = $properties->get($name); + $property = Util::getProperty($schema, $name); } - if (null !== $property->getType() || null !== $property->getRef()) { + if (OA\UNDEFINED !== $property->type || OA\UNDEFINED !== $property->ref) { $context->popPropertyMetadata(); continue; } if (null === $item->type) { - $properties->remove($name); + $key = Util::searchIndexedCollectionItem($schema->properties, 'property', $name); + unset($schema->properties[$key]); $context->popPropertyMetadata(); continue; } - $this->describeItem($item->type, $property, $context, $item); + $this->describeItem($item->type, $property, $context); $context->popPropertyMetadata(); } $context->popClassMetadata(); @@ -196,48 +201,51 @@ class JMSModelDescriber implements ModelDescriberInterface, ModelRegistryAwareIn /** * @internal */ - public function describeItem(array $type, $property, Context $context) + public function describeItem(array $type, OA\Schema $property, Context $context) { $nestedTypeInfo = $this->getNestedTypeInArray($type); if (null !== $nestedTypeInfo) { list($nestedType, $isHash) = $nestedTypeInfo; if ($isHash) { - $property->setType('object'); - // in the case of a virtual property, set it as free object type - $property->merge(['additionalProperties' => []]); + $property->type = 'object'; + $property->additionalProperties = Util::createChild($property, OA\Property::class); // this is a free form object (as nested array) if ('array' === $nestedType['name'] && !isset($nestedType['params'][0])) { + // in the case of a virtual property, set it as free object type + $property->additionalProperties = true; + return; } - $this->describeItem($nestedType, $property->getAdditionalProperties(), $context); + $this->describeItem($nestedType, $property->additionalProperties, $context); return; } - $property->setType('array'); - $this->describeItem($nestedType, $property->getItems(), $context); + $property->type = 'array'; + $property->items = Util::createChild($property, OA\Items::class); + $this->describeItem($nestedType, $property->items, $context); } elseif ('array' === $type['name']) { - $property->setType('object'); - $property->merge(['additionalProperties' => []]); + $property->type = 'object'; + $property->additionalProperties = true; } elseif ('string' === $type['name']) { - $property->setType('string'); + $property->type = 'string'; } elseif (in_array($type['name'], ['bool', 'boolean'], true)) { - $property->setType('boolean'); + $property->type = 'boolean'; } elseif (in_array($type['name'], ['int', 'integer'], true)) { - $property->setType('integer'); + $property->type = 'integer'; } elseif (in_array($type['name'], ['double', 'float'], true)) { - $property->setType('number'); - $property->setFormat($type['name']); + $property->type = 'number'; + $property->format = $type['name']; } elseif (is_subclass_of($type['name'], \DateTimeInterface::class)) { - $property->setType('string'); - $property->setFormat('date-time'); + $property->type = 'string'; + $property->format = 'date-time'; } else { $groups = $this->computeGroups($context, $type); $model = new Model(new Type(Type::BUILTIN_TYPE_OBJECT, false, $type['name']), $groups); - $property->setRef($this->modelRegistry->register($model)); + $property->ref = $this->modelRegistry->register($model); $this->contexts[$model->getHash()] = $context; $this->metadataStacks[$model->getHash()] = clone $context->getMetadataStack(); diff --git a/ModelDescriber/ModelDescriberInterface.php b/ModelDescriber/ModelDescriberInterface.php index 5029ba0..56c2d80 100644 --- a/ModelDescriber/ModelDescriberInterface.php +++ b/ModelDescriber/ModelDescriberInterface.php @@ -11,8 +11,8 @@ namespace Nelmio\ApiDocBundle\ModelDescriber; -use EXSyst\Component\Swagger\Schema; use Nelmio\ApiDocBundle\Model\Model; +use OpenApi\Annotations\Schema; interface ModelDescriberInterface { diff --git a/ModelDescriber/ObjectModelDescriber.php b/ModelDescriber/ObjectModelDescriber.php index d5f293c..3c33385 100644 --- a/ModelDescriber/ObjectModelDescriber.php +++ b/ModelDescriber/ObjectModelDescriber.php @@ -12,12 +12,13 @@ namespace Nelmio\ApiDocBundle\ModelDescriber; use Doctrine\Common\Annotations\Reader; -use EXSyst\Component\Swagger\Schema; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait; use Nelmio\ApiDocBundle\Model\Model; use Nelmio\ApiDocBundle\ModelDescriber\Annotations\AnnotationsReader; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; use Nelmio\ApiDocBundle\PropertyDescriber\PropertyDescriberInterface; +use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\PropertyInfo\Type; @@ -31,31 +32,36 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar private $doctrineReader; /** @var PropertyDescriberInterface[] */ private $propertyDescribers; + /** @var string[] */ + private $mediaTypes; private $swaggerDefinitionAnnotationReader; public function __construct( PropertyInfoExtractorInterface $propertyInfo, Reader $reader, - $propertyDescribers + $propertyDescribers, + array $mediaTypes ) { $this->propertyInfo = $propertyInfo; $this->doctrineReader = $reader; $this->propertyDescribers = $propertyDescribers; + $this->mediaTypes = $mediaTypes; } - public function describe(Model $model, Schema $schema) + public function describe(Model $model, OA\Schema $schema) { - $schema->setType('object'); - $properties = $schema->getProperties(); + $schema->type = 'object'; $class = $model->getType()->getClassName(); + $schema->_context->class = $class; + $context = []; if (null !== $model->getGroups()) { $context = ['serializer_groups' => array_filter($model->getGroups(), 'is_string')]; } - $annotationsReader = new AnnotationsReader($this->doctrineReader, $this->modelRegistry); + $annotationsReader = new AnnotationsReader($this->doctrineReader, $this->modelRegistry, $this->mediaTypes); $annotationsReader->updateDefinition(new \ReflectionClass($class), $schema); $propertyInfoProperties = $this->propertyInfo->getProperties($class, $context); @@ -64,10 +70,10 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar } foreach ($propertyInfoProperties as $propertyName) { - // read property options from Swagger Property annotation if it exists + // read property options from OpenApi Property annotation if it exists if (property_exists($class, $propertyName)) { $reflectionProperty = new \ReflectionProperty($class, $propertyName); - $property = $properties->get($annotationsReader->getPropertyName($reflectionProperty, $propertyName)); + $property = Util::getProperty($schema, $annotationsReader->getPropertyName($reflectionProperty, $propertyName)); $groups = $model->getGroups(); if (isset($groups[$propertyName]) && is_array($groups[$propertyName])) { @@ -76,11 +82,11 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar $annotationsReader->updateProperty($reflectionProperty, $property, $groups); } else { - $property = $properties->get($propertyName); + $property = Util::getProperty($schema, $propertyName); } // If type manually defined - if (null !== $property->getType() || null !== $property->getRef()) { + if (OA\UNDEFINED !== $property->type || OA\UNDEFINED !== $property->ref) { continue; } @@ -97,7 +103,7 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar } } - private function describeProperty(Type $type, Model $model, Schema $property, string $propertyName) + private function describeProperty(Type $type, Model $model, OA\Schema $property, string $propertyName) { foreach ($this->propertyDescribers as $propertyDescriber) { if ($propertyDescriber instanceof ModelRegistryAwareInterface) { @@ -110,7 +116,7 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar } } - throw new \Exception(sprintf('Type "%s" is not supported in %s::$%s. You may use the `@SWG\Property(type="")` annotation to specify it manually.', $type->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.', $type->getBuiltinType(), $model->getType()->getClassName(), $propertyName)); } public function supports(Model $model): bool diff --git a/OpenApiPhp/AddDefaults.php b/OpenApiPhp/AddDefaults.php new file mode 100644 index 0000000..2dc53ba --- /dev/null +++ b/OpenApiPhp/AddDefaults.php @@ -0,0 +1,39 @@ +getAnnotationsOfType(OA\Info::class)) { + return; + } + if (($annotations = $analysis->getAnnotationsOfType(OA\OpenApi::class)) && OA\UNDEFINED !== $annotations[0]->info) { + return; + } + if (OA\UNDEFINED !== $analysis->openapi->info) { + return; + } + + $analysis->addAnnotation(new OA\Info(['title' => '', 'version' => '0.0.0', '_context' => new Context(['generated' => true])]), null); + } +} diff --git a/OpenApiPhp/ModelRegister.php b/OpenApiPhp/ModelRegister.php new file mode 100644 index 0000000..52c9903 --- /dev/null +++ b/OpenApiPhp/ModelRegister.php @@ -0,0 +1,171 @@ +modelRegistry = $modelRegistry; + $this->mediaTypes = $mediaTypes; + } + + public function __invoke(Analysis $analysis, array $parentGroups = null) + { + foreach ($analysis->annotations as $annotation) { + // @Model using the ref field + if ($annotation instanceof OA\Schema && $annotation->ref instanceof ModelAnnotation) { + $model = $annotation->ref; + + $annotation->ref = $this->modelRegistry->register(new Model($this->createType($model->type), $this->getGroups($model, $parentGroups), $model->options)); + + // It is no longer an unmerged annotation + $this->detach($model, $annotation, $analysis); + + continue; + } + + // Misusage of ::$ref + if (($annotation instanceof OA\Response || $annotation instanceof OA\RequestBody) && $annotation->ref instanceof ModelAnnotation) { + throw new \InvalidArgumentException(sprintf('Using @Model inside @%s::$ref is not allowed. You should use ::$ref with @Property, @Parameter, @Schema, @Items but within @Response or @RequestBody you should put @Model directly at the root of the annotation : `@Response(..., @Model(...))`.', get_class($annotation))); + } + + // Implicit usages + + // We don't use $ref for @Responses, @RequestBody and @Parameter to respect semantics + // We don't replace these objects with the @Model found (we inject it in a subfield) whereas we do for @Schemas + + $model = $this->getModel($annotation); // We check whether there is a @Model annotation nested + if (null === $model) { + continue; + } + + if ($annotation instanceof OA\Response || $annotation instanceof OA\RequestBody) { + $properties = [ + '_context' => Util::createContext(['nested' => $annotation], $annotation->_context), + 'ref' => $this->modelRegistry->register(new Model($this->createType($model->type), $this->getGroups($model, $parentGroups), $model->options)), + ]; + + foreach ($this->mediaTypes as $mediaType) { + $this->createContentForMediaType($mediaType, $properties, $annotation, $analysis); + } + $this->detach($model, $annotation, $analysis); + + continue; + } + + if (!$annotation instanceof OA\Parameter) { + throw new \InvalidArgumentException(sprintf("@Model annotation can't be nested with an annotation of type @%s.", get_class($annotation))); + } + + if ($annotation->schema instanceof OA\Schema && 'array' === $annotation->schema->type) { + $annotationClass = OA\Items::class; + } else { + $annotationClass = OA\Schema::class; + } + + if (!is_string($model->type)) { + // Ignore invalid annotations, they are validated later + continue; + } + + $annotation->merge([new $annotationClass([ + 'ref' => $this->modelRegistry->register(new Model($this->createType($model->type), $this->getGroups($model, $parentGroups), $model->options)), + ])]); + + // It is no longer an unmerged annotation + $this->detach($model, $annotation, $analysis); + } + } + + private function getGroups(ModelAnnotation $model, array $parentGroups = null) + { + if (null === $model->groups) { + return $parentGroups; + } + + return array_merge($parentGroups ?? [], $model->groups); + } + + private function detach(ModelAnnotation $model, OA\AbstractAnnotation $annotation, Analysis $analysis) + { + foreach ($annotation->_unmerged as $key => $unmerged) { + if ($unmerged === $model) { + unset($annotation->_unmerged[$key]); + + break; + } + } + $analysis->annotations->detach($model); + } + + private function createType(string $type): Type + { + if ('[]' === substr($type, -2)) { + return new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, null, $this->createType(substr($type, 0, -2))); + } + + return new Type(Type::BUILTIN_TYPE_OBJECT, false, $type); + } + + private function getModel(OA\AbstractAnnotation $annotation): ?ModelAnnotation + { + foreach ($annotation->_unmerged as $unmerged) { + if ($unmerged instanceof ModelAnnotation) { + return $unmerged; + } + } + + return null; + } + + private function createContentForMediaType( + string $type, + array $properties, + OA\AbstractAnnotation $annotation, + Analysis $analysis + ) { + switch ($type) { + case 'json': + $modelAnnotation = new OA\JsonContent($properties); + + break; + case 'xml': + $modelAnnotation = new OA\XmlContent($properties); + + break; + default: + throw new \InvalidArgumentException(sprintf("@Model annotation is not compatible with the media type '%s'. It must be one of 'json' or 'xml'.", $this->mediaType)); + } + + $annotation->merge([$modelAnnotation]); + $analysis->addAnnotation($modelAnnotation, null); + } +} diff --git a/OpenApiPhp/Util.php b/OpenApiPhp/Util.php new file mode 100644 index 0000000..1473431 --- /dev/null +++ b/OpenApiPhp/Util.php @@ -0,0 +1,519 @@ +paths[] having its member path set to $path. + * Create, add to $api->paths[] and return this new PathItem object and set the property if none found. + * + * @see OA\OpenApi::$paths + * @see OA\PathItem::path + * + * @param OA\OpenApi $api + * @param string $path + * + * @return OA\PathItem + */ + public static function getPath(OA\OpenApi $api, $path): OA\PathItem + { + return self::getIndexedCollectionItem($api, OA\PathItem::class, $path); + } + + /** + * Return an existing Schema object from $api->components->schemas[] having its member schema set to $schema. + * Create, add to $api->components->schemas[] and return this new Schema object and set the property if none found. + * + * @param OA\OpenApi $api + * @param string $schema + * + * @return OA\Schema + * + * @see OA\Schema::$schema + * @see OA\Components::$schemas + */ + public static function getSchema(OA\OpenApi $api, $schema): OA\Schema + { + if (!$api->components instanceof OA\Components) { + $api->components = new OA\Components([]); + } + + return self::getIndexedCollectionItem($api->components, OA\Schema::class, $schema); + } + + /** + * Return an existing Property object from $schema->properties[] + * having its member property set to $property. + * + * Create, add to $schema->properties[] and return this new Property object + * and set the property if none found. + * + * @see OA\Schema::$properties + * @see OA\Property::$property + * + * @param OA\Schema $schema + * @param string $property + * + * @return OA\Property + */ + public static function getProperty(OA\Schema $schema, $property): OA\Property + { + return self::getIndexedCollectionItem($schema, OA\Property::class, $property); + } + + /** + * Return an existing Operation from $path->{$method} + * or create, set $path->{$method} and return this new Operation object. + * + * @see OA\PathItem::$get + * @see OA\PathItem::$post + * @see OA\PathItem::$put + * @see OA\PathItem::$patch + * @see OA\PathItem::$delete + * @see OA\PathItem::$options + * @see OA\PathItem::$head + * + * @param OA\PathItem $path + * @param string $method + * + * @return OA\Operation + */ + public static function getOperation(OA\PathItem $path, $method): OA\Operation + { + $class = array_keys($path::$_nested, \strtolower($method), true)[0]; + + return self::getChild($path, $class, ['path' => $path->path]); + } + + /** + * Return an existing Parameter object from $operation->parameters[] + * having its members name set to $name and in set to $in. + * + * Create, add to $operation->parameters[] and return + * this new Parameter object and set its members if none found. + * + * @see OA\Operation::$parameters + * @see OA\Parameter::$name + * @see OA\Parameter::$in + * + * @param OA\Operation $operation + * @param string $name + * @param string $in + * + * @return OA\Parameter + */ + public static function getOperationParameter(OA\Operation $operation, $name, $in): OA\Parameter + { + return self::getCollectionItem($operation, OA\Parameter::class, ['name' => $name, 'in' => $in]); + } + + /** + * Return an existing nested Annotation from $parent->{$property} if exists. + * Create, add to $parent->{$property} and set its members to $properties otherwise. + * + * $property is determined from $parent::$_nested[$class] + * it is expected to be a string nested property. + * + * @see OA\AbstractAnnotation::$_nested + * + * @param OA\AbstractAnnotation $parent + * @param $class + * @param array $properties + * + * @return OA\AbstractAnnotation + */ + public static function getChild(OA\AbstractAnnotation $parent, $class, array $properties = []): OA\AbstractAnnotation + { + $nested = $parent::$_nested; + $property = $nested[$class]; + + if (null === $parent->{$property} || UNDEFINED === $parent->{$property}) { + $parent->{$property} = self::createChild($parent, $class, $properties); + } + + return $parent->{$property}; + } + + /** + * Return an existing nested Annotation from $parent->{$collection}[] + * having all $properties set to the respective values. + * + * Create, add to $parent->{$collection}[] and set its members + * to $properties otherwise. + * + * $collection is determined from $parent::$_nested[$class] + * it is expected to be a single value array nested Annotation. + * + * @see OA\AbstractAnnotation::$_nested + * + * @param OA\AbstractAnnotation $parent + * @param string $class + * @param array $properties + * + * @return OA\AbstractAnnotation + */ + public static function getCollectionItem(OA\AbstractAnnotation $parent, $class, array $properties = []): OA\AbstractAnnotation + { + $key = null; + $nested = $parent::$_nested; + $collection = $nested[$class][0]; + + if (!empty($properties)) { + $key = self::searchCollectionItem( + $parent->{$collection} && UNDEFINED !== $parent->{$collection} ? $parent->{$collection} : [], + $properties + ); + } + if (null === $key) { + $key = self::createCollectionItem($parent, $collection, $class, $properties); + } + + return $parent->{$collection}[$key]; + } + + /** + * Return an existing nested Annotation from $parent->{$collection}[] + * having its mapped $property set to $value. + * + * Create, add to $parent->{$collection}[] and set its member $property to $value otherwise. + * + * $collection is determined from $parent::$_nested[$class] + * it is expected to be a double value array nested Annotation + * with the second value being the mapping index $property. + * + * @see OA\AbstractAnnotation::$_nested + * + * @param OA\AbstractAnnotation $parent + * @param string $class + * @param mixed $value + * + * @return OA\AbstractAnnotation + */ + public static function getIndexedCollectionItem(OA\AbstractAnnotation $parent, $class, $value): OA\AbstractAnnotation + { + $nested = $parent::$_nested; + [$collection, $property] = $nested[$class]; + + $key = self::searchIndexedCollectionItem( + $parent->{$collection} && UNDEFINED !== $parent->{$collection} ? $parent->{$collection} : [], + $property, + $value + ); + + if (false === $key) { + $key = self::createCollectionItem($parent, $collection, $class, [$property => $value]); + } + + return $parent->{$collection}[$key]; + } + + /** + * Search for an Annotation within $collection that has all members set + * to the respective values in the associative array $properties. + * + * @param array $collection + * @param array $properties + * + * @return int|string|null + */ + public static function searchCollectionItem(array $collection, array $properties) + { + foreach ($collection ?: [] as $i => $child) { + foreach ($properties as $k => $prop) { + if ($child->{$k} !== $prop) { + continue 2; + } + } + + return $i; + } + + return null; + } + + /** + * Search for an Annotation within the $collection that has its member $index set to $value. + * + * @param array $collection + * @param string $member + * @param mixed $value + * + * @return false|int|string + */ + public static function searchIndexedCollectionItem(array $collection, $member, $value) + { + return array_search($value, array_column($collection, $member), true); + } + + /** + * Create a new Object of $class with members $properties within $parent->{$collection}[] + * and return the created index. + * + * @param OA\AbstractAnnotation $parent + * @param string $collection + * @param string $class + * @param array $properties + * + * @return int + */ + public static function createCollectionItem(OA\AbstractAnnotation $parent, $collection, $class, array $properties = []): int + { + if (UNDEFINED === $parent->{$collection}) { + $parent->{$collection} = []; + } + + $key = \count($parent->{$collection} ?: []); + $parent->{$collection}[$key] = self::createChild($parent, $class, $properties); + + return $key; + } + + /** + * Create a new Object of $class with members $properties and set the context parent to be $parent. + * + * + * @param OA\AbstractAnnotation $parent + * @param string $class + * @param array $properties + * + * @throws \InvalidArgumentException at an attempt to pass in properties that are found in $parent::$_nested + * + * @return OA\AbstractAnnotation + */ + public static function createChild(OA\AbstractAnnotation $parent, $class, array $properties = []): OA\AbstractAnnotation + { + $nesting = self::getNestingIndexes($class); + + if (!empty(array_intersect(array_keys($properties), $nesting))) { + throw new \InvalidArgumentException('Nesting Annotations is not supported.'); + } + + return new $class( + array_merge($properties, ['_context' => self::createContext(['nested' => $parent], $parent->_context)]) + ); + } + + /** + * Create a new Context with members $properties and parent context $parent. + * + * @see Context + * + * @param array $properties + * @param Context|null $parent + * + * @return Context + */ + public static function createContext(array $properties = [], Context $parent = null): Context + { + return new Context($properties, $parent); + } + + /** + * Merge $from into $annotation. $overwrite is only used for leaf scalar values. + * + * The main purpose is to create a Swagger Object from array config values + * in the structure of a json serialized Swagger object. + * + * @param OA\AbstractAnnotation $annotation + * @param array|\ArrayObject|OA\AbstractAnnotation $from + * @param bool $overwrite + */ + public static function merge(OA\AbstractAnnotation $annotation, $from, bool $overwrite = false) + { + if (\is_array($from)) { + self::mergeFromArray($annotation, $from, $overwrite); + } elseif (\is_a($from, OA\AbstractAnnotation::class)) { + /* @var OA\AbstractAnnotation $from */ + self::mergeFromArray($annotation, json_decode(json_encode($from), true), $overwrite); + } elseif (\is_a($from, \ArrayObject::class)) { + /* @var \ArrayObject $from */ + self::mergeFromArray($annotation, $from->getArrayCopy(), $overwrite); + } + } + + private static function mergeFromArray(OA\AbstractAnnotation $annotation, array $properties, bool $overwrite) + { + $done = []; + + foreach ($annotation::$_nested as $className => $propertyName) { + if (\is_string($propertyName)) { + if (array_key_exists($propertyName, $properties)) { + self::mergeChild($annotation, $className, $properties[$propertyName], $overwrite); + $done[] = $propertyName; + } + } elseif (\array_key_exists($propertyName[0], $properties)) { + $collection = $propertyName[0]; + $property = $propertyName[1] ?? null; + self::mergeCollection($annotation, $className, $collection, $property, $properties[$collection], $overwrite); + $done[] = $collection; + } + } + + $defaults = \get_class_vars(\get_class($annotation)); + + foreach ($annotation::$_types as $propertyName => $type) { + if (array_key_exists($propertyName, $properties)) { + self::mergeTyped($annotation, $propertyName, $type, $properties, $defaults, $overwrite); + $done[] = $propertyName; + } + } + + foreach ($properties as $propertyName => $value) { + if ('$ref' === $propertyName) { + $propertyName = 'ref'; + } + if (!\in_array($propertyName, $done, true)) { + self::mergeProperty($annotation, $propertyName, $value, $defaults[$propertyName], $overwrite); + } + } + } + + private static function mergeChild(OA\AbstractAnnotation $annotation, $className, $value, bool $overwrite) + { + self::merge(self::getChild($annotation, $className), $value, $overwrite); + } + + private static function mergeCollection(OA\AbstractAnnotation $annotation, $className, $collection, $property, $items, bool $overwrite) + { + if (null !== $property) { + foreach ($items as $prop => $value) { + $child = self::getIndexedCollectionItem($annotation, $className, (string) $prop); + self::merge($child, $value); + } + } else { + $nesting = self::getNestingIndexes($className); + foreach ($items as $props) { + $create = []; + $merge = []; + foreach ($props as $k => $v) { + if (\in_array($k, $nesting, true)) { + $merge[$k] = $v; + } else { + $create[$k] = $v; + } + } + self::merge(self::getCollectionItem($annotation, $className, $create), $merge, $overwrite); + } + } + } + + private static function mergeTyped(OA\AbstractAnnotation $annotation, $propertyName, $type, array $properties, array $defaults, bool $overwrite) + { + if (\is_string($type) && 0 === strpos($type, '[')) { + /* type is declared as array in @see OA\AbstractAnnotation::$_types */ + $annotation->{$propertyName} = array_unique(array_merge( + $annotation->{$propertyName} && UNDEFINED !== $annotation->{$propertyName} ? $annotation->{$propertyName} : [], + $properties[$propertyName] + )); + } else { + self::mergeProperty($annotation, $propertyName, $properties[$propertyName], $defaults[$propertyName], $overwrite); + } + } + + private static function mergeProperty(OA\AbstractAnnotation $annotation, $propertyName, $value, $default, bool $overwrite) + { + if (true === $overwrite || $default === $annotation->{$propertyName}) { + $annotation->{$propertyName} = $value; + } + } + + private static function getNestingIndexes($class): array + { + return array_values(array_map( + function ($value) { + return \is_array($value) ? $value[0] : $value; + }, + self::getNesting($class) ?? [] + )); + } + + private static function getNesting($class) + { + switch ($class) { + case OA\OpenApi::class: + return OA\OpenApi::$_nested; + case OA\Info::class: + return OA\Info::$_nested; + case OA\PathItem::class: + return OA\PathItem::$_nested; + case OA\Get::class: + case OA\Post::class: + case OA\Put::class: + case OA\Delete::class: + case OA\Patch::class: + case OA\Head::class: + case OA\Options::class: + return OA\Operation::$_nested; + case OA\Parameter::class: + return OA\Parameter::$_nested; + case OA\Items::class: + return OA\Items::$_nested; + case OA\Property::class: + case OA\Schema::class: + return OA\Schema::$_nested; + case OA\Tag::class: + return OA\Tag::$_nested; + case OA\Response::class: + return OA\Response::$_nested; + case OA\Header::class: + return OA\Header::$_nested; + default: + return null; + } + } +} diff --git a/PropertyDescriber/ArrayPropertyDescriber.php b/PropertyDescriber/ArrayPropertyDescriber.php index 7055df4..0608404 100644 --- a/PropertyDescriber/ArrayPropertyDescriber.php +++ b/PropertyDescriber/ArrayPropertyDescriber.php @@ -11,9 +11,10 @@ namespace Nelmio\ApiDocBundle\PropertyDescriber; -use EXSyst\Component\Swagger\Schema; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; +use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\Type; class ArrayPropertyDescriber implements PropertyDescriberInterface, ModelRegistryAwareInterface @@ -28,15 +29,15 @@ class ArrayPropertyDescriber implements PropertyDescriberInterface, ModelRegistr $this->propertyDescribers = $propertyDescribers; } - public function describe(Type $type, Schema $property, array $groups = null) + public function describe(Type $type, OA\Schema $property, array $groups = null) { $type = $type->getCollectionValueType(); if (null === $type) { throw new \LogicException(sprintf('Property "%s" is an array, but its items type isn\'t specified. You can specify that by using the type `string[]` for instance or `@SWG\Property(type="array", @SWG\Items(type="string"))`.', $property->getTitle())); } - $property->setType('array'); - $property = $property->getItems(); + $property->type = 'array'; + $property = Util::getChild($property, OA\Items::class); foreach ($this->propertyDescribers as $propertyDescriber) { if ($propertyDescriber instanceof ModelRegistryAwareInterface) { diff --git a/PropertyDescriber/BooleanPropertyDescriber.php b/PropertyDescriber/BooleanPropertyDescriber.php index 6d3b4ed..4785331 100644 --- a/PropertyDescriber/BooleanPropertyDescriber.php +++ b/PropertyDescriber/BooleanPropertyDescriber.php @@ -11,14 +11,14 @@ namespace Nelmio\ApiDocBundle\PropertyDescriber; -use EXSyst\Component\Swagger\Schema; +use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\Type; class BooleanPropertyDescriber implements PropertyDescriberInterface { - public function describe(Type $type, Schema $property, array $groups = null) + public function describe(Type $type, OA\Schema $property, array $groups = null) { - $property->setType('boolean'); + $property->type = 'boolean'; } public function supports(Type $type): bool diff --git a/PropertyDescriber/DateTimePropertyDescriber.php b/PropertyDescriber/DateTimePropertyDescriber.php index 56ea985..e9a3cfb 100644 --- a/PropertyDescriber/DateTimePropertyDescriber.php +++ b/PropertyDescriber/DateTimePropertyDescriber.php @@ -11,15 +11,15 @@ namespace Nelmio\ApiDocBundle\PropertyDescriber; -use EXSyst\Component\Swagger\Schema; +use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\Type; class DateTimePropertyDescriber implements PropertyDescriberInterface { - public function describe(Type $type, Schema $property, array $groups = null) + public function describe(Type $type, OA\Schema $property, array $groups = null) { - $property->setType('string'); - $property->setFormat('date-time'); + $property->type = 'string'; + $property->format = 'date-time'; } public function supports(Type $type): bool diff --git a/PropertyDescriber/FloatPropertyDescriber.php b/PropertyDescriber/FloatPropertyDescriber.php index e87d91e..baad8c9 100644 --- a/PropertyDescriber/FloatPropertyDescriber.php +++ b/PropertyDescriber/FloatPropertyDescriber.php @@ -11,15 +11,15 @@ namespace Nelmio\ApiDocBundle\PropertyDescriber; -use EXSyst\Component\Swagger\Schema; +use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\Type; class FloatPropertyDescriber implements PropertyDescriberInterface { - public function describe(Type $type, Schema $property, array $groups = null) + public function describe(Type $type, OA\Schema $property, array $groups = null) { - $property->setType('number'); - $property->setFormat('float'); + $property->type = 'number'; + $property->format = 'float'; } public function supports(Type $type): bool diff --git a/PropertyDescriber/IntegerPropertyDescriber.php b/PropertyDescriber/IntegerPropertyDescriber.php index e66a11c..91214ec 100644 --- a/PropertyDescriber/IntegerPropertyDescriber.php +++ b/PropertyDescriber/IntegerPropertyDescriber.php @@ -11,14 +11,14 @@ namespace Nelmio\ApiDocBundle\PropertyDescriber; -use EXSyst\Component\Swagger\Schema; +use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\Type; class IntegerPropertyDescriber implements PropertyDescriberInterface { - public function describe(Type $type, Schema $property, array $groups = null) + public function describe(Type $type, OA\Schema $property, array $groups = null) { - $property->setType('integer'); + $property->type = 'integer'; } public function supports(Type $type): bool diff --git a/PropertyDescriber/ObjectPropertyDescriber.php b/PropertyDescriber/ObjectPropertyDescriber.php index cc9619e..9c6cc22 100644 --- a/PropertyDescriber/ObjectPropertyDescriber.php +++ b/PropertyDescriber/ObjectPropertyDescriber.php @@ -11,23 +11,21 @@ namespace Nelmio\ApiDocBundle\PropertyDescriber; -use EXSyst\Component\Swagger\Schema; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait; use Nelmio\ApiDocBundle\Model\Model; +use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\Type; class ObjectPropertyDescriber implements PropertyDescriberInterface, ModelRegistryAwareInterface { use ModelRegistryAwareTrait; - public function describe(Type $type, Schema $property, array $groups = null) + public function describe(Type $type, OA\Schema $property, array $groups = null) { $type = new Type($type->getBuiltinType(), false, $type->getClassName(), $type->isCollection(), $type->getCollectionKeyType(), $type->getCollectionValueType()); // ignore nullable field - $property->setRef( - $this->modelRegistry->register(new Model($type, $groups)) - ); + $property->ref = $this->modelRegistry->register(new Model($type, $groups)); } public function supports(Type $type): bool diff --git a/PropertyDescriber/PropertyDescriberInterface.php b/PropertyDescriber/PropertyDescriberInterface.php index bd92789..428634e 100644 --- a/PropertyDescriber/PropertyDescriberInterface.php +++ b/PropertyDescriber/PropertyDescriberInterface.php @@ -11,7 +11,7 @@ namespace Nelmio\ApiDocBundle\PropertyDescriber; -use EXSyst\Component\Swagger\Schema; +use OpenApi\Annotations\Schema; use Symfony\Component\PropertyInfo\Type; interface PropertyDescriberInterface diff --git a/PropertyDescriber/StringPropertyDescriber.php b/PropertyDescriber/StringPropertyDescriber.php index 2b3c9af..b88d8cd 100644 --- a/PropertyDescriber/StringPropertyDescriber.php +++ b/PropertyDescriber/StringPropertyDescriber.php @@ -11,14 +11,14 @@ namespace Nelmio\ApiDocBundle\PropertyDescriber; -use EXSyst\Component\Swagger\Schema; +use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\Type; class StringPropertyDescriber implements PropertyDescriberInterface { - public function describe(Type $type, Schema $property, array $groups = null) + public function describe(Type $type, OA\Schema $property, array $groups = null) { - $property->setType('string'); + $property->type = 'string'; } public function supports(Type $type): bool diff --git a/README.md b/README.md index d67ead1..d16d228 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ Version](https://poser.pugx.org/nelmio/api-doc-bundle/v/stable)](https://packagi The **NelmioApiDocBundle** bundle allows you to generate a decent documentation for your APIs. +## Migrate from 3.x to 4.0 + +[To migrate from 3.x to 4.0, follow our guide.](https://github.com/nelmio/NelmioApiDocBundle/blob/master/UPGRADE-4.0.md) + +Version 4.0 brings OpenAPI 3.0 support. If you want to stick to Swagger 2.0, you should use the version 3 of this bundle. + ## Migrate from 2.x to 3.0 [To migrate from 2.x to 3.0, follow our guide.](https://github.com/nelmio/NelmioApiDocBundle/blob/master/UPGRADE-3.0.md) diff --git a/Resources/config/fos_rest.xml b/Resources/config/fos_rest.xml index dbf8f2f..56125a8 100644 --- a/Resources/config/fos_rest.xml +++ b/Resources/config/fos_rest.xml @@ -6,6 +6,7 @@ + diff --git a/Resources/config/services.xml b/Resources/config/services.xml index c575d42..a511711 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -42,6 +42,7 @@ + diff --git a/Resources/doc/alternative_names.rst b/Resources/doc/alternative_names.rst index 701f904..435cfbe 100644 --- a/Resources/doc/alternative_names.rst +++ b/Resources/doc/alternative_names.rst @@ -43,7 +43,7 @@ In this case the class ``App\Entity\User`` will be aliased into: class HomeController { /** - * @SWG\Response(response=200, @SWG\Schema(ref="#/definitions/MyModel")) + * @OA\Response(response=200, @OA\JsonContent(@OA\Schema(ref="#/components/schemas/MyModel"))) */ public function indexAction() { diff --git a/Resources/doc/faq.rst b/Resources/doc/faq.rst index 9149c95..de2dd83 100644 --- a/Resources/doc/faq.rst +++ b/Resources/doc/faq.rst @@ -6,39 +6,40 @@ Sharing parameter configuration Q: I use the same value in multiple endpoints. How can I avoid duplicating the descriptions? -A: You can configure ``definitions`` in the nelmio_api_doc configuration and then reference them: +A: You can configure ``schemas`` in the nelmio_api_doc configuration and then reference them: .. code-block:: yaml # config/nelmio_api_doc.yml nelmio_api_doc: documentation: - definitions: - NelmioImageList: - description: "Response for some queries" - type: object - properties: - total: - type: integer - example: 42 - items: - type: array + components: + schemas: + NelmioImageList: + description: "Response for some queries" + type: object + properties: + total: + type: integer + example: 42 items: - $ref: "#/definitions/ImageMetadata" + type: array + items: + $ref: "#/components/schemas/ImageMetadata" .. code-block:: php // src/App/Controller/NelmioController.php /** - * @SWG\Response( + * @OA\Response( * response=200, * description="List of image definitions", - * @SWG\Schema( + * @OA\JsonContent(@OA\Schema( * type="object", * title="ListOperationsResponse", - * additionalProperties={"$ref": "#/definitions/NelmioImageList"} - * ) + * additionalProperties={"$ref": "#/components/schemas/NelmioImageList"} + * )) */ Optional Path Parameters @@ -55,7 +56,7 @@ optional? The controller might look like this:: * name="get_user_metadata" * ) * - * @SWG\Response( + * @OA\Response( * response=200, * description="Json object with all user meta data or a json string with the value of the requested field" * ) @@ -76,14 +77,13 @@ separate actions in your controller. For example:: * name="get_user_metadata" * ) * - * @SWG\Response( + * @OA\Response( * response=200, * description="Json hashmap with all user meta data", - * @SWG\Schema( + * @OA\JsonContent(@OA\Schema( * type="object", * example={"foo": "bar", "hello": "world"} - * ) - * + * )) * ) */ public function cgetAction(string $user) @@ -99,12 +99,12 @@ separate actions in your controller. For example:: * name="get_user_metadata_single" * ) * - * @SWG\Response( + * @OA\Response( * response=200, * description="A json string with the value of the requested field", - * @SWG\Schema( + * @OA\JsonContent(@OA\Schema( * type="string" - * ) + * )) * ) */ public function getAction(string $user, string $metaName = null) @@ -137,13 +137,13 @@ A: We removed the google fonts in 3.3 to avoid the external request for GDPR rea .. code-block:: twig {# templates/bundles/NelmioApiDocBundle/SwaggerUI/index.html.twig #} - + {# To avoid a "reached nested level" error an exclamation mark `!` has to be added See https://symfony.com/blog/new-in-symfony-3-4-improved-the-overriding-of-templates #} {% extends '@!NelmioApiDoc/SwaggerUi/index.html.twig' %} - + {% block stylesheets %} {{ parent() }} @@ -184,14 +184,14 @@ Endpoints grouping Q: Areas feature doesn't fit my needs. So how can I group similar endpoints of one or more controllers in a separate section in the documentation? -A: Use ``@SWG\Tag`` annotation. +A: Use ``@OA\Tag`` annotation. .. code-block:: php /** * Class BookmarkController * - * @SWG\Tag(name="Bookmarks") + * @OA\Tag(name="Bookmarks") */ class BookmarkController extends AbstractFOSRestController implements ContextPresetInterface { diff --git a/Resources/doc/index.rst b/Resources/doc/index.rst index 2c51dd5..bfea8e1 100644 --- a/Resources/doc/index.rst +++ b/Resources/doc/index.rst @@ -99,28 +99,31 @@ Using the bundle ---------------- You can configure global information in the bundle configuration ``documentation.info`` section (take a look at -`the OpenAPI 2.0 specification (formerly Swagger)`_ to know the available fields): +`the OpenAPI 3.0 specification (formerly Swagger)`_ to know the available fields): .. code-block:: yaml nelmio_api_doc: documentation: - host: api.example.com - schemes: [http, https] + servers: + - url: http://api.example.com/unsafe + description: API over HTTP + - url: https://api.example.com/secured + description: API over HTTPS info: title: My App description: This is an awesome app! version: 1.0.0 - securityDefinitions: - Bearer: - type: apiKey - description: 'Value: Bearer {jwt}' - name: Authorization - in: header + components: + securitySchemes: + Bearer: + type: http + scheme: bearer + bearerFormat: JWT security: - Bearer: [] -.. _`the OpenAPI 2.0 specification (formerly Swagger)`: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md +.. _`the OpenAPI 3.0 specification (formerly Swagger)`: https://swagger.io/docs/specification .. note:: @@ -135,7 +138,7 @@ To document your routes, you can use the SwaggerPHP annotations and the use AppBundle\Entity\Reward; use Nelmio\ApiDocBundle\Annotation\Model; use Nelmio\ApiDocBundle\Annotation\Security; - use Swagger\Annotations as SWG; + use OpenApi\Annotations as OA; use Symfony\Component\Routing\Annotation\Route; class UserController @@ -146,21 +149,23 @@ To document your routes, you can use the SwaggerPHP annotations and the * This call takes into account all confirmed awards, but not pending or refused awards. * * @Route("/api/{user}/rewards", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Returns the rewards of an user", - * @SWG\Schema( - * type="array", - * @SWG\Items(ref=@Model(type=Reward::class, groups={"full"})) + * @OA\JsonContent( + * @OA\Schema( + * type="array", + * @OA\Items(ref=@Model(type=Reward::class, groups={"full"})) + * ) * ) * ) - * @SWG\Parameter( + * @OA\Parameter( * name="order", * in="query", * type="string", * description="The field used to order rewards" * ) - * @SWG\Tag(name="rewards") + * @OA\Tag(name="rewards") * @Security(name="Bearer") */ public function fetchUserRewardsAction(User $user) @@ -186,7 +191,7 @@ This annotation has two options: * ``type`` to specify your model's type:: /** - Ā  Ā  * @SWG\Response( + Ā  Ā  * @OA\Response( Ā  Ā  * response=200, Ā  Ā  * Ā  Ā  @Model(type=User::class) Ā  Ā  * ) @@ -195,7 +200,7 @@ This annotation has two options: * ``groups`` to specify the serialization groups used to (de)serialize your model:: Ā  /** - Ā  Ā  * @SWG\Response( + Ā  Ā  * @OA\Response( Ā  Ā  * response=200, Ā  Ā  * Ā  Ā  @Model(type=User::class, groups={"non_sensitive_data"}) Ā  Ā  * ) @@ -203,23 +208,25 @@ This annotation has two options: .. tip:: - When used at the root of ``@SWG\Response`` and ``@SWG\Parameter``, ``@Model`` is automatically nested - in a ``@SWG\Schema``. + When used at the root of ``@OA\Response`` and ``@OA\Parameter``, ``@Model`` is automatically nested + in a ``@OA\Schema``. - To use ``@Model`` directly within a ``@SWG\Schema``, ``@SWG\Items`` or ``@SWG\Property``, you have to use the ``$ref`` field:: + The media type defaults to ``application/json``. + + To use ``@Model`` directly within a ``@OA\Schema``, ``@OA\Items`` or ``@OA\Property``, you have to use the ``$ref`` field:: /** - * @SWG\Response( - * @SWG\Schema(ref=@Model(type=User::class)) + * @OA\Response( + * @OA\JsonContent(ref=@Model(type=User::class)) * ) * * or * - * @SWG\Response( - * @SWG\Schema(type="object", - * @SWG\Property(property="foo", ref=@Model(type=FooClass::class)) + * @OA\Response(@OA\XmlContent( + * @OA\Schema(type="object", + * @OA\Property(property="foo", ref=@Model(type=FooClass::class)) * ) - * ) + * )) */ Symfony Form types @@ -234,9 +241,9 @@ You can customize the documentation of a form field using the ``documentation`` ], ]); -See the `OpenAPI 2.0 specification`__ to see all the available fields of the ``documentation`` option. +See the `OpenAPI 3.0 specification`__ to see all the available fields of the ``documentation`` option. -__ https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#schemaObject +__ https://swagger.io/specification/ General PHP objects @@ -264,33 +271,33 @@ General PHP objects When using the JMS serializer combined with `willdurand/Hateoas`_ (and the `BazingaHateoasBundle`_), HATEOAS metadata are automatically extracted -If you want to customize the documentation of an object's property, you can use ``@SWG\Property``:: +If you want to customize the documentation of an object's property, you can use ``@OA\Property``:: use Nelmio\ApiDocBundle\Annotation\Model; - use Swagger\Annotations as SWG; + use OpenApi\Annotations as OA; class User { /** * @var int - * @SWG\Property(description="The unique identifier of the user.") + * @OA\Property(description="The unique identifier of the user.") */ public $id; /** - * @SWG\Property(type="string", maxLength=255) + * @OA\Property(type="string", maxLength=255) */ public $username; /** - * @SWG\Property(ref=@Model(type=User::class)) + * @OA\Property(ref=@Model(type=User::class)) */ public $friend; } -See the `OpenAPI 2.0 specification`__ to see all the available fields of ``@SWG\Property``. +See the `OpenAPI 3.0 specification`__ to see all the available fields of ``@OA\Property``. -__ https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#schemaObject +__ https://swagger.io/specification/ Learn more ---------- diff --git a/RouteDescriber/FosRestDescriber.php b/RouteDescriber/FosRestDescriber.php index 209ad7e..b199df5 100644 --- a/RouteDescriber/FosRestDescriber.php +++ b/RouteDescriber/FosRestDescriber.php @@ -12,9 +12,10 @@ namespace Nelmio\ApiDocBundle\RouteDescriber; use Doctrine\Common\Annotations\Reader; -use EXSyst\Component\Swagger\Swagger; use FOS\RestBundle\Controller\Annotations\QueryParam; use FOS\RestBundle\Controller\Annotations\RequestParam; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; +use OpenApi\Annotations as OA; use Symfony\Component\Routing\Route; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Regex; @@ -23,17 +24,22 @@ final class FosRestDescriber implements RouteDescriberInterface { use RouteDescriberTrait; + /** @var Reader */ private $annotationReader; - public function __construct(Reader $annotationReader) + /** @var string[] */ + private $mediaTypes; + + public function __construct(Reader $annotationReader, array $mediaTypes) { $this->annotationReader = $annotationReader; + $this->mediaTypes = $mediaTypes; } - public function describe(Swagger $api, Route $route, \ReflectionMethod $reflectionMethod) + public function describe(OA\OpenApi $api, Route $route, \ReflectionMethod $reflectionMethod) { $annotations = $this->annotationReader->getMethodAnnotations($reflectionMethod); - $annotations = array_filter($annotations, function ($value) { + $annotations = array_filter($annotations, static function ($value) { return $value instanceof RequestParam || $value instanceof QueryParam; }); @@ -43,48 +49,32 @@ final class FosRestDescriber implements RouteDescriberInterface if ($annotation instanceof QueryParam) { $name = $parameterName.($annotation->map ? '[]' : ''); - $parameter = $operation->getParameters()->get($name, 'query'); - $parameter->setAllowEmptyValue($annotation->nullable && $annotation->allowBlank); + $parameter = Util::getOperationParameter($operation, $name, 'query'); + $parameter->allowEmptyValue = $annotation->nullable && $annotation->allowBlank; - $parameter->setRequired(!$annotation->nullable && $annotation->strict); - } else { - $body = $operation->getParameters()->get('body', 'body')->getSchema(); - $body->setType('object'); - $parameter = $body->getProperties()->get($parameterName); + $parameter->required = !$annotation->nullable && $annotation->strict; - if (!$annotation->nullable && $annotation->strict) { - $requiredParameters = $body->getRequired(); - $requiredParameters[] = $parameterName; - - $body->setRequired(array_values(array_unique($requiredParameters))); + if (OA\UNDEFINED === $parameter->description) { + $parameter->description = $annotation->description; } - } - $parameter->setDefault($annotation->getDefault()); - if (null !== $parameter->getType()) { - continue; - } + $schema = Util::getChild($parameter, OA\Schema::class); + $this->describeCommonSchemaFromAnnotation($schema, $annotation); + } else { + /** @var OA\RequestBody $requestBody */ + $requestBody = Util::getChild($operation, OA\RequestBody::class); + foreach ($this->mediaTypes as $mediaType) { + $contentSchema = $this->getContentSchemaForType($requestBody, $mediaType); + $schema = Util::getProperty($contentSchema, $parameterName); - if (null === $parameter->getDescription()) { - $parameter->setDescription($annotation->description); - } + if (!$annotation->nullable && $annotation->strict) { + $requiredParameters = is_array($contentSchema->required) ? $contentSchema->required : []; + $requiredParameters[] = $parameterName; - if ($annotation->map) { - $parameter->setType('array'); - $parameter->setCollectionFormat('multi'); - $parameter = $parameter->getItems(); - } - - $parameter->setType('string'); - - $pattern = $this->getPattern($annotation->requirements); - if (null !== $pattern) { - $parameter->setPattern($pattern); - } - - $format = $this->getFormat($annotation->requirements); - if (null !== $format) { - $parameter->setFormat($format); + $contentSchema->required = array_values(array_unique($requiredParameters)); + } + $this->describeCommonSchemaFromAnnotation($schema, $annotation); + } } } } @@ -117,4 +107,64 @@ final class FosRestDescriber implements RouteDescriberInterface return null; } + + private function getContentSchemaForType(OA\RequestBody $requestBody, string $type): OA\Schema + { + $requestBody->content = OA\UNDEFINED !== $requestBody->content ? $requestBody->content : []; + switch ($type) { + case 'json': + $contentType = 'application\json'; + + break; + case 'xml': + $contentType = 'application\xml'; + + break; + default: + throw new \InvalidArgumentException('Unsupported media type'); + } + if (!isset($requestBody->content[$contentType])) { + $requestBody->content[$contentType] = new OA\MediaType( + [ + 'mediaType' => $contentType, + ] + ); + /** @var OA\Schema $schema */ + $schema = Util::getChild( + $requestBody->content[$contentType], + OA\Schema::class + ); + $schema->type = 'object'; + } + + return Util::getChild( + $requestBody->content[$contentType], + OA\Schema::class + ); + } + + private function describeCommonSchemaFromAnnotation(OA\Schema $schema, $annotation) + { + $schema->default = $annotation->getDefault(); + + if (OA\UNDEFINED === $schema->type) { + $schema->type = $annotation->map ? 'array' : 'string'; + } + + if ($annotation->map) { + $schema->type = 'array'; + $schema->collectionFormat = 'multi'; + $schema->items = Util::getChild($schema, OA\Items::class); + } + + $pattern = $this->getPattern($annotation->requirements); + if (null !== $pattern) { + $schema->pattern = $pattern; + } + + $format = $this->getFormat($annotation->requirements); + if (null !== $format) { + $schema->format = $format; + } + } } diff --git a/RouteDescriber/PhpDocDescriber.php b/RouteDescriber/PhpDocDescriber.php index c316795..1ae2ef7 100644 --- a/RouteDescriber/PhpDocDescriber.php +++ b/RouteDescriber/PhpDocDescriber.php @@ -11,7 +11,7 @@ namespace Nelmio\ApiDocBundle\RouteDescriber; -use EXSyst\Component\Swagger\Swagger; +use OpenApi\Annotations as OA; use phpDocumentor\Reflection\DocBlockFactory; use phpDocumentor\Reflection\DocBlockFactoryInterface; use Symfony\Component\Routing\Route; @@ -30,7 +30,7 @@ final class PhpDocDescriber implements RouteDescriberInterface $this->docBlockFactory = $docBlockFactory; } - public function describe(Swagger $api, Route $route, \ReflectionMethod $reflectionMethod) + public function describe(OA\OpenApi $api, Route $route, \ReflectionMethod $reflectionMethod) { $classDocBlock = null; $docBlock = null; @@ -47,19 +47,19 @@ final class PhpDocDescriber implements RouteDescriberInterface foreach ($this->getOperations($api, $route) as $operation) { if (null !== $docBlock) { - if (null === $operation->getSummary() && '' !== $docBlock->getSummary()) { - $operation->setSummary($docBlock->getSummary()); + if (OA\UNDEFINED === $operation->summary && '' !== $docBlock->getSummary()) { + $operation->summary = $docBlock->getSummary(); } - if (null === $operation->getDescription() && '' !== (string) $docBlock->getDescription()) { - $operation->setDescription((string) $docBlock->getDescription()); + if (OA\UNDEFINED === $operation->description && '' !== (string) $docBlock->getDescription()) { + $operation->description = (string) $docBlock->getDescription(); } if ($docBlock->hasTag('deprecated')) { - $operation->setDeprecated(true); + $operation->deprecated = true; } } if (null !== $classDocBlock) { if ($classDocBlock->hasTag('deprecated')) { - $operation->setDeprecated(true); + $operation->deprecated = true; } } } diff --git a/RouteDescriber/RouteDescriberInterface.php b/RouteDescriber/RouteDescriberInterface.php index 5dcf0d4..9067c60 100644 --- a/RouteDescriber/RouteDescriberInterface.php +++ b/RouteDescriber/RouteDescriberInterface.php @@ -11,10 +11,10 @@ namespace Nelmio\ApiDocBundle\RouteDescriber; -use EXSyst\Component\Swagger\Swagger; +use OpenApi\Annotations\OpenApi; use Symfony\Component\Routing\Route; interface RouteDescriberInterface { - public function describe(Swagger $api, Route $route, \ReflectionMethod $reflectionMethod); + public function describe(OpenApi $api, Route $route, \ReflectionMethod $reflectionMethod); } diff --git a/RouteDescriber/RouteDescriberTrait.php b/RouteDescriber/RouteDescriberTrait.php index 7c62c85..eae5fbf 100644 --- a/RouteDescriber/RouteDescriberTrait.php +++ b/RouteDescriber/RouteDescriberTrait.php @@ -11,8 +11,9 @@ namespace Nelmio\ApiDocBundle\RouteDescriber; -use EXSyst\Component\Swagger\Operation; -use EXSyst\Component\Swagger\Swagger; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; +use OpenApi\Annotations as OA; +use OpenApi\Annotations\OpenApi; use Symfony\Component\Routing\Route; /** @@ -23,20 +24,20 @@ trait RouteDescriberTrait /** * @internal * - * @return Operation[] + * @return OA\Operation[] */ - private function getOperations(Swagger $api, Route $route): array + private function getOperations(OpenApi $api, Route $route): array { $operations = []; - $path = $api->getPaths()->get($this->normalizePath($route->getPath())); - $methods = $route->getMethods() ?: Swagger::$METHODS; + $path = Util::getPath($api, $this->normalizePath($route->getPath())); + $methods = $route->getMethods() ?: Util::OPERATIONS; foreach ($methods as $method) { $method = strtolower($method); - if (!in_array($method, Swagger::$METHODS)) { + if (!in_array($method, Util::OPERATIONS)) { continue; } - $operations[] = $path->getOperation($method); + $operations[] = Util::getOperation($path, $method); } return $operations; diff --git a/RouteDescriber/RouteMetadataDescriber.php b/RouteDescriber/RouteMetadataDescriber.php index a226a32..5a7baa4 100644 --- a/RouteDescriber/RouteMetadataDescriber.php +++ b/RouteDescriber/RouteMetadataDescriber.php @@ -11,10 +11,9 @@ namespace Nelmio\ApiDocBundle\RouteDescriber; -use EXSyst\Component\Swagger\Operation; -use EXSyst\Component\Swagger\Parameter; -use EXSyst\Component\Swagger\Swagger; use LogicException; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; +use OpenApi\Annotations as OA; use Symfony\Component\Routing\Route; /** @@ -24,10 +23,10 @@ final class RouteMetadataDescriber implements RouteDescriberInterface { use RouteDescriberTrait; - public function describe(Swagger $api, Route $route, \ReflectionMethod $reflectionMethod) + public function describe(OA\OpenApi $api, Route $route, \ReflectionMethod $reflectionMethod) { foreach ($this->getOperations($api, $route) as $operation) { - $operation->merge(['schemes' => $route->getSchemes()]); + Util::merge($operation, ['security' => $route->getSchemes()]); $requirements = $route->getRequirements(); $compiledRoute = $route->compile(); @@ -40,24 +39,27 @@ final class RouteMetadataDescriber implements RouteDescriberInterface } $paramId = $pathVariable.'/path'; + /** @var OA\Parameter $parameter */ $parameter = $existingParams[$paramId] ?? null; if (null !== $parameter) { - if (!$parameter->getRequired()) { + if (!$parameter->required || OA\UNDEFINED === $parameter->required) { throw new LogicException(\sprintf('Global parameter "%s" is used as part of route "%s" and must be set as "required"', $pathVariable, $route->getPath())); } continue; } - $parameter = $operation->getParameters()->get($pathVariable, 'path'); - $parameter->setRequired(true); + $parameter = Util::getOperationParameter($operation, $pathVariable, 'path'); + $parameter->required = true; - if (null === $parameter->getType()) { - $parameter->setType('string'); + $parameter->schema = Util::getChild($parameter, OA\Schema::class); + + if (OA\UNDEFINED === $parameter->schema->type) { + $parameter->schema->type = 'string'; } - if (isset($requirements[$pathVariable]) && null === $parameter->getPattern()) { - $parameter->setPattern($requirements[$pathVariable]); + if (isset($requirements[$pathVariable]) && OA\UNDEFINED === $parameter->schema->pattern) { + $parameter->schema->pattern = $requirements[$pathVariable]; } } } @@ -66,22 +68,24 @@ final class RouteMetadataDescriber implements RouteDescriberInterface /** * The '$ref' parameters need special handling, since their objects are missing 'name' and 'in'. * - * @return Parameter[] existing $ref parameters + * @return OA\Parameter[] existing $ref parameters */ - private function getRefParams(Swagger $api, Operation $operation): array + private function getRefParams(OA\OpenApi $api, OA\Operation $operation): array { - /** @var Parameter[] $globalParams */ - $globalParams = $api->getParameters(); + /** @var OA\Parameter[] $globalParams */ + $globalParams = OA\UNDEFINED !== $api->components->parameters ? $api->components->parameters : []; $existingParams = []; - foreach ($operation->getParameters() as $id => $parameter) { - $ref = $parameter->getRef(); - if (null === $ref) { + $operationParameters = OA\UNDEFINED !== $operation->parameters ? $operation->parameters : []; + /** @var OA\Parameter $parameter */ + foreach ($operationParameters as $id => $parameter) { + $ref = $parameter->ref; + if (OA\UNDEFINED === $ref) { // we only concern ourselves with '$ref' parameters, so continue the loop continue; } - $ref = \mb_substr($ref, 13); // trim the '#/parameters/' part of ref + $ref = \mb_substr($ref, 24); // trim the '#/components/parameters/' part of ref if (!isset($globalParams[$ref])) { // this shouldn't happen during proper configs, but in case of bad config, just ignore it here continue; @@ -90,7 +94,7 @@ final class RouteMetadataDescriber implements RouteDescriberInterface $refParameter = $globalParams[$ref]; // param ids are in form {name}/{in} - $existingParams[\sprintf('%s/%s', $refParameter->getName(), $refParameter->getIn())] = $refParameter; + $existingParams[\sprintf('%s/%s', $refParameter->name, $refParameter->in)] = $refParameter; } return $existingParams; diff --git a/SwaggerPhp/AddDefaults.php b/SwaggerPhp/AddDefaults.php deleted file mode 100644 index 2618d70..0000000 --- a/SwaggerPhp/AddDefaults.php +++ /dev/null @@ -1,37 +0,0 @@ -getAnnotationsOfType(Info::class)) { - return; - } - if (($annotations = $analysis->getAnnotationsOfType(Swagger::class)) && null !== $annotations[0]->info) { - return; - } - - $analysis->addAnnotation(new Info(['title' => '', 'version' => '0.0.0', '_context' => new Context(['generated' => true])]), null); - } -} diff --git a/SwaggerPhp/ModelRegister.php b/SwaggerPhp/ModelRegister.php deleted file mode 100644 index 2c9c327..0000000 --- a/SwaggerPhp/ModelRegister.php +++ /dev/null @@ -1,130 +0,0 @@ -modelRegistry = $modelRegistry; - } - - public function __invoke(Analysis $analysis, array $parentGroups = null) - { - $modelsRegistered = []; - foreach ($analysis->annotations as $annotation) { - // @Model using the ref field - if ($annotation instanceof Schema && $annotation->ref instanceof ModelAnnotation) { - $model = $annotation->ref; - - $annotation->ref = $this->modelRegistry->register(new Model($this->createType($model->type), $this->getGroups($model, $parentGroups), $model->options)); - - // It is no longer an unmerged annotation - $this->detach($model, $annotation, $analysis); - - continue; - } - - // Implicit usages - if ($annotation instanceof Response) { - $annotationClass = Schema::class; - } elseif ($annotation instanceof Parameter) { - if ('array' === $annotation->type) { - $annotationClass = Items::class; - } else { - $annotationClass = Schema::class; - } - } elseif ($annotation instanceof Schema) { - $annotationClass = Items::class; - } else { - continue; - } - - $model = null; - foreach ($annotation->_unmerged as $unmerged) { - if ($unmerged instanceof ModelAnnotation) { - $model = $unmerged; - - break; - } - } - - if (null === $model || !$model instanceof ModelAnnotation) { - continue; - } - - if (!is_string($model->type)) { - // Ignore invalid annotations, they are validated later - continue; - } - - if ($annotation instanceof Schema) { - @trigger_error(sprintf('Using `@Model` implicitly in a `@SWG\Schema`, `@SWG\Items` or `@SWG\Property` annotation in %s is deprecated since version 3.2 and won\'t be supported in 4.0. Use `ref=@Model()` instead.', $annotation->_context->getDebugLocation()), E_USER_DEPRECATED); - } - - $annotation->merge([new $annotationClass([ - 'ref' => $this->modelRegistry->register(new Model($this->createType($model->type), $this->getGroups($model, $parentGroups), $model->options)), - ])]); - - // It is no longer an unmerged annotation - $this->detach($model, $annotation, $analysis); - } - } - - private function getGroups(ModelAnnotation $model, array $parentGroups = null) - { - if (null === $model->groups) { - return $parentGroups; - } - - return array_merge($parentGroups ?? [], $model->groups); - } - - private function detach(ModelAnnotation $model, AbstractAnnotation $annotation, Analysis $analysis) - { - foreach ($annotation->_unmerged as $key => $unmerged) { - if ($unmerged === $model) { - unset($annotation->_unmerged[$key]); - - break; - } - } - $analysis->annotations->detach($model); - } - - private function createType(string $type): Type - { - if ('[]' === substr($type, -2)) { - return new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, null, $this->createType(substr($type, 0, -2))); - } - - return new Type(Type::BUILTIN_TYPE_OBJECT, false, $type); - } -} diff --git a/Tests/ApiDocGeneratorTest.php b/Tests/ApiDocGeneratorTest.php index 8eac565..77772a4 100644 --- a/Tests/ApiDocGeneratorTest.php +++ b/Tests/ApiDocGeneratorTest.php @@ -23,7 +23,7 @@ class ApiDocGeneratorTest extends TestCase $adapter = new ArrayAdapter(); $generator = new ApiDocGenerator([new DefaultDescriber()], [], $adapter); - $this->assertEquals($generator->generate(), $adapter->getItem('swagger_doc')->get()); + $this->assertEquals($generator->generate(), $adapter->getItem('openapi_doc')->get()); } public function testCacheWithCustomId() diff --git a/Tests/Describer/AbstractDescriberTest.php b/Tests/Describer/AbstractDescriberTest.php index f73dcb3..8709042 100644 --- a/Tests/Describer/AbstractDescriberTest.php +++ b/Tests/Describer/AbstractDescriberTest.php @@ -11,8 +11,8 @@ namespace Nelmio\ApiDocBundle\Tests\Describer; -use EXSyst\Component\Swagger\Swagger; use Nelmio\ApiDocBundle\Describer\DescriberInterface; +use OpenApi\Annotations\OpenApi; use PHPUnit\Framework\TestCase; abstract class AbstractDescriberTest extends TestCase @@ -20,9 +20,9 @@ abstract class AbstractDescriberTest extends TestCase /** @var DescriberInterface */ protected $describer; - protected function getSwaggerDoc(): Swagger + protected function getOpenApiDoc(): OpenApi { - $api = new Swagger(); + $api = new OpenApi([]); $this->describer->describe($api); return $api; diff --git a/Tests/Describer/ApiPlatformDescriberTest.php b/Tests/Describer/ApiPlatformDescriberTest.php index 5e83a03..73b3c30 100644 --- a/Tests/Describer/ApiPlatformDescriberTest.php +++ b/Tests/Describer/ApiPlatformDescriberTest.php @@ -13,8 +13,8 @@ namespace Nelmio\ApiDocBundle\Tests\Describer; use ApiPlatform\Core\Documentation\Documentation; use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; -use EXSyst\Component\Swagger\Swagger; use Nelmio\ApiDocBundle\Describer\ApiPlatformDescriber; +use OpenApi\Annotations\OpenApi; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; class ApiPlatformDescriberTest extends AbstractDescriberTest @@ -30,8 +30,8 @@ class ApiPlatformDescriberTest extends AbstractDescriberTest ->with($this->documentation) ->willReturn(['info' => ['title' => 'My Test App']]); - $expectedApi = new Swagger(['info' => ['title' => 'My Test App']]); - $this->assertEquals($expectedApi->toArray(), $this->getSwaggerDoc()->toArray()); + $expectedApi = new OpenApi(['info' => ['title' => 'My Test App']]); + $this->assertEquals($expectedApi->toJson(), $this->getOpenApiDoc()->toJson()); } public function testDescribeRemovesBasePathAfterNormalization() @@ -41,8 +41,8 @@ class ApiPlatformDescriberTest extends AbstractDescriberTest ->with($this->documentation) ->willReturn(['info' => ['title' => 'My Test App'], 'basePath' => '/foo']); - $expectedApi = new Swagger(['info' => ['title' => 'My Test App']]); - $this->assertEquals($expectedApi->toArray(), $this->getSwaggerDoc()->toArray()); + $expectedApi = new OpenApi(['info' => ['title' => 'My Test App']]); + $this->assertEquals($expectedApi->toJson(), $this->getOpenApiDoc()->toJson()); } protected function setUp() diff --git a/Tests/Describer/RouteDescriberTest.php b/Tests/Describer/RouteDescriberTest.php index b21baaf..dff1ba4 100644 --- a/Tests/Describer/RouteDescriberTest.php +++ b/Tests/Describer/RouteDescriberTest.php @@ -11,10 +11,10 @@ namespace Nelmio\ApiDocBundle\Tests\Describer; -use EXSyst\Component\Swagger\Swagger; use Nelmio\ApiDocBundle\Describer\RouteDescriber; use Nelmio\ApiDocBundle\RouteDescriber\RouteDescriberInterface; use Nelmio\ApiDocBundle\Util\ControllerReflector; +use OpenApi\Annotations\OpenApi; use Symfony\Bundle\FrameworkBundle\Controller\ControllerNameParser; use Symfony\Component\DependencyInjection\Container; use Symfony\Component\Routing\Route; @@ -32,7 +32,7 @@ class RouteDescriberTest extends AbstractDescriberTest $this->routeDescriber->expects($this->never()) ->method('describe'); - $this->assertEquals((new Swagger())->toArray(), $this->getSwaggerDoc()->toArray()); + $this->assertEquals((new OpenApi([]))->toJson(), $this->getOpenApiDoc()->toJson()); } protected function setUp() diff --git a/Tests/Functional/BazingaFunctionalTest.php b/Tests/Functional/BazingaFunctionalTest.php index beab5fd..918be18 100644 --- a/Tests/Functional/BazingaFunctionalTest.php +++ b/Tests/Functional/BazingaFunctionalTest.php @@ -72,7 +72,8 @@ class BazingaFunctionalTest extends WebTestCase ], ], ], - ], $this->getModel('BazingaUser')->toArray()); + 'schema' => 'BazingaUser', + ], json_decode($this->getModel('BazingaUser')->toJson(), true)); } public function testWithGroup() @@ -89,7 +90,8 @@ class BazingaFunctionalTest extends WebTestCase ], ], ], - ], $this->getModel('BazingaUser_grouped')->toArray()); + 'schema' => 'BazingaUser_grouped', + ], json_decode($this->getModel('BazingaUser_grouped')->toJson(), true)); } public function testWithType() @@ -107,7 +109,7 @@ class BazingaFunctionalTest extends WebTestCase 'properties' => [ 'typed_bazinga_users' => [ 'items' => [ - '$ref' => '#/definitions/BazingaUser', + '$ref' => '#/components/schemas/BazingaUser', ], 'type' => 'array', ], @@ -117,7 +119,8 @@ class BazingaFunctionalTest extends WebTestCase ], ], ], - ], $this->getModel('BazingaUserTyped')->toArray()); + 'schema' => 'BazingaUserTyped', + ], json_decode($this->getModel('BazingaUserTyped')->toJson(), true)); } protected static function createKernel(array $options = []) diff --git a/Tests/Functional/Controller/ApiController.php b/Tests/Functional/Controller/ApiController.php index 985209b..059d7cb 100644 --- a/Tests/Functional/Controller/ApiController.php +++ b/Tests/Functional/Controller/ApiController.php @@ -20,7 +20,7 @@ use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyConstraints; use Nelmio\ApiDocBundle\Tests\Functional\Entity\User; use Nelmio\ApiDocBundle\Tests\Functional\Form\DummyType; use Nelmio\ApiDocBundle\Tests\Functional\Form\UserType; -use Swagger\Annotations as SWG; +use OpenApi\Annotations as OA; use Symfony\Component\Routing\Annotation\Route; /** @@ -29,12 +29,12 @@ use Symfony\Component\Routing\Annotation\Route; class ApiController { /** - * @SWG\Response( - * response="200", - * description="Success", - * @SWG\Schema(ref=@Model(type=Article::class, groups={"light"})) + * @OA\Response( + * response="200", + * description="Success", + * @Model(type=Article::class, groups={"light"})) * ) - * @SWG\Parameter(ref="#/parameters/test") + * @OA\Parameter(ref="#/components/parameters/test") * @Route("/article/{id}", methods={"GET"}) */ public function fetchArticleAction() @@ -47,7 +47,7 @@ class ApiController * @Route("/swagger", methods={"GET", "LINK"}) * @Route("/swagger2", methods={"GET"}) * @Operation( - * @SWG\Response(response="201", description="An example resource") + * @OA\Response(response="201", description="An example resource") * ) */ public function swaggerAction() @@ -56,21 +56,19 @@ class ApiController /** * @Route("/swagger/implicit", methods={"GET", "POST"}) - * @SWG\Response( - * response="201", - * description="Operation automatically detected", - * @Model(type=User::class) + * @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)) + * ) * ) - * @SWG\Parameter( - * name="foo", - * in="body", - * description="This is a parameter", - * @SWG\Schema( - * type="array", - * @SWG\Items(ref=@Model(type=User::class)) - * ) - * ) - * @SWG\Tag(name="implicit") + * @OA\Tag(name="implicit") */ public function implicitSwaggerAction() { @@ -78,16 +76,14 @@ class ApiController /** * @Route("/test/users/{user}", methods={"POST"}, schemes={"https"}, requirements={"user"="/foo/"}) - * @SWG\Response( - * response="201", - * description="Operation automatically detected", - * @Model(type=User::class) - * ) - * @SWG\Parameter( - * name="foo", - * in="body", - * description="This is a parameter", - * @SWG\Schema(ref=@Model(type=UserType::class, options={"bar": "baz"})) + * @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() @@ -96,9 +92,7 @@ class ApiController /** * @Route("/test/{user}", methods={"GET"}, schemes={"https"}, requirements={"user"="/foo/"}) - * @Operation( - * @SWG\Response(response=200, description="sucessful") - * ) + * @OA\Response(response=200, description="sucessful") */ public function userAction() { @@ -127,9 +121,9 @@ class ApiController } /** - * @SWG\Get( + * @OA\Get( * path="/filtered", - * @SWG\Response(response="201", description="") + * @OA\Response(response="201", description="") * ) */ public function filteredAction() @@ -138,13 +132,11 @@ class ApiController /** * @Route("/form", methods={"POST"}) - * @SWG\Parameter( - * name="form", - * in="body", - * description="Request content", - * @SWG\Schema(ref=@Model(type=DummyType::class)) + * @OA\RequestBody( + * description="Request content", + * @Model(type=DummyType::class)) * ) - * @SWG\Response(response="201", description="") + * @OA\Response(response="201", description="") */ public function formAction() { @@ -152,7 +144,7 @@ class ApiController /** * @Route("/security") - * @SWG\Response(response="201", description="") + * @OA\Response(response="201", description="") * @Security(name="api_key") * @Security(name="basic") */ @@ -162,10 +154,10 @@ class ApiController /** * @Route("/swagger/symfonyConstraints", methods={"GET"}) - * @SWG\Response( - * response="201", - * description="Used for symfony constraints test", - * @SWG\Schema(ref=@Model(type=SymfonyConstraints::class)) + * @OA\Response( + * response="201", + * description="Used for symfony constraints test", + * @Model(type=SymfonyConstraints::class) * ) */ public function symfonyConstraintsAction() @@ -173,15 +165,15 @@ class ApiController } /** - * @SWG\Response( + * @OA\Response( * response="200", * description="Success", - * @SWG\Schema(ref="#/definitions/Test") - * ) - * @SWG\Response( + * ref="#/components/schemas/Test" + * ), + * @OA\Response( * response="201", - * ref="#/responses/201" - * ) + * ref="#/components/responses/201" + * ) * @Route("/configReference", methods={"GET"}) */ public function configReferenceAction() @@ -190,10 +182,10 @@ class ApiController /** * @Route("/multi-annotations", methods={"GET", "POST"}) - * @SWG\Get(description="This is the get operation") - * @SWG\Post(description="This is post") + * @OA\Get(description="This is the get operation") + * @OA\Post(description="This is post") * - * @SWG\Response(response=200, description="Worked well!", @Model(type=DummyType::class)) + * @OA\Response(response=200, description="Worked well!", @Model(type=DummyType::class)) */ public function operationsWithOtherAnnotations() { diff --git a/Tests/Functional/Controller/BazingaController.php b/Tests/Functional/Controller/BazingaController.php index e8b238d..f8bb7d6 100644 --- a/Tests/Functional/Controller/BazingaController.php +++ b/Tests/Functional/Controller/BazingaController.php @@ -13,7 +13,7 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Controller; use Nelmio\ApiDocBundle\Annotation\Model; use Nelmio\ApiDocBundle\Tests\Functional\Entity\BazingaUser; -use Swagger\Annotations as SWG; +use OpenApi\Annotations as OA; use Symfony\Component\Routing\Annotation\Route; /** @@ -23,7 +23,7 @@ class BazingaController { /** * @Route("/api/bazinga", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Success", * @Model(type=BazingaUser::class) @@ -35,7 +35,7 @@ class BazingaController /** * @Route("/api/bazinga_foo", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Success", * @Model(type=BazingaUser::class, groups={"foo"}) diff --git a/Tests/Functional/Controller/BazingaTypedController.php b/Tests/Functional/Controller/BazingaTypedController.php index a1f4e9d..ad72687 100644 --- a/Tests/Functional/Controller/BazingaTypedController.php +++ b/Tests/Functional/Controller/BazingaTypedController.php @@ -13,7 +13,7 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Controller; use Nelmio\ApiDocBundle\Annotation\Model; use Nelmio\ApiDocBundle\Tests\Functional\EntityExcluded\BazingaUserTyped; -use Swagger\Annotations as SWG; +use OpenApi\Annotations as OA; use Symfony\Component\Routing\Annotation\Route; /** @@ -23,7 +23,7 @@ class BazingaTypedController { /** * @Route("/api/bazinga_typed", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Success", * @Model(type=BazingaUserTyped::class) diff --git a/Tests/Functional/Controller/ClassApiController.php b/Tests/Functional/Controller/ClassApiController.php index 94df74d..21bbd0e 100644 --- a/Tests/Functional/Controller/ClassApiController.php +++ b/Tests/Functional/Controller/ClassApiController.php @@ -12,7 +12,7 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Controller; use Nelmio\ApiDocBundle\Annotation\Security; -use Swagger\Annotations as SWG; +use OpenApi\Annotations as OA; use Symfony\Component\Routing\Annotation\Route; /** @@ -23,7 +23,7 @@ class ClassApiController { /** * @Route("/security/class") - * @SWG\Response(response="201", description="") + * @OA\Response(response="201", description="") */ public function securityAction() { diff --git a/Tests/Functional/Controller/JMSController.php b/Tests/Functional/Controller/JMSController.php index 2370614..392eace 100644 --- a/Tests/Functional/Controller/JMSController.php +++ b/Tests/Functional/Controller/JMSController.php @@ -21,7 +21,7 @@ use Nelmio\ApiDocBundle\Tests\Functional\Entity\NestedGroup\JMSChatRoomUser; use Nelmio\ApiDocBundle\Tests\Functional\Entity\NestedGroup\JMSChatUser; use Nelmio\ApiDocBundle\Tests\Functional\Entity\NestedGroup\JMSPicture; use Nelmio\ApiDocBundle\Tests\Functional\Entity\VirtualProperty; -use Swagger\Annotations as SWG; +use OpenApi\Annotations as OA; use Symfony\Component\Routing\Annotation\Route; /** @@ -31,7 +31,7 @@ class JMSController { /** * @Route("/api/jms", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Success", * @Model(type=JMSUser::class) @@ -43,7 +43,7 @@ class JMSController /** * @Route("/api/yaml", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Success", * @Model(type=VirtualProperty::class) @@ -55,7 +55,7 @@ class JMSController /** * @Route("/api/jms_complex", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Success", * @Model(type=JMSComplex::class, groups={"list", "details", "User" : {"list"}}) @@ -67,7 +67,7 @@ class JMSController /** * @Route("/api/jms_complex_dual", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Success", * @Model(type=JMSDualComplex::class, groups={"Default", "complex" : {"User" : {"details"}}}) @@ -79,7 +79,7 @@ class JMSController /** * @Route("/api/jms_naming_strategy", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Success", * @Model(type=JMSNamingStrategyConstraints::class, groups={"Default"}) @@ -91,7 +91,7 @@ class JMSController /** * @Route("/api/jms_chat", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Success", * @Model(type=JMSChat::class, groups={"Default", "members" : {"mini"}}) @@ -103,7 +103,7 @@ class JMSController /** * @Route("/api/jms_picture", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Success", * @Model(type=JMSPicture::class, groups={"mini"}) @@ -115,7 +115,7 @@ class JMSController /** * @Route("/api/jms_mini_user", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Success", * @Model(type=JMSChatUser::class, groups={"mini"}) @@ -127,7 +127,7 @@ class JMSController /** * @Route("/api/jms_mini_user_nested", methods={"GET"}) - * @SWG\Response( + * @OA\Response( * response=200, * description="Success", * @Model(type=JMSChatRoomUser::class, groups={"mini", "friend": {"living":{"Default"}}}) diff --git a/Tests/Functional/Controller/TestController.php b/Tests/Functional/Controller/TestController.php index 8b2aaba..f1c1397 100644 --- a/Tests/Functional/Controller/TestController.php +++ b/Tests/Functional/Controller/TestController.php @@ -11,7 +11,7 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Controller; -use Swagger\Annotations as SWG; +use OpenApi\Annotations as OA; use Symfony\Component\Routing\Annotation\Route; /** @@ -20,7 +20,7 @@ use Symfony\Component\Routing\Annotation\Route; class TestController { /** - * @SWG\Response( + * @OA\Response( * response="200", * description="Test" * ) @@ -31,8 +31,8 @@ class TestController } /** - * @SWG\Parameter(ref="#/parameters/test"), - * @SWG\Response( + * @OA\Parameter(ref="#/components/parameters/test"), + * @OA\Response( * response="200", * description="Test Ref" * ) diff --git a/Tests/Functional/Entity/JMSComplex.php b/Tests/Functional/Entity/JMSComplex.php index 66f7e62..7d3f80a 100644 --- a/Tests/Functional/Entity/JMSComplex.php +++ b/Tests/Functional/Entity/JMSComplex.php @@ -13,13 +13,13 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Entity; use JMS\Serializer\Annotation as Serializer; use Nelmio\ApiDocBundle\Annotation\Model; -use Swagger\Annotations as SWG; +use OpenApi\Annotations as OA; /** * @Serializer\ExclusionPolicy("all") - * @SWG\Definition( + * @OA\Schema( * required={"id", "user"}, - * @SWG\Property(property="virtual", ref=@Model(type=JMSUser::class)) + * @OA\Property(property="virtual", ref=@Model(type=JMSUser::class)) * ) */ class JMSComplex @@ -32,7 +32,7 @@ class JMSComplex private $id; /** - * @SWG\Property(ref=@Model(type=JMSUser::class)) + * @OA\Property(ref=@Model(type=JMSUser::class)) * @Serializer\Expose * @Serializer\Groups({"details"}) * @Serializer\SerializedName("user") diff --git a/Tests/Functional/Entity/JMSDualComplex.php b/Tests/Functional/Entity/JMSDualComplex.php index 4fa72db..ea41a9c 100644 --- a/Tests/Functional/Entity/JMSDualComplex.php +++ b/Tests/Functional/Entity/JMSDualComplex.php @@ -13,7 +13,7 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Entity; use JMS\Serializer\Annotation as Serializer; use Nelmio\ApiDocBundle\Annotation\Model; -use Swagger\Annotations as SWG; +use OpenApi\Annotations as OA; class JMSDualComplex { @@ -23,12 +23,12 @@ class JMSDualComplex private $id; /** - * @SWG\Property(ref=@Model(type=JMSComplex::class)) + * @OA\Property(ref=@Model(type=JMSComplex::class)) */ private $complex; /** - * @SWG\Property(ref=@Model(type=JMSUser::class)) + * @OA\Property(ref=@Model(type=JMSUser::class)) */ private $user; } diff --git a/Tests/Functional/Entity/JMSUser.php b/Tests/Functional/Entity/JMSUser.php index 0c21218..442a9ee 100644 --- a/Tests/Functional/Entity/JMSUser.php +++ b/Tests/Functional/Entity/JMSUser.php @@ -12,7 +12,7 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Entity; use JMS\Serializer\Annotation as Serializer; -use Swagger\Annotations as SWG; +use OpenApi\Annotations as OA; /** * User. @@ -26,7 +26,7 @@ class JMSUser * @Serializer\Expose * @Serializer\Groups({"list"}) * - * @SWG\Property(description = "User id", readOnly = true, title = "userid", example=1, default = null) + * @OA\Property(description = "User id", readOnly = true, title = "userid", example=1, default = null) */ private $id; @@ -35,14 +35,14 @@ class JMSUser * @Serializer\Expose * @Serializer\SerializedName("daysOnline") * - * @SWG\Property(default = 0, minimum = 1, maximum = 300) + * @OA\Property(default = 0, minimum = 1, maximum = 300) */ private $daysOnline; /** * @Serializer\Type("string") * @Serializer\Expose - * @SWG\Property(readOnly = false) + * @OA\Property(readOnly = false) * @Serializer\Groups({"details"}) */ private $email; @@ -52,7 +52,7 @@ class JMSUser * @Serializer\Accessor(getter="getRoles", setter="setRoles") * @Serializer\Expose * - * @SWG\Property(default = {"user"}, description = "Roles list", example="[""ADMIN"",""SUPERUSER""]", title="roles") + * @OA\Property(default = {"user"}, description = "Roles list", example="[""ADMIN"",""SUPERUSER""]", title="roles") */ private $roles; @@ -62,7 +62,7 @@ class JMSUser private $password; /** - * @SWG\Property(property="last_update", type="date") + * @OA\Property(property="last_update", type="date") * @Serializer\Expose */ private $updatedAt; @@ -103,7 +103,7 @@ class JMSUser * @Serializer\Expose * @Serializer\SerializedName("friendsNumber") * - * @SWG\Property(type = "string", minLength = 1, maxLength = 100) + * @OA\Property(type = "string", minLength = 1, maxLength = 100) */ private $friendsNumber; @@ -122,7 +122,7 @@ class JMSUser * @Serializer\Type("string") * @Serializer\Expose * - * @SWG\Property(enum = {"disabled", "enabled"}) + * @OA\Property(enum = {"disabled", "enabled"}) */ private $status; diff --git a/Tests/Functional/Entity/User.php b/Tests/Functional/Entity/User.php index 6a1469b..e0d5bb7 100644 --- a/Tests/Functional/Entity/User.php +++ b/Tests/Functional/Entity/User.php @@ -11,7 +11,7 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Entity; -use Swagger\Annotations as SWG; +use OpenApi\Annotations as OA; /** * @author Guilhem N. @@ -21,19 +21,19 @@ class User /** * @var int * - * @SWG\Property(description = "User id", readOnly = true, title = "userid", example=1, default = null) + * @OA\Property(description = "User id", readOnly = true, title = "userid", example=1, default = null) */ private $id; /** - * @SWG\Property(type="string", readOnly = false) + * @OA\Property(type="string", readOnly = false) */ private $email; /** * @var string[] * - * @SWG\Property( + * @OA\Property( * description = "User roles", * title = "roles", * example="[""ADMIN"",""SUPERUSER""]", @@ -45,19 +45,19 @@ class User /** * @var int * - * @SWG\Property(type = "string") + * @OA\Property(type = "string") */ private $friendsNumber; /** * @var float - * @SWG\Property(default = 0.0) + * @OA\Property(default = 0.0) */ private $money; /** * @var \DateTime - * @SWG\Property(property="creationDate") + * @OA\Property(property="creationDate") */ private $createdAt; @@ -74,7 +74,7 @@ class User /** * @var string * - * @SWG\Property(enum = {"disabled", "enabled"}) + * @OA\Property(enum = {"disabled", "enabled"}) */ private $status; diff --git a/Tests/Functional/FOSRestTest.php b/Tests/Functional/FOSRestTest.php index 8b58634..2a04b2c 100644 --- a/Tests/Functional/FOSRestTest.php +++ b/Tests/Functional/FOSRestTest.php @@ -12,6 +12,7 @@ namespace Nelmio\ApiDocBundle\Tests\Functional; use FOS\RestBundle\FOSRestBundle; +use OpenApi\Annotations as OA; class FOSRestTest extends WebTestCase { @@ -30,30 +31,28 @@ class FOSRestTest extends WebTestCase $operation = $this->getOperation('/api/fosrest', 'post'); - $parameters = $operation->getParameters(); - $this->assertTrue($parameters->has('foo', 'query')); - $this->assertTrue($parameters->has('body', 'body')); - $body = $parameters->get('body', 'body')->getSchema()->getProperties(); + $this->assertHasParameter('foo', 'query', $operation); + $this->assertInstanceOf(OA\RequestBody::class, $operation->requestBody); - $this->assertTrue($body->has('bar')); - $this->assertTrue($body->has('baz')); + $bodySchema = $operation->requestBody->content['application\json']->schema; - $fooParameter = $parameters->get('foo', 'query'); - $this->assertNotNull($fooParameter->getPattern()); - $this->assertEquals('\d+', $fooParameter->getPattern()); - $this->assertNull($fooParameter->getFormat()); + $this->assertHasProperty('bar', $bodySchema); + $this->assertHasProperty('baz', $bodySchema); - $barParameter = $body->get('bar'); - $this->assertNotNull($barParameter->getPattern()); - $this->assertEquals('\d+', $barParameter->getPattern()); - $this->assertNull($barParameter->getFormat()); + $fooParameter = $this->getParameter($operation, 'foo', 'query'); + $this->assertInstanceOf(OA\Schema::class, $fooParameter->schema); + $this->assertEquals('\d+', $fooParameter->schema->pattern); + $this->assertEquals(OA\UNDEFINED, $fooParameter->schema->format); - $bazParameter = $body->get('baz'); - $this->assertNotNull($bazParameter->getFormat()); - $this->assertEquals('IsTrue', $bazParameter->getFormat()); - $this->assertNull($bazParameter->getPattern()); + $barProperty = $this->getProperty($bodySchema, 'bar'); + $this->assertEquals('\d+', $barProperty->pattern); + $this->assertEquals(OA\UNDEFINED, $barProperty->format); + + $bazProperty = $this->getProperty($bodySchema, 'baz'); + $this->assertEquals(OA\UNDEFINED, $bazProperty->pattern); + $this->assertEquals('IsTrue', $bazProperty->format); // The _format path attribute should be removed - $this->assertFalse($parameters->has('_format', 'path')); + $this->assertNotHasParameter('_format', 'path', $operation); } } diff --git a/Tests/Functional/FunctionalTest.php b/Tests/Functional/FunctionalTest.php index 138aaa7..263fd50 100644 --- a/Tests/Functional/FunctionalTest.php +++ b/Tests/Functional/FunctionalTest.php @@ -11,7 +11,8 @@ namespace Nelmio\ApiDocBundle\Tests\Functional; -use EXSyst\Component\Swagger\Tag; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; +use OpenApi\Annotations as OA; class FunctionalTest extends WebTestCase { @@ -24,39 +25,39 @@ class FunctionalTest extends WebTestCase public function testConfiguredDocumentation() { - $this->assertEquals('My Default App', $this->getSwaggerDefinition()->getInfo()->getTitle()); - $this->assertEquals('My Test App', $this->getSwaggerDefinition('test')->getInfo()->getTitle()); + $this->assertEquals('My Default App', $this->getOpenApiDefinition()->info->title); + $this->assertEquals('My Test App', $this->getOpenApiDefinition('test')->info->title); } public function testUndocumentedAction() { - $paths = $this->getSwaggerDefinition()->getPaths(); - $this->assertFalse($paths->has('/undocumented')); - $this->assertFalse($paths->has('/api/admin')); + $api = $this->getOpenApiDefinition(); + + $this->assertNotHasPath('/undocumented', $api); + $this->assertNotHasPath('/api/admin', $api); } public function testFetchArticleAction() { $operation = $this->getOperation('/api/article/{id}', 'get'); - $responses = $operation->getResponses(); - $this->assertTrue($responses->has('200')); - $this->assertEquals('#/definitions/Article', $responses->get('200')->getSchema()->getRef()); + $this->assertHasResponse('200', $operation); + $response = $this->getOperationResponse($operation, '200'); + $this->assertEquals('#/components/schemas/Article', $response->content['application/json']->schema->ref); // Ensure that groups are supported - $modelProperties = $this->getModel('Article')->getProperties(); - $this->assertCount(1, $modelProperties); - $this->assertTrue($modelProperties->has('author')); - $this->assertSame('#/definitions/User2', $modelProperties->get('author')->getRef()); - - $this->assertFalse($modelProperties->has('content')); + $articleModel = $this->getModel('Article'); + $this->assertCount(1, $articleModel->properties); + $this->assertHasProperty('author', $articleModel); + $this->assertSame('#/components/schemas/User2', Util::getProperty($articleModel, 'author')->ref); + $this->assertNotHasProperty('author', Util::getProperty($articleModel, 'author')); } public function testFilteredAction() { - $paths = $this->getSwaggerDefinition()->getPaths(); + $openApi = $this->getOpenApiDefinition(); - $this->assertFalse($paths->has('/filtered')); + $this->assertNotHasPath('/filtered', $openApi); } /** @@ -64,13 +65,13 @@ class FunctionalTest extends WebTestCase * * @dataProvider swaggerActionPathsProvider */ - public function testSwaggerAction($path) + public function testSwaggerAction(string $path) { $operation = $this->getOperation($path, 'get'); - $responses = $operation->getResponses(); - $this->assertTrue($responses->has('201')); - $this->assertEquals('An example resource', $responses->get('201')->getDescription()); + $this->assertHasResponse('201', $operation); + $response = $this->getOperationResponse($operation, '201'); + $this->assertEquals('An example resource', $response->description); } public function swaggerActionPathsProvider() @@ -81,24 +82,22 @@ class FunctionalTest extends WebTestCase /** * @dataProvider implicitSwaggerActionMethodsProvider */ - public function testImplicitSwaggerAction($method) + public function testImplicitSwaggerAction(string $method) { $operation = $this->getOperation('/api/swagger/implicit', $method); - $this->assertEquals([new Tag('implicit')], $operation->getTags()); + $this->assertEquals(['implicit'], $operation->tags); - $responses = $operation->getResponses(); - $this->assertTrue($responses->has('201')); - $response = $responses->get('201'); - $this->assertEquals('Operation automatically detected', $response->getDescription()); - $this->assertEquals('#/definitions/User', $response->getSchema()->getRef()); + $this->assertHasResponse('201', $operation); + $response = $this->getOperationResponse($operation, '201'); + $this->assertEquals('Operation automatically detected', $response->description); + $this->assertEquals('#/components/schemas/User', $response->content['application/json']->schema->ref); - $parameters = $operation->getParameters(); - $this->assertTrue($parameters->has('foo', 'body')); - $parameter = $parameters->get('foo', 'body'); - - $this->assertEquals('This is a parameter', $parameter->getDescription()); - $this->assertEquals('#/definitions/User', $parameter->getSchema()->getItems()->getRef()); + $this->assertInstanceOf(OA\RequestBody::class, $operation->requestBody); + $requestBody = $operation->requestBody; + $this->assertEquals('This is a request body', $requestBody->description); + $this->assertEquals('array', $requestBody->content['application/json']->schema->type); + $this->assertEquals('#/components/schemas/User', $requestBody->content['application/json']->schema->items->ref); } public function implicitSwaggerActionMethodsProvider() @@ -110,29 +109,27 @@ class FunctionalTest extends WebTestCase { $operation = $this->getOperation('/api/test/{user}', 'get'); - $this->assertEquals(['https'], $operation->getSchemes()); - $this->assertEmpty($operation->getSummary()); - $this->assertEmpty($operation->getDescription()); - $this->assertNull($operation->getDeprecated()); - $this->assertTrue($operation->getResponses()->has(200)); + $this->assertEquals(['https'], $operation->security); + $this->assertEquals(OA\UNDEFINED, $operation->summary); + $this->assertEquals(OA\UNDEFINED, $operation->description); + $this->assertEquals(OA\UNDEFINED, $operation->deprecated); + $this->assertHasResponse(200, $operation); - $parameters = $operation->getParameters(); - $this->assertTrue($parameters->has('user', 'path')); - - $parameter = $parameters->get('user', 'path'); - $this->assertTrue($parameter->getRequired()); - $this->assertEquals('string', $parameter->getType()); - $this->assertEquals('/foo/', $parameter->getPattern()); - $this->assertEmpty($parameter->getFormat()); + $this->assertHasParameter('user', 'path', $operation); + $parameter = Util::getOperationParameter($operation, 'user', 'path'); + $this->assertTrue($parameter->required); + $this->assertEquals('string', $parameter->schema->type); + $this->assertEquals('/foo/', $parameter->schema->pattern); + $this->assertEquals(OA\UNDEFINED, $parameter->schema->format); } public function testDeprecatedAction() { $operation = $this->getOperation('/api/deprecated', 'get'); - $this->assertEquals('This action is deprecated.', $operation->getSummary()); - $this->assertEquals('Please do not use this action.', $operation->getDescription()); - $this->assertTrue($operation->getDeprecated()); + $this->assertEquals('This action is deprecated.', $operation->summary); + $this->assertEquals('Please do not use this action.', $operation->description); + $this->assertTrue($operation->deprecated); } public function testApiPlatform() @@ -160,6 +157,7 @@ class FunctionalTest extends WebTestCase 'readOnly' => true, 'title' => 'userid', 'example' => 1, + 'default' => null, ], 'email' => [ 'type' => 'string', @@ -182,15 +180,15 @@ class FunctionalTest extends WebTestCase ], 'users' => [ 'items' => [ - '$ref' => '#/definitions/User', + '$ref' => '#/components/schemas/User', ], 'type' => 'array', ], 'friend' => [ - '$ref' => '#/definitions/User', + '$ref' => '#/components/schemas/User', ], 'dummy' => [ - '$ref' => '#/definitions/Dummy2', + '$ref' => '#/components/schemas/Dummy2', ], 'status' => [ 'type' => 'string', @@ -201,8 +199,9 @@ class FunctionalTest extends WebTestCase 'format' => 'date-time', ], ], + 'schema' => 'User', ], - $this->getModel('User')->toArray() + json_decode($this->getModel('User')->toJson(), true) ); } @@ -215,13 +214,13 @@ class FunctionalTest extends WebTestCase 'items' => ['type' => 'string'], 'type' => 'array', ], - 'dummy' => ['$ref' => '#/definitions/DummyType'], + 'dummy' => ['$ref' => '#/components/schemas/DummyType'], 'dummies' => [ - 'items' => ['$ref' => '#/definitions/DummyType'], + 'items' => ['$ref' => '#/components/schemas/DummyType'], 'type' => 'array', ], 'empty_dummies' => [ - 'items' => ['$ref' => '#/definitions/DummyEmptyType'], + 'items' => ['$ref' => '#/components/schemas/DummyEmptyType'], 'type' => 'array', ], 'quz' => [ @@ -254,7 +253,8 @@ class FunctionalTest extends WebTestCase ], ], 'required' => ['dummy', 'dummies', 'entity', 'entities', 'document', 'documents', 'extended_builtin'], - ], $this->getModel('UserType')->toArray()); + 'schema' => 'UserType', + ], json_decode($this->getModel('UserType')->toJson(), true)); $this->assertEquals([ 'type' => 'object', @@ -299,7 +299,8 @@ class FunctionalTest extends WebTestCase ], ], 'required' => ['foo', 'foz', 'password'], - ], $this->getModel('DummyType')->toArray()); + 'schema' => 'DummyType', + ], json_decode($this->getModel('DummyType')->toJson(), true)); } public function testSecurityAction() @@ -310,7 +311,7 @@ class FunctionalTest extends WebTestCase ['api_key' => []], ['basic' => []], ]; - $this->assertEquals($expected, $operation->getSecurity()); + $this->assertEquals($expected, $operation->security); } public function testClassSecurityAction() @@ -320,7 +321,7 @@ class FunctionalTest extends WebTestCase $expected = [ ['basic' => []], ]; - $this->assertEquals($expected, $operation->getSecurity()); + $this->assertEquals($expected, $operation->security); } public function testSymfonyConstraintDocumentation() @@ -382,29 +383,30 @@ class FunctionalTest extends WebTestCase ], ], 'type' => 'object', - ], $this->getModel('SymfonyConstraints')->toArray()); + 'schema' => 'SymfonyConstraints', + ], json_decode($this->getModel('SymfonyConstraints')->toJson(), true)); } public function testConfigReference() { $operation = $this->getOperation('/api/configReference', 'get'); - $this->assertEquals('#/definitions/Test', $operation->getResponses()->get('200')->getSchema()->getRef()); - $this->assertEquals('#/responses/201', $operation->getResponses()->get('201')->getRef()); + $this->assertEquals('#/components/schemas/Test', $this->getOperationResponse($operation, '200')->ref); + $this->assertEquals('#/components/responses/201', $this->getOperationResponse($operation, '201')->ref); } public function testOperationsWithOtherAnnotationsAction() { $getOperation = $this->getOperation('/api/multi-annotations', 'get'); - $this->assertSame('This is the get operation', $getOperation->getDescription()); - $this->assertSame('Worked well!', $getOperation->getResponses()->get(200)->getDescription()); + $this->assertSame('This is the get operation', $getOperation->description); + $this->assertSame('Worked well!', $this->getOperationResponse($getOperation, 200)->description); $postOperation = $this->getOperation('/api/multi-annotations', 'post'); - $this->assertSame('This is post', $postOperation->getDescription()); - $this->assertSame('Worked well!', $postOperation->getResponses()->get(200)->getDescription()); + $this->assertSame('This is post', $postOperation->description); + $this->assertSame('Worked well!', $this->getOperationResponse($postOperation, 200)->description); } public function testNoDuplicatedParameters() { - $this->assertFalse($this->getOperation('/api/article/{id}', 'get')->getParameters()->has('id', 'path')); + $this->assertNotHasParameter('name', 'path', $this->getOperation('/api/article/{id}', 'get')); } } diff --git a/Tests/Functional/JMSFunctionalTest.php b/Tests/Functional/JMSFunctionalTest.php index 0fa81e5..b71059d 100644 --- a/Tests/Functional/JMSFunctionalTest.php +++ b/Tests/Functional/JMSFunctionalTest.php @@ -29,7 +29,8 @@ class JMSFunctionalTest extends WebTestCase 'type' => 'integer', ], ], - ], $this->getModel('JMSPicture')->toArray()); + 'schema' => 'JMSPicture', + ], json_decode($this->getModel('JMSPicture')->toJson(), true)); $this->assertEquals([ 'type' => 'object', @@ -38,7 +39,8 @@ class JMSFunctionalTest extends WebTestCase 'type' => 'integer', ], ], - ], $this->getModel('JMSPicture_mini')->toArray()); + 'schema' => 'JMSPicture_mini', + ], json_decode($this->getModel('JMSPicture_mini')->toJson(), true)); } public function testModeChatDocumentation() @@ -51,21 +53,23 @@ class JMSFunctionalTest extends WebTestCase ], 'members' => [ 'items' => [ - '$ref' => '#/definitions/JMSChatUser', + '$ref' => '#/components/schemas/JMSChatUser', ], 'type' => 'array', ], ], - ], $this->getModel('JMSChat')->toArray()); + 'schema' => 'JMSChat', + ], json_decode($this->getModel('JMSChat')->toJson(), true)); $this->assertEquals([ 'type' => 'object', 'properties' => [ 'picture' => [ - '$ref' => '#/definitions/JMSPicture', + '$ref' => '#/components/schemas/JMSPicture', ], ], - ], $this->getModel('JMSChatUser')->toArray()); + 'schema' => 'JMSChatUser', + ], json_decode($this->getModel('JMSChatUser')->toJson(), true)); } public function testModelDocumentation() @@ -79,6 +83,7 @@ class JMSFunctionalTest extends WebTestCase 'readOnly' => true, 'title' => 'userid', 'example' => 1, + 'default' => null, ], 'daysOnline' => [ 'type' => 'integer', @@ -106,13 +111,13 @@ class JMSFunctionalTest extends WebTestCase 'friends' => [ 'type' => 'array', 'items' => [ - '$ref' => '#/definitions/User', + '$ref' => '#/components/schemas/User', ], ], 'indexed_friends' => [ 'type' => 'object', 'additionalProperties' => [ - '$ref' => '#/definitions/User', + '$ref' => '#/components/schemas/User', ], ], 'favorite_dates' => [ @@ -127,7 +132,7 @@ class JMSFunctionalTest extends WebTestCase 'format' => 'date-time', ], 'best_friend' => [ - '$ref' => '#/definitions/User', + '$ref' => '#/components/schemas/User', ], 'status' => [ 'type' => 'string', @@ -136,10 +141,12 @@ class JMSFunctionalTest extends WebTestCase 'enum' => ['disabled', 'enabled'], ], 'virtual_type1' => [ - '$ref' => '#/definitions/VirtualTypeClassDoesNotExistsHandlerDefined', + 'title' => 'JMS custom types handled via Custom Type Handlers.', + '$ref' => '#/components/schemas/VirtualTypeClassDoesNotExistsHandlerDefined', ], 'virtual_type2' => [ - '$ref' => '#/definitions/VirtualTypeClassDoesNotExistsHandlerNotDefined', + 'title' => 'JMS custom types handled via Custom Type Handlers.', + '$ref' => '#/components/schemas/VirtualTypeClassDoesNotExistsHandlerNotDefined', ], 'last_update' => [ 'type' => 'date', @@ -199,10 +206,12 @@ class JMSFunctionalTest extends WebTestCase 'type' => 'integer', ], ], - ], $this->getModel('JMSUser')->toArray()); + 'schema' => 'JMSUser', + ], json_decode($this->getModel('JMSUser')->toJson(), true)); $this->assertEquals([ - ], $this->getModel('VirtualTypeClassDoesNotExistsHandlerNotDefined')->toArray()); + 'schema' => 'VirtualTypeClassDoesNotExistsHandlerNotDefined', + ], json_decode($this->getModel('VirtualTypeClassDoesNotExistsHandlerNotDefined')->toJson(), true)); $this->assertEquals([ 'type' => 'object', @@ -211,7 +220,8 @@ class JMSFunctionalTest extends WebTestCase 'type' => 'string', ], ], - ], $this->getModel('VirtualTypeClassDoesNotExistsHandlerDefined')->toArray()); + 'schema' => 'VirtualTypeClassDoesNotExistsHandlerDefined', + ], json_decode($this->getModel('VirtualTypeClassDoesNotExistsHandlerDefined')->toJson(), true)); } public function testModelComplexDualDocumentation() @@ -223,13 +233,14 @@ class JMSFunctionalTest extends WebTestCase 'type' => 'integer', ], 'complex' => [ - '$ref' => '#/definitions/JMSComplex2', + '$ref' => '#/components/schemas/JMSComplex2', ], 'user' => [ - '$ref' => '#/definitions/JMSUser', + '$ref' => '#/components/schemas/JMSUser', ], ], - ], $this->getModel('JMSDualComplex')->toArray()); + 'schema' => 'JMSDualComplex', + ], json_decode($this->getModel('JMSDualComplex')->toJson(), true)); } public function testNestedGroups() @@ -237,10 +248,11 @@ class JMSFunctionalTest extends WebTestCase $this->assertEquals([ 'type' => 'object', 'properties' => [ - 'living' => ['$ref' => '#/definitions/JMSChatLivingRoom'], - 'dining' => ['$ref' => '#/definitions/JMSChatRoom'], + 'living' => ['$ref' => '#/components/schemas/JMSChatLivingRoom'], + 'dining' => ['$ref' => '#/components/schemas/JMSChatRoom'], ], - ], $this->getModel('JMSChatFriend')->toArray()); + 'schema' => 'JMSChatFriend', + ], json_decode($this->getModel('JMSChatFriend')->toJson(), true)); $this->assertEquals([ 'type' => 'object', @@ -248,7 +260,8 @@ class JMSFunctionalTest extends WebTestCase 'id1' => ['type' => 'integer'], 'id3' => ['type' => 'integer'], ], - ], $this->getModel('JMSChatRoom')->toArray()); + 'schema' => 'JMSChatRoom', + ], json_decode($this->getModel('JMSChatRoom')->toJson(), true)); } public function testModelComplexDocumentation() @@ -257,15 +270,16 @@ class JMSFunctionalTest extends WebTestCase 'type' => 'object', 'properties' => [ 'id' => ['type' => 'integer'], - 'user' => ['$ref' => '#/definitions/JMSUser'], + 'user' => ['$ref' => '#/components/schemas/JMSUser'], 'name' => ['type' => 'string'], - 'virtual' => ['$ref' => '#/definitions/JMSUser'], + 'virtual' => ['$ref' => '#/components/schemas/JMSUser'], ], 'required' => [ 'id', 'user', ], - ], $this->getModel('JMSComplex')->toArray()); + 'schema' => 'JMSComplex', + ], json_decode($this->getModel('JMSComplex')->toJson(), true)); } public function testYamlConfig() @@ -280,7 +294,8 @@ class JMSFunctionalTest extends WebTestCase 'type' => 'string', ], ], - ], $this->getModel('VirtualProperty')->toArray()); + 'schema' => 'VirtualProperty', + ], json_decode($this->getModel('VirtualProperty')->toJson(), true)); } public function testNamingStrategyWithConstraints() @@ -295,7 +310,8 @@ class JMSFunctionalTest extends WebTestCase ], ], 'required' => ['beautifulName'], - ], $this->getModel('JMSNamingStrategyConstraints')->toArray()); + 'schema' => 'JMSNamingStrategyConstraints', + ], json_decode($this->getModel('JMSNamingStrategyConstraints')->toJson(), true)); } protected static function createKernel(array $options = []) diff --git a/Tests/Functional/ModelDescriber/VirtualTypeClassDoesNotExistsHandlerDefinedDescriber.php b/Tests/Functional/ModelDescriber/VirtualTypeClassDoesNotExistsHandlerDefinedDescriber.php index 1ac1dac..d2c40f7 100644 --- a/Tests/Functional/ModelDescriber/VirtualTypeClassDoesNotExistsHandlerDefinedDescriber.php +++ b/Tests/Functional/ModelDescriber/VirtualTypeClassDoesNotExistsHandlerDefinedDescriber.php @@ -11,17 +11,19 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\ModelDescriber; -use EXSyst\Component\Swagger\Schema; use Nelmio\ApiDocBundle\Model\Model; use Nelmio\ApiDocBundle\ModelDescriber\ModelDescriberInterface; +use Nelmio\ApiDocBundle\OpenApiPhp\Util; +use OpenApi\Annotations as OA; use Symfony\Component\PropertyInfo\Type; class VirtualTypeClassDoesNotExistsHandlerDefinedDescriber implements ModelDescriberInterface { - public function describe(Model $model, Schema $schema) + public function describe(Model $model, OA\Schema $schema) { - $schema->setType('object'); - $schema->getProperties()->get('custom_prop')->setType('string'); + $schema->type = 'object'; + $property = Util::getProperty($schema, 'custom_prop'); + $property->type = 'string'; } public function supports(Model $model): bool diff --git a/Tests/Functional/SwaggerUiTest.php b/Tests/Functional/SwaggerUiTest.php index 0ed8ce5..894af56 100644 --- a/Tests/Functional/SwaggerUiTest.php +++ b/Tests/Functional/SwaggerUiTest.php @@ -35,8 +35,7 @@ class SwaggerUiTest extends WebTestCase $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('text/html; charset=UTF-8', $response->headers->get('Content-Type')); - $expected = $this->getSwaggerDefinition()->toArray(); - $expected['basePath'] = '/app_dev.php'; + $expected = json_decode($this->getOpenApiDefinition()->toJson(), true); $this->assertEquals($expected, json_decode($crawler->filterXPath('//script[@id="swagger-data"]')->text(), true)['spec']); } @@ -49,28 +48,10 @@ class SwaggerUiTest extends WebTestCase $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('text/html; charset=UTF-8', $response->headers->get('Content-Type')); - $expected = $this->getSwaggerDefinition()->toArray(); - $expected['basePath'] = '/app_dev.php'; - $expected['info']['title'] = 'My Test App'; - $expected['paths'] = [ - '/api/dummies' => $expected['paths']['/api/dummies'], - '/api/foo' => $expected['paths']['/api/foo'], - '/api/dummies/{id}' => $expected['paths']['/api/dummies/{id}'], - '/test/test/' => ['get' => [ - 'responses' => ['200' => ['description' => 'Test']], - ]], - '/test/test/{id}' => ['get' => [ - 'responses' => ['200' => ['description' => 'Test Ref']], - 'parameters' => [['$ref' => '#/parameters/test']], - ]], + $expected = json_decode($this->getOpenApiDefinition('test')->toJson(), true); + $expected['servers'] = [ + ['url' => 'http://api.example.com/app_dev.php'], ]; - $expected['definitions'] = [ - 'Dummy' => $expected['definitions']['Dummy'], - 'Test' => ['type' => 'string'], - 'JMSPicture_mini' => ['type' => 'object'], - 'BazingaUser_grouped' => ['type' => 'object'], - ]; - $this->assertEquals($expected, json_decode($crawler->filterXPath('//script[@id="swagger-data"]')->text(), true)['spec']); } @@ -82,9 +63,10 @@ class SwaggerUiTest extends WebTestCase $this->assertEquals(200, $response->getStatusCode()); $this->assertEquals('application/json', $response->headers->get('Content-Type')); - $expected = $this->getSwaggerDefinition()->toArray(); - $expected['basePath'] = '/app_dev.php'; - $expected['host'] = 'api.example.com'; + $expected = json_decode($this->getOpenApiDefinition()->toJson(), true); + $expected['servers'] = [ + ['url' => 'http://api.example.com/app_dev.php'], + ]; $this->assertEquals($expected, json_decode($response->getContent(), true)); } diff --git a/Tests/Functional/TestKernel.php b/Tests/Functional/TestKernel.php index 761478c..7643f0c 100644 --- a/Tests/Functional/TestKernel.php +++ b/Tests/Functional/TestKernel.php @@ -160,21 +160,23 @@ class TestKernel extends Kernel 'info' => [ 'title' => 'My Default App', ], - 'definitions' => [ - 'Test' => [ - 'type' => 'string', + 'components' => [ + 'schemas' => [ + 'Test' => [ + 'type' => 'string', + ], ], - ], - 'parameters' => [ - 'test' => [ - 'name' => 'id', - 'in' => 'path', - 'required' => true, + 'parameters' => [ + 'test' => [ + 'name' => 'id', + 'in' => 'path', + 'required' => true, + ], ], - ], - 'responses' => [ - '201' => [ - 'description' => 'Awesome description', + 'responses' => [ + '201' => [ + 'description' => 'Awesome description', + ], ], ], ], diff --git a/Tests/Functional/WebTestCase.php b/Tests/Functional/WebTestCase.php index 24d371e..e35a3ae 100644 --- a/Tests/Functional/WebTestCase.php +++ b/Tests/Functional/WebTestCase.php @@ -11,8 +11,7 @@ namespace Nelmio\ApiDocBundle\Tests\Functional; -use EXSyst\Component\Swagger\Operation; -use EXSyst\Component\Swagger\Schema; +use OpenApi\Annotations as OA; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase as BaseWebTestCase; class WebTestCase extends BaseWebTestCase @@ -22,29 +21,139 @@ class WebTestCase extends BaseWebTestCase return new TestKernel(); } - protected function getSwaggerDefinition($area = 'default') + protected function getOpenApiDefinition($area = 'default'): OA\OpenApi { return static::$kernel->getContainer()->get(sprintf('nelmio_api_doc.generator.%s', $area))->generate(); } - protected function getModel($name): Schema + protected function getModel($name): OA\Schema { - $definitions = $this->getSwaggerDefinition()->getDefinitions(); - $this->assertTrue($definitions->has($name)); + $api = $this->getOpenApiDefinition(); + $key = array_search($name, array_column($api->components->schemas, 'schema')); + static::assertNotFalse($key, sprintf('Model "%s" does not exist.', $name)); - return $definitions->get($name); + return $api->components->schemas[$key]; } - protected function getOperation($path, $method): Operation + protected function getOperation($path, $method): OA\Operation { - $api = $this->getSwaggerDefinition(); - $paths = $api->getPaths(); + $path = $this->getPath($path); - $this->assertTrue($paths->has($path), sprintf('Path "%s" does not exist.', $path)); - $action = $paths->get($path); + $this->assertInstanceOf( + OA\Operation::class, + $path->{$method}, + sprintf('Operation "%s" for path "%s" does not exist', $method, $path->path) + ); - $this->assertTrue($action->hasOperation($method), sprintf('Operation "%s" for path "%s" does not exist', $path, $method)); + return $path->{$method}; + } - return $action->getOperation($method); + protected function getOperationResponse(OA\Operation $operation, $response): OA\Response + { + $this->assertHasResponse($response, $operation); + $key = array_search($response, array_column($operation->responses, 'response')); + + return $operation->responses[$key]; + } + + protected function getProperty(OA\Schema $annotation, $property): OA\Property + { + $this->assertHasProperty($property, $annotation); + $key = array_search($property, array_column($annotation->properties, 'property')); + + return $annotation->properties[$key]; + } + + protected function getParameter(OA\AbstractAnnotation $annotation, $name, $in): OA\Parameter + { + /* @var OA\Operation|OA\OpenApi $annotation */ + $this->assertHasParameter($name, $in, $annotation); + $parameters = array_filter($annotation->parameters ?: [], function (OA\Parameter $parameter) use ($name, $in) { + return $parameter->name === $name && $parameter->in === $in; + }); + + return array_values($parameters)[0]; + } + + protected function getPath($path): OA\PathItem + { + $api = $this->getOpenApiDefinition(); + self::assertHasPath($path, $api); + + return $api->paths[array_search($path, array_column($api->paths, 'path'))]; + } + + public function assertHasPath($path, OA\OpenApi $api) + { + $paths = array_column(OA\UNDEFINED !== $api->paths ? $api->paths : [], 'path'); + static::assertContains( + $path, + $paths, + sprintf('Failed asserting that path "%s" does exist.', $path) + ); + } + + public function assertNotHasPath($path, OA\OpenApi $api) + { + $paths = array_column(OA\UNDEFINED !== $api->paths ? $api->paths : [], 'path'); + static::assertNotContains( + $path, + $paths, + sprintf('Failed asserting that path "%s" does not exist.', $path) + ); + } + + public function assertHasResponse($responseCode, OA\Operation $operation) + { + $responses = array_column(OA\UNDEFINED !== $operation->responses ? $operation->responses : [], 'response'); + static::assertContains( + $responseCode, + $responses, + sprintf('Failed asserting that response "%s" does exist.', $responseCode) + ); + } + + public function assertHasParameter($name, $in, OA\AbstractAnnotation $annotation) + { + /* @var OA\Operation|OA\OpenApi $annotation */ + $parameters = array_column(OA\UNDEFINED !== $annotation->parameters ? $annotation->parameters : [], 'name', 'in'); + static::assertContains( + $name, + $parameters[$in] ?? [], + sprintf('Failed asserting that parameter "%s" in "%s" does exist.', $name, $in) + ); + } + + public function assertNotHasParameter($name, $in, OA\AbstractAnnotation $annotation) + { + /* @var OA\Operation|OA\OpenApi $annotation */ + $parameters = array_column(OA\UNDEFINED !== $annotation->parameters ? $annotation->parameters : [], 'name', 'in'); + static::assertNotContains( + $name, + $parameters[$in] ?? [], + sprintf('Failed asserting that parameter "%s" in "%s" does not exist.', $name, $in) + ); + } + + public function assertHasProperty($property, OA\AbstractAnnotation $annotation) + { + /* @var OA\Schema|OA\Property|OA\Items $annotation */ + $properties = array_column(OA\UNDEFINED !== $annotation->properties ? $annotation->properties : [], 'property'); + static::assertContains( + $property, + $properties, + sprintf('Failed asserting that property "%s" does exist.', $property) + ); + } + + public function assertNotHasProperty($property, OA\AbstractAnnotation $annotation) + { + /* @var OA\Schema|OA\Property|OA\Items $annotation */ + $properties = array_column(OA\UNDEFINED !== $annotation->properties ? $annotation->properties : [], 'property'); + static::assertNotContains( + $property, + $properties, + sprintf('Failed asserting that property "%s" does not exist.', $property) + ); } } diff --git a/Tests/Model/ModelRegistryTest.php b/Tests/Model/ModelRegistryTest.php index 63d09c3..1ce1f8c 100644 --- a/Tests/Model/ModelRegistryTest.php +++ b/Tests/Model/ModelRegistryTest.php @@ -11,9 +11,9 @@ namespace Nelmio\ApiDocBundle\Tests\Model; -use EXSyst\Component\Swagger\Swagger; use Nelmio\ApiDocBundle\Model\Model; use Nelmio\ApiDocBundle\Model\ModelRegistry; +use OpenApi\Annotations as OA; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Type; @@ -27,10 +27,10 @@ class ModelRegistryTest extends TestCase 'groups' => ['group1'], ], ]; - $registry = new ModelRegistry([], new Swagger(), $alternativeNames); + $registry = new ModelRegistry([], new OA\OpenApi([]), $alternativeNames); $type = new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true); - $this->assertEquals('#/definitions/array', $registry->register(new Model($type, ['group1']))); + $this->assertEquals('#/components/schemas/array', $registry->register(new Model($type, ['group1']))); } /** @@ -40,7 +40,7 @@ class ModelRegistryTest extends TestCase */ public function testNameAliasingForObjects(string $expected, $groups, array $alternativeNames) { - $registry = new ModelRegistry([], new Swagger(), $alternativeNames); + $registry = new ModelRegistry([], new OA\OpenApi([]), $alternativeNames); $type = new Type(Type::BUILTIN_TYPE_OBJECT, false, self::class); $this->assertEquals($expected, $registry->register(new Model($type, $groups))); @@ -50,7 +50,7 @@ class ModelRegistryTest extends TestCase { return [ [ - '#/definitions/ModelRegistryTest', + '#/components/schemas/ModelRegistryTest', null, [ 'Foo1' => [ @@ -60,7 +60,7 @@ class ModelRegistryTest extends TestCase ], ], [ - '#/definitions/Foo1', + '#/components/schemas/Foo1', ['group1'], [ 'Foo1' => [ @@ -70,7 +70,7 @@ class ModelRegistryTest extends TestCase ], ], [ - '#/definitions/Foo1', + '#/components/schemas/Foo1', ['group1', 'group2'], [ 'Foo1' => [ @@ -80,7 +80,7 @@ class ModelRegistryTest extends TestCase ], ], [ - '#/definitions/ModelRegistryTest', + '#/components/schemas/ModelRegistryTest', null, [ 'Foo1' => [ @@ -90,7 +90,7 @@ class ModelRegistryTest extends TestCase ], ], [ - '#/definitions/Foo1', + '#/components/schemas/Foo1', [], [ 'Foo1' => [ @@ -110,9 +110,9 @@ class ModelRegistryTest extends TestCase $this->expectException('\LogicException'); $this->expectExceptionMessage(sprintf('Schema of type "%s" can\'t be generated, no describer supports it.', $stringType)); - $registry = new ModelRegistry([], new Swagger()); + $registry = new ModelRegistry([], new OA\OpenApi([])); $registry->register(new Model($type)); - $registry->registerDefinitions(); + $registry->registerSchemas(); } public function unsupportedTypesProvider() diff --git a/Tests/ModelDescriber/Annotations/SymfonyConstraintAnnotationReaderTest.php b/Tests/ModelDescriber/Annotations/SymfonyConstraintAnnotationReaderTest.php index 55ea9ec..42f6e96 100644 --- a/Tests/ModelDescriber/Annotations/SymfonyConstraintAnnotationReaderTest.php +++ b/Tests/ModelDescriber/Annotations/SymfonyConstraintAnnotationReaderTest.php @@ -12,8 +12,8 @@ namespace Nelmio\ApiDocBundle\Tests\ModelDescriber\Annotations; use Doctrine\Common\Annotations\AnnotationReader; -use EXSyst\Component\Swagger\Schema; use Nelmio\ApiDocBundle\ModelDescriber\Annotations\SymfonyConstraintAnnotationReader; +use OpenApi\Annotations as OA; use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\Constraints as Assert; @@ -33,18 +33,18 @@ class SymfonyConstraintAnnotationReaderTest extends TestCase private $property2; }; - $schema = new Schema(); - $schema->getProperties()->set('property1', new Schema()); - $schema->getProperties()->set('property2', new Schema()); + $schema = new OA\Schema([]); + $schema->merge([new OA\Property(['property' => 'property1'])]); + $schema->merge([new OA\Property(['property' => 'property2'])]); $symfonyConstraintAnnotationReader = new SymfonyConstraintAnnotationReader(new AnnotationReader()); $symfonyConstraintAnnotationReader->setSchema($schema); - $symfonyConstraintAnnotationReader->updateProperty(new \ReflectionProperty($entity, 'property1'), $schema->getProperties()->get('property1')); - $symfonyConstraintAnnotationReader->updateProperty(new \ReflectionProperty($entity, 'property2'), $schema->getProperties()->get('property2')); + $symfonyConstraintAnnotationReader->updateProperty(new \ReflectionProperty($entity, 'property1'), $schema->properties[0]); + $symfonyConstraintAnnotationReader->updateProperty(new \ReflectionProperty($entity, 'property2'), $schema->properties[1]); // expect required to be numeric array with sequential keys (not [0 => ..., 2 => ...]) - $this->assertEquals($schema->getRequired(), ['property1', 'property2']); + $this->assertEquals($schema->required, ['property1', 'property2']); } public function testAssertChoiceResultsInNumericArray() @@ -62,15 +62,15 @@ class SymfonyConstraintAnnotationReaderTest extends TestCase private $property1; }; - $schema = new Schema(); - $schema->getProperties()->set('property1', new Schema()); + $schema = new OA\Schema([]); + $schema->merge([new OA\Property(['property' => 'property1'])]); $symfonyConstraintAnnotationReader = new SymfonyConstraintAnnotationReader(new AnnotationReader()); $symfonyConstraintAnnotationReader->setSchema($schema); - $symfonyConstraintAnnotationReader->updateProperty(new \ReflectionProperty($entity, 'property1'), $schema->getProperties()->get('property1')); + $symfonyConstraintAnnotationReader->updateProperty(new \ReflectionProperty($entity, 'property1'), $schema->properties[0]); // expect enum to be numeric array with sequential keys (not [1 => "active", 2 => "active"]) - $this->assertEquals($schema->getProperties()->get('property1')->getEnum(), ['active', 'blocked']); + $this->assertEquals($schema->properties[0]->enum, ['active', 'blocked']); } } diff --git a/Tests/Swagger/ModelRegisterTest.php b/Tests/Swagger/ModelRegisterTest.php deleted file mode 100644 index 66ef80b..0000000 --- a/Tests/Swagger/ModelRegisterTest.php +++ /dev/null @@ -1,67 +0,0 @@ -__invoke(new Analysis([$annotation = $annotationsReader->getPropertyAnnotation( - new \ReflectionProperty(Foo::class, 'bar'), - SWG\Property::class - )])); - - $this->assertEquals(['items' => ['$ref' => '#/definitions/Foo']], json_decode(json_encode($annotation), true)); - } -} - -class Foo -{ - /** - * @SWG\Property(@ModelAnnotation(type=Foo::class)) - */ - private $bar; -} - -class NullModelDescriber implements ModelDescriberInterface -{ - public function describe(Model $model, Schema $schema) - { - } - - public function supports(Model $model): bool - { - return true; - } -} diff --git a/Tests/SwaggerPhp/UtilTest.php b/Tests/SwaggerPhp/UtilTest.php new file mode 100644 index 0000000..ec88b5c --- /dev/null +++ b/Tests/SwaggerPhp/UtilTest.php @@ -0,0 +1,842 @@ +rootContext = new Context(['isTestingRoot' => true]); + $this->rootAnnotation = new OA\OpenApi(['_context' => $this->rootContext]); + } + + public function testCreateContextSetsParentContext() + { + $context = Util::createContext([], $this->rootContext); + + $this->assertSame($this->rootContext, $context->getRootContext()); + } + + public function testCreateContextWithProperties() + { + $context = Util::createContext(['testing' => 'trait']); + + $this->assertTrue($context->is('testing')); + $this->assertSame('trait', $context->testing); + } + + public function testCreateChild() + { + $info = Util::createChild($this->rootAnnotation, OA\Info::class); + + $this->assertInstanceOf(OA\Info::class, $info); + } + + public function testCreateChildHasContext() + { + $info = Util::createChild($this->rootAnnotation, OA\Info::class); + + $this->assertInstanceOf(Context::class, $info->_context); + } + + public function testCreateChildHasNestedContext() + { + $path = Util::createChild($this->rootAnnotation, OA\PathItem::class); + $this->assertIsNested($this->rootAnnotation, $path); + + $parameter = Util::createChild($path, OA\Parameter::class); + $this->assertIsNested($path, $parameter); + + $schema = Util::createChild($parameter, OA\Schema::class); + $this->assertIsNested($parameter, $schema); + + $this->assertIsConnectedToRootContext($schema); + } + + public function testCreateChildWithEmptyProperties() + { + $properties = []; + /** @var OA\Info $info */ + $info = Util::createChild($this->rootAnnotation, OA\Info::class, $properties); + + $properties = array_filter(get_object_vars($info), function ($key) { + return 0 !== strpos($key, '_'); + }, ARRAY_FILTER_USE_KEY); + + $this->assertEquals([UNDEFINED], array_unique(array_values($properties))); + + $this->assertIsNested($this->rootAnnotation, $info); + $this->assertIsConnectedToRootContext($info); + } + + public function testCreateChildWithProperties() + { + $properties = ['title' => 'testing', 'version' => '999', 'x' => new \stdClass()]; + /** @var OA\Info $info */ + $info = Util::createChild($this->rootAnnotation, OA\Info::class, $properties); + + $this->assertSame($info->title, $properties['title']); + $this->assertSame($info->version, $properties['version']); + $this->assertSame($info->x, $properties['x']); + + $this->assertIsNested($this->rootAnnotation, $info); + $this->assertIsConnectedToRootContext($info); + } + + public function testCreateCollectionItemAddsCreatedItemToCollection() + { + $collection = 'paths'; + $class = OA\PathItem::class; + + $p1 = Util::createCollectionItem($this->rootAnnotation, $collection, $class); + $this->assertSame(0, $p1); + $this->assertCount(1, $this->rootAnnotation->{$collection}); + $this->assertInstanceOf($class, $this->rootAnnotation->{$collection}[$p1]); + $this->assertIsNested($this->rootAnnotation, $this->rootAnnotation->{$collection}[$p1]); + $this->assertIsConnectedToRootContext($this->rootAnnotation->{$collection}[$p1]); + + $p2 = Util::createCollectionItem($this->rootAnnotation, $collection, $class); + $this->assertSame(1, $p2); + $this->assertCount(2, $this->rootAnnotation->{$collection}); + $this->assertInstanceOf($class, $this->rootAnnotation->{$collection}[$p2]); + $this->assertIsNested($this->rootAnnotation, $this->rootAnnotation->{$collection}[$p2]); + $this->assertIsConnectedToRootContext($this->rootAnnotation->{$collection}[$p2]); + + $this->rootAnnotation->components = Util::createChild($this->rootAnnotation, OA\Components::class); + + $collection = 'schemas'; + $class = OA\Schema::class; + + $d1 = Util::createCollectionItem($this->rootAnnotation->components, $collection, $class); + $this->assertSame(0, $d1); + $this->assertCount(1, $this->rootAnnotation->components->{$collection}); + $this->assertInstanceOf($class, $this->rootAnnotation->components->{$collection}[$d1]); + $this->assertIsNested($this->rootAnnotation->components, $this->rootAnnotation->components->{$collection}[$d1]); + $this->assertIsConnectedToRootContext($this->rootAnnotation->components->{$collection}[$d1]); + } + + public function testCreateCollectionItemDoesNotAddToUnknownProperty() + { + $collection = 'foobars'; + $class = OA\Info::class; + + $expectedRegex = "/Property \"{$collection}\" doesn't exist .*/"; + set_error_handler(function ($_, $err) { echo $err; }); + $this->expectOutputRegex($expectedRegex); + Util::createCollectionItem($this->rootAnnotation, $collection, $class); + $this->expectOutputRegex($expectedRegex); + $this->assertNull($this->rootAnnotation->{$collection}); + restore_error_handler(); + } + + public function testSearchCollectionItem() + { + $item1 = new \stdClass(); + $item1->prop1 = 'item 1 prop 1'; + $item1->prop2 = 'item 1 prop 2'; + $item1->prop3 = 'item 1 prop 3'; + + $item2 = new \stdClass(); + $item2->prop1 = 'item 2 prop 1'; + $item2->prop2 = 'item 2 prop 2'; + $item2->prop3 = 'item 2 prop 3'; + + $collection = [ + $item1, + $item2, + ]; + + $this->assertSame(0, Util::searchCollectionItem($collection, get_object_vars($item1))); + $this->assertSame(1, Util::searchCollectionItem($collection, get_object_vars($item2))); + + $this->assertNull(Util::searchCollectionItem( + $collection, + array_merge(get_object_vars($item2), ['prop3' => 'foobar']) + )); + + $search = ['baz' => 'foobar']; + $this->expectOutputString('Undefined property: stdClass::$baz'); + + try { + Util::searchCollectionItem($collection, array_merge(get_object_vars($item2), $search)); + } catch (\Exception $e) { + echo $e->getMessage(); + } + + // no exception on empty collection + $this->assertNull(Util::searchCollectionItem([], get_object_vars($item2))); + } + + /** + * @dataProvider provideIndexedCollectionData + */ + public function testSearchIndexedCollectionItem($setup, $asserts) + { + foreach ($asserts as $collection => $items) { + foreach ($items as $assert) { + $setupCollection = empty($assert['components']) ? + ($setup[$collection] ?? []) : + (OA\UNDEFINED !== $setup['components']->{$collection} ? $setup['components']->{$collection} : []); + + // get the indexing correct within haystack preparation + $properties = array_fill(0, \count($setupCollection), null); + + // prepare the haystack array + foreach ($items as $assertItem) { + // e.g. $properties[1] = new OA\PathItem(['path' => 'path 1']) + $properties[$assertItem['index']] = new $assertItem['class']([ + $assertItem['key'] => $assertItem['value'], + ]); + } + + $this->assertSame( + $assert['index'], + Util::searchIndexedCollectionItem($properties, $assert['key'], $assert['value']), + sprintf('Failed to get the correct index for %s', print_r($assert, true)) + ); + } + } + } + + /** + * @dataProvider provideIndexedCollectionData + */ + public function testGetIndexedCollectionItem($setup, $asserts) + { + $parent = new $setup['class'](array_merge( + $this->getSetupPropertiesWithoutClass($setup), + ['_context' => $this->rootContext] + )); + + foreach ($asserts as $collection => $items) { + foreach ($items as $assert) { + $itemParent = empty($assert['components']) ? $parent : $parent->components; + + $child = Util::getIndexedCollectionItem( + $itemParent, + $assert['class'], + $assert['value'] + ); + + $this->assertInstanceOf($assert['class'], $child); + $this->assertSame($child->{$assert['key']}, $assert['value']); + $this->assertSame( + $itemParent->{$collection}[$assert['index']], + $child + ); + + $setupHaystack = empty($assert['components']) ? + $setup[$collection] ?? [] : + $setup['components']->{$collection} ?? []; + + // the children created within provider are not connected + if (!\in_array($child, $setupHaystack, true)) { + $this->assertIsNested($itemParent, $child); + $this->assertIsConnectedToRootContext($child); + } + } + } + } + + public function provideIndexedCollectionData(): array + { + return [[ + 'setup' => [ + 'class' => OA\OpenApi::class, + 'paths' => [ + new OA\PathItem(['path' => 'path 0']), + ], + 'components' => new OA\Components([ + 'parameters' => [ + new OA\Parameter(['parameter' => 'parameter 0']), + new OA\Parameter(['parameter' => 'parameter 1']), + ], + ]), + ], + 'assert' => [ + // one fixed within setup and one dynamically created + 'paths' => [ + [ + 'index' => 0, + 'class' => OA\PathItem::class, + 'key' => 'path', + 'value' => 'path 0', + ], + [ + 'index' => 1, + 'class' => OA\PathItem::class, + 'key' => 'path', + 'value' => 'path 1', + ], + ], + // search indexes out of order followed by dynamically created + 'parameters' => [ + [ + 'index' => 1, + 'class' => OA\Parameter::class, + 'key' => 'parameter', + 'value' => 'parameter 1', + 'components' => true, + ], + [ + 'index' => 0, + 'class' => OA\Parameter::class, + 'key' => 'parameter', + 'value' => 'parameter 0', + 'components' => true, + ], + [ + 'index' => 2, + 'class' => OA\Parameter::class, + 'key' => 'parameter', + 'value' => 'parameter 2', + 'components' => true, + ], + ], + // two dynamically created + 'responses' => [ + [ + 'index' => 0, + 'class' => OA\Response::class, + 'key' => 'response', + 'value' => 'response 0', + 'components' => true, + ], + [ + 'index' => 1, + 'class' => OA\Response::class, + 'key' => 'response', + 'value' => 'response 1', + 'components' => true, + ], + ], + // for sake of completeness + 'securitySchemes' => [ + [ + 'index' => 0, + 'class' => OA\SecurityScheme::class, + 'key' => 'securityScheme', + 'value' => 'securityScheme 0', + 'components' => true, + ], + ], + ], + ]]; + } + + /** + * @dataProvider provideChildData + */ + public function testGetChild($setup, $asserts) + { + $parent = new $setup['class'](array_merge( + $this->getSetupPropertiesWithoutClass($setup), + ['_context' => $this->rootContext] + )); + + foreach ($asserts as $key => $assert) { + if (array_key_exists('exceptionMessage', $assert)) { + $this->expectExceptionMessage($assert['exceptionMessage']); + } + $child = Util::getChild($parent, $assert['class'], $assert['props']); + + $this->assertInstanceOf($assert['class'], $child); + $this->assertSame($child, $parent->{$key}); + + if (\array_key_exists($key, $setup)) { + $this->assertSame($setup[$key], $parent->{$key}); + } + + $this->assertEquals($assert['props'], $this->getNonDefaultProperties($child)); + } + } + + public function provideChildData(): array + { + return [[ + 'setup' => [ + 'class' => OA\PathItem::class, + 'get' => new OA\Get([]), + ], + 'assert' => [ + // fixed within setup + 'get' => [ + 'class' => OA\Get::class, + 'props' => [], + ], + // create new without props + 'put' => [ + 'class' => OA\Put::class, + 'props' => [], + ], + // create new with multiple props + 'delete' => [ + 'class' => OA\Delete::class, + 'props' => [ + 'summary' => 'testing delete', + 'deprecated' => true, + ], + ], + ], + ], [ + 'setup' => [ + 'class' => OA\Parameter::class, + ], + 'assert' => [ + // create new with multiple props + 'schema' => [ + 'class' => OA\Schema::class, + 'props' => [ + 'ref' => '#/testing/schema', + 'minProperties' => 0, + 'enum' => [null, 'check', 999, false], + ], + ], + ], + ], [ + 'setup' => [ + 'class' => OA\Parameter::class, + ], + 'assert' => [ + // externalDocs triggers invalid argument exception + 'schema' => [ + 'class' => OA\Schema::class, + 'props' => [ + 'externalDocs' => [], + ], + 'exceptionMessage' => 'Nesting Annotations is not supported.', + ], + ], + ]]; + } + + public function testGetOperationParameterReturnsExisting() + { + $name = 'operation name'; + $in = 'operation in'; + + $parameter = new OA\Parameter(['name' => $name, 'in' => $in]); + $operation = new OA\Get(['parameters' => [ + new OA\Parameter([]), + new OA\Parameter(['name' => 'foo']), + new OA\Parameter(['in' => 'bar']), + new OA\Parameter(['name' => $name, 'in' => 'bar']), + new OA\Parameter(['name' => 'foo', 'in' => $in]), + $parameter, + ]]); + + $actual = Util::getOperationParameter($operation, $name, $in); + $this->assertSame($parameter, $actual); + } + + public function testGetOperationParameterCreatesWithNameAndIn() + { + $name = 'operation name'; + $in = 'operation in'; + + $operation = new OA\Get(['parameters' => [ + new OA\Parameter([]), + new OA\Parameter(['name' => 'foo']), + new OA\Parameter(['in' => 'bar']), + new OA\Parameter(['name' => $name, 'in' => 'bar']), + new OA\Parameter(['name' => 'foo', 'in' => $in]), + ]]); + + $actual = Util::getOperationParameter($operation, $name, $in); + $this->assertInstanceOf(OA\Parameter::class, $actual); + $this->assertSame($name, $actual->name); + $this->assertSame($in, $actual->in); + } + + public function testGetOperationReturnsExisting() + { + $get = new OA\Get([]); + $path = new OA\PathItem(['get' => $get]); + + $this->assertSame($get, Util::getOperation($path, 'get')); + } + + public function testGetOperationCreatesWithPath() + { + $pathStr = '/testing/get/path'; + $path = new OA\PathItem(['path' => $pathStr]); + + $get = Util::getOperation($path, 'get'); + $this->assertInstanceOf(OA\Get::class, $get); + $this->assertSame($pathStr, $get->path); + } + + public function testMergeWithEmptyArray() + { + $api = new OA\OpenApi([]); + $expected = json_encode($api); + + Util::merge($api, [], false); + $actual = json_encode($api); + + $this->assertSame($expected, $actual); + + Util::merge($api, [], true); + $actual = json_encode($api); + + $this->assertSame($expected, $actual); + } + + /** + * @dataProvider provideMergeData + */ + public function testMerge($setup, $merge, $assert) + { + $api = new OA\OpenApi($setup); + + Util::merge($api, $merge, false); + $this->assertTrue($api->validate()); + $actual = json_decode(json_encode($api), true); + + $this->assertEquals($assert, $actual); + } + + public function provideMergeData(): array + { + $no = 'do not overwrite'; + $yes = 'do overwrite'; + + $requiredInfo = ['title' => '', 'version' => '']; + + $setupDefaults = [ + 'info' => new OA\Info($requiredInfo), + 'paths' => [], + ]; + $assertDefaults = [ + 'info' => $requiredInfo, + 'openapi' => '3.0.0', + 'paths' => [], + ]; + + return [[ + // simple child merge + 'setup' => [ + 'info' => new OA\Info(['version' => $no]), + 'paths' => [], + ], + 'merge' => [ + 'info' => ['title' => $yes, 'version' => $yes], + ], + 'assert' => [ + 'info' => ['title' => $yes, 'version' => $no], + ] + $assertDefaults, + ], [ + // indexed collection merge + 'setup' => [ + 'components' => new OA\Components([ + 'schemas' => [ + new OA\Schema(['schema' => $no, 'title' => $no]), + ], + ]), + ] + $setupDefaults, + 'merge' => [ + 'components' => [ + 'schemas' => [ + $no => ['title' => $yes, 'description' => $yes], + ], + ], + ], + 'assert' => [ + 'components' => [ + 'schemas' => [ + $no => ['title' => $no, 'description' => $yes], + ], + ], + ] + $assertDefaults, + ], [ + // collection merge + 'setup' => [ + 'tags' => [new OA\Tag(['name' => $no])], + ] + $setupDefaults, + 'merge' => [ + 'tags' => [ + // this is actually appending right now, no clue if this is wanted, + // but the complete NelmioApiDocBundle test suite is not upset by this fact + ['name' => $yes], + // this should not append since a tag with exactly the same properties + // is already present + ['name' => $no], + // this does, but should not append since the name already exists, and the + // docs in Tag state that the tag names must be unique, but it is complicated + // and $api->validate() does not complain either + ['name' => $no, 'description' => $yes], + ], + ], + 'assert' => [ + 'tags' => [ + ['name' => $no], + ['name' => $yes], + ['name' => $no, 'description' => $yes], + ], + ] + $assertDefaults, + ], + [ + // heavy nested merge array + 'setup' => $setupDefaults, + 'merge' => $merge = [ + 'servers' => [ + ['url' => 'http'], + ['url' => 'https'], + ], + 'paths' => [ + '/path/to/resource' => [ + 'get' => [ + 'responses' => [ + '200' => [ + '$ref' => '#/components/responses/default', + ], + ], + 'requestBody' => [ + 'description' => 'request foo', + 'content' => [ + 'foo-request' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['baz', 'bar'], + ], + ], + ], + ], + ], + ], + ], + 'tags' => [ + ['name' => 'baz'], + ['name' => 'foo'], + ['name' => 'baz'], + ['name' => 'foo'], + ['name' => 'foo'], + ], + 'components' => [ + 'responses' => [ + 'default' => [ + 'description' => 'default response', + 'headers' => [ + 'foo-header' => [ + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => ['foo', 'bar', 'baz'], + ], + ], + ], + ], + ], + ], + ], + ], + 'assert' => array_merge( + $assertDefaults, + $merge, + ['tags' => \array_slice($merge['tags'], 0, 2, true)] + ), + ], [ + // heavy nested merge array object + 'setup' => $setupDefaults, + 'merge' => new \ArrayObject([ + 'servers' => [ + ['url' => 'http'], + ['url' => 'https'], + ], + 'paths' => [ + '/path/to/resource' => [ + 'get' => new \ArrayObject([ + 'responses' => [ + '200' => [ + '$ref' => '#/components/responses/default', + ], + ], + 'requestBody' => new \ArrayObject([ + 'description' => 'request foo', + 'content' => [ + 'foo-request' => [ + 'schema' => [ + 'required' => ['baz', 'bar'], + 'type' => 'object', + ], + ], + ], + ]), + ]), + ], + ], + 'tags' => new \ArrayObject([ + ['name' => 'baz'], + ['name' => 'foo'], + new \ArrayObject(['name' => 'baz']), + ['name' => 'foo'], + ['name' => 'foo'], + ]), + 'components' => new \ArrayObject([ + 'responses' => [ + 'default' => [ + 'description' => 'default response', + 'headers' => new \ArrayObject([ + 'foo-header' => new \ArrayObject([ + 'schema' => new \ArrayObject([ + 'type' => 'array', + 'items' => new \ArrayObject([ + 'type' => 'string', + 'enum' => ['foo', 'bar', 'baz'], + ]), + ]), + ]), + ]), + ], + ], + ]), + ]), + 'assert' => array_merge( + $assertDefaults, + $merge, + ['tags' => \array_slice($merge['tags'], 0, 2, true)] + ), + ], [ + // heavy nested merge swagger instance + 'setup' => $setupDefaults, + 'merge' => new OA\OpenApi([ + 'servers' => [ + new OA\Server(['url' => 'http']), + new OA\Server(['url' => 'https']), + ], + 'paths' => [ + new OA\PathItem([ + 'path' => '/path/to/resource', + 'get' => new OA\Get([ + 'responses' => [ + new OA\Response([ + 'response' => '200', + 'ref' => '#/components/responses/default', + ]), + ], + 'requestBody' => new OA\RequestBody([ + 'description' => 'request foo', + 'content' => [ + new OA\MediaType([ + 'mediaType' => 'foo-request', + 'schema' => new OA\Schema([ + 'type' => 'object', + 'required' => ['baz', 'bar'], + ]), + ]), + ], + ]), + ]), + ]), + ], + 'tags' => [ + new OA\Tag(['name' => 'baz']), + new OA\Tag(['name' => 'foo']), + new OA\Tag(['name' => 'baz']), + new OA\Tag(['name' => 'foo']), + new OA\Tag(['name' => 'foo']), + ], + 'components' => new OA\Components([ + 'responses' => [ + new OA\Response([ + 'response' => 'default', + 'description' => 'default response', + 'headers' => [ + new OA\Header([ + 'header' => 'foo-header', + 'schema' => new OA\Schema([ + 'type' => 'array', + 'items' => new OA\Items([ + 'type' => 'string', + 'enum' => ['foo', 'bar', 'baz'], + ]), + ]), + ]), + ], + ]), + ], + ]), + ]), + 'assert' => array_merge( + $assertDefaults, + $merge, + ['tags' => \array_slice($merge['tags'], 0, 2, true)] + ), + ], ]; + } + + public function assertIsNested(OA\AbstractAnnotation $parent, OA\AbstractAnnotation $child) + { + self::assertTrue($child->_context->is('nested')); + self::assertSame($parent, $child->_context->nested); + } + + public function assertIsConnectedToRootContext(OA\AbstractAnnotation $annotation) + { + $this->assertSame($this->rootContext, $annotation->_context->getRootContext()); + } + + private function getSetupPropertiesWithoutClass(array $setup) + { + return array_filter($setup, function ($k) {return 'class' !== $k; }, ARRAY_FILTER_USE_KEY); + } + + private function getNonDefaultProperties($object) + { + $objectVars = \get_object_vars($object); + $classVars = \get_class_vars(\get_class($object)); + $props = []; + foreach ($objectVars as $key => $value) { + if ($value !== $classVars[$key] && 0 !== \strpos($key, '_')) { + $props[$key] = $value; + } + } + + return $props; + } +} diff --git a/UPGRADE-4.0.md b/UPGRADE-4.0.md new file mode 100644 index 0000000..d8dc38a --- /dev/null +++ b/UPGRADE-4.0.md @@ -0,0 +1,28 @@ +Upgrading From 3.x To 4.0 +========================= + +Version 4 is a major change introducing OpenAPI 3.0 support, the rebranded swagger specification, which brings a set of new interesting features. Unfortunately this required a rework to a large part of the bundle, and introduces BC breaks. + +The Visual guide to "[What's new in 3.0 spec](https://blog.readme.com/an-example-filled-guide-to-swagger-3-2/)" gives more information on OpenApi 3.0. + +Version 4 does not support older versions of the specification. If you need to output swagger v2 documentation, you will need to use the latest 3.x release. + +The Upgrade to Swagger 3.0 +-------------------------- + +The biggest part of the upgrade will most likely be the upgrade of the library `zircote/swagger-php` to `3.0` which introduces new annotations in order to support OpenAPI 3.0 but also changes +their namespace from ``Swagger`` to ``OpenApi``. + +They created a dedicated page to help you upgrade : https://zircote.github.io/swagger-php/Migrating-to-v3.html. + +Here are some additional advices that are more likely to apply to NelmioApiDocBundle users: + +- Upgrade all your ``use Swagger\Annotations as SWG`` statements to ``use OpenApi\Annotations as OA;`` (to simplify the upgrade you may also stick to the ``SWG`` aliasing). + In case you changed ``SWG`` to ``OA``, upgrade all your annotations from ``@SWG\...`` to ``@OA\...``. + +- Update your config in case you used inlined swagger docummentation (the field ``nelmio_api_doc.documentation``). [A tool](https://openapi-converter.herokuapp.com/) is available to help you convert it. + +- In case you used ``@OA\Response(..., @OA\Schema(...))``, you should explicit your media type by using the annotation ``@OA\JsonContent`` or ``@OA\XmlContent`` instead of ``@OA\Schema``: + ``@OA\Response(..., @OA\JsonContent(...))`` or ``@OA\Response(..., @OA\XmlContent(...))``. + + When you use ``@Model`` directly (``@OA\Response(..., @Model(...)))``), the media type is set by default to ``json``. diff --git a/composer.json b/composer.json index c904eb4..65bfac8 100644 --- a/composer.json +++ b/composer.json @@ -16,33 +16,33 @@ ], "require": { "php": "^7.1", - "symfony/framework-bundle": "^3.4|^4.0|^5.0", - "symfony/options-resolver": "^3.4.4|^4.0|^5.0", - "symfony/property-info": "^3.4|^4.0|^5.0", - "exsyst/swagger": "^0.4.1", - "zircote/swagger-php": "^2.0.9", + "ext-json": "*", + "symfony/framework-bundle": "^4.0|^5.0", + "symfony/options-resolver": "^4.0|^5.0", + "symfony/property-info": "^4.0|^5.0", + "zircote/swagger-php": "^3.0", "phpdocumentor/reflection-docblock": "^3.1|^4.0|^5.0" }, "require-dev": { - "symfony/templating": "^3.4|^4.0|^5.0", - "symfony/twig-bundle": "^3.4|^4.0|^5.0", - "symfony/asset": "^3.4|^4.0|^5.0", - "symfony/console": "^3.4|^4.0|^5.0", - "symfony/config": "^3.4|^4.0|^5.0", - "symfony/validator": "^3.4|^4.0|^5.0", - "symfony/property-access": "^3.4|^4.0|^5.0", - "symfony/form": "^3.4|^4.0|^5.0", - "symfony/dom-crawler": "^3.4|^4.0|^5.0", - "symfony/browser-kit": "^3.4|^4.0|^5.0", - "symfony/cache": "^3.4|^4.0|^5.0", - "symfony/phpunit-bridge": "^3.4.24|^4.0|^5.0", - "symfony/stopwatch": "^3.4|^4.0|^5.0", - "symfony/routing": "^3.4|^4.0|^5.0", - "sensio/framework-extra-bundle": "^3.0.13|^4.0|^5.0", + "symfony/templating": "^4.0|^5.0", + "symfony/twig-bundle": "^4.0|^5.0", + "symfony/asset": "^4.0|^5.0", + "symfony/console": "^4.0|^5.0", + "symfony/config": "^4.0|^5.0", + "symfony/validator": "^4.0|^5.0", + "symfony/property-access": "^4.0|^5.0", + "symfony/form": "^4.0|^5.0", + "symfony/dom-crawler": "^4.0|^5.0", + "symfony/browser-kit": "^4.0|^5.0", + "symfony/cache": "^4.0|^5.0", + "symfony/phpunit-bridge": "^4.0|^5.0", + "symfony/stopwatch": "^4.0|^5.0", + "symfony/routing": "^4.0|^5.0", + "sensio/framework-extra-bundle": "^4.0|^5.0", "doctrine/annotations": "^1.2", "doctrine/common": "^2.4", - "api-platform/core": "^2.1.2", + "api-platform/core": "^2.4", "friendsofsymfony/rest-bundle": "^2.0", "willdurand/hateoas-bundle": "^1.0|^2.0", "jms/serializer-bundle": "^2.3|^3.0",