welcome back to dyb-tech

This commit is contained in:
Daniel Guzman
2024-05-18 02:28:01 +02:00
parent 9513cdba09
commit 9f30bc98c7
6149 changed files with 668407 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Annotation;
/**
* @Annotation
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
final class Areas
{
/** @var string[] */
private $areas;
public function __construct(array $properties)
{
if (!array_key_exists('value', $properties) || !is_array($properties['value'])) {
$properties['value'] = array_values($properties);
}
if ([] === $properties['value']) {
throw new \InvalidArgumentException('An array of areas was expected');
}
$areas = [];
foreach ($properties['value'] as $area) {
if (!is_string($area)) {
throw new \InvalidArgumentException('An area must be given as a string');
}
if (!in_array($area, $areas)) {
$areas[] = $area;
}
}
if (0 === count($areas)) {
throw new \LogicException('At least one area is expected');
}
$this->areas = $areas;
}
public function has(string $area): bool
{
return in_array($area, $this->areas, true);
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Annotation;
use OpenApi\Annotations\Parameter;
use OpenApi\Attributes\Attachable;
use OpenApi\Generator;
/**
* @Annotation
*/
#[\Attribute(\Attribute::TARGET_METHOD)]
final class Model extends Attachable
{
/** {@inheritdoc} */
public static $_types = [
'type' => 'string',
'groups' => '[string]',
'options' => '[mixed]',
];
public static $_required = ['type'];
public static $_parents = [
Parameter::class,
];
/**
* @var string
*/
public $type;
/**
* @var string[]
*/
public $groups;
/**
* @var mixed[]
*/
public $options;
/**
* @var array<string, mixed>
*/
public $serializationContext;
/**
* @param mixed[] $properties
* @param string[] $groups
* @param mixed[] $options
* @param array<string, mixed> $serializationContext
*/
public function __construct(
array $properties = [],
string $type = Generator::UNDEFINED,
array $groups = null,
array $options = null,
array $serializationContext = []
) {
parent::__construct($properties + [
'type' => $type,
'groups' => $groups,
'options' => $options,
'serializationContext' => $serializationContext,
]);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Annotation;
use OpenApi\Annotations\Operation as BaseOperation;
/**
* @Annotation
*/
#[\Attribute(\Attribute::TARGET_METHOD)]
class Operation extends BaseOperation
{
}
+50
View File
@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Annotation;
use OpenApi\Annotations\AbstractAnnotation;
/**
* @Annotation
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class Security extends AbstractAnnotation
{
/** {@inheritdoc} */
public static $_types = [
'name' => 'string',
'scopes' => '[string]',
];
public static $_required = ['name'];
/**
* @var string
*/
public $name;
/**
* @var string[]
*/
public $scopes = [];
public function __construct(
array $properties = [],
string $name = null,
array $scopes = []
) {
parent::__construct($properties + [
'name' => $name,
'scopes' => $scopes,
]);
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle;
use Nelmio\ApiDocBundle\Describer\DescriberInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface;
use Nelmio\ApiDocBundle\Model\ModelRegistry;
use Nelmio\ApiDocBundle\ModelDescriber\ModelDescriberInterface;
use Nelmio\ApiDocBundle\OpenApiPhp\ModelRegister;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Analysis;
use OpenApi\Annotations\OpenApi;
use OpenApi\Generator;
use OpenApi\Processors\ProcessorInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerAwareTrait;
final class ApiDocGenerator
{
use LoggerAwareTrait;
/** @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 = [];
/** @var string[] */
private $mediaTypes = ['json'];
/**
* @var ?string
*/
private $openApiVersion = null;
/** @var Generator */
private $generator;
/**
* @param DescriberInterface[]|iterable $describers
* @param ModelDescriberInterface[]|iterable $modelDescribers
*/
public function __construct($describers, $modelDescribers, CacheItemPoolInterface $cacheItemPool = null, string $cacheItemId = null, Generator $generator = null)
{
$this->describers = $describers;
$this->modelDescribers = $modelDescribers;
$this->cacheItemPool = $cacheItemPool;
$this->cacheItemId = $cacheItemId;
$this->generator = $generator ?? new Generator($this->logger);
}
public function setAlternativeNames(array $alternativeNames)
{
$this->alternativeNames = $alternativeNames;
}
public function setMediaTypes(array $mediaTypes)
{
$this->mediaTypes = $mediaTypes;
}
public function setOpenApiVersion(?string $openApiVersion)
{
$this->openApiVersion = $openApiVersion;
}
public function generate(): OpenApi
{
if (null !== $this->openApi) {
return $this->openApi;
}
if ($this->cacheItemPool) {
$item = $this->cacheItemPool->getItem($this->cacheItemId ?? 'openapi_doc');
if ($item->isHit()) {
return $this->openApi = $item->get();
}
}
if ($this->openApiVersion) {
$this->generator->setVersion($this->openApiVersion);
}
$this->generator->setProcessors($this->getProcessors($this->generator));
$context = Util::createContext(['version' => $this->generator->getVersion()]);
$this->openApi = new OpenApi(['_context' => $context]);
$modelRegistry = new ModelRegistry($this->modelDescribers, $this->openApi, $this->alternativeNames);
if (null !== $this->logger) {
$modelRegistry->setLogger($this->logger);
}
foreach ($this->describers as $describer) {
if ($describer instanceof ModelRegistryAwareInterface) {
$describer->setModelRegistry($modelRegistry);
}
$describer->describe($this->openApi);
}
$analysis = new Analysis([], $context);
$analysis->addAnnotation($this->openApi, $context);
// Register model annotations
$modelRegister = new ModelRegister($modelRegistry, $this->mediaTypes);
$modelRegister($analysis);
// Calculate the associated schemas
$modelRegistry->registerSchemas();
$analysis->process($this->generator->getProcessors());
$analysis->validate();
if (isset($item)) {
$this->cacheItemPool->save($item->set($this->openApi));
}
return $this->openApi;
}
/**
* Get an array of processors that will be used to process the OpenApi object.
*
* @param Generator $generator The generator instance to get the standard processors from
*
* @return array<ProcessorInterface|callable> The array of processors
*/
private function getProcessors(Generator $generator): array
{
// Get the standard processors from the generator.
$processors = $generator->getProcessors();
// Remove OperationId processor as we use a lot of generated annotations which do not have enough information in their context
// to generate these ids properly.
// @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::createContext
foreach ($processors as $key => $processor) {
if ($processor instanceof \OpenApi\Processors\OperationId) {
unset($processors[$key]);
}
}
return $processors;
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Command;
use Nelmio\ApiDocBundle\Render\Html\AssetsMode;
use Nelmio\ApiDocBundle\Render\RenderOpenApi;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class DumpCommand extends Command
{
/**
* @var RenderOpenApi
*/
private $renderOpenApi;
/**
* @var mixed[]
*/
private $defaultHtmlConfig = [
'assets_mode' => AssetsMode::CDN,
'swagger_ui_config' => [],
];
public function __construct(RenderOpenApi $renderOpenApi)
{
$this->renderOpenApi = $renderOpenApi;
parent::__construct();
}
/**
* Configures the dump command.
*/
protected function configure(): void
{
$availableFormats = $this->renderOpenApi->getAvailableFormats();
$this
->setDescription('Dumps documentation in OpenAPI format to: '.implode(', ', $availableFormats))
->addOption('area', '', InputOption::VALUE_OPTIONAL, '', 'default')
->addOption(
'format',
'',
InputOption::VALUE_REQUIRED,
'Output format like: '.implode(', ', $availableFormats),
RenderOpenApi::JSON
)
->addOption('server-url', '', InputOption::VALUE_REQUIRED, 'URL where live api doc is served')
->addOption('html-config', '', InputOption::VALUE_REQUIRED, '', json_encode($this->defaultHtmlConfig))
->addOption('no-pretty', '', InputOption::VALUE_NONE, 'Do not pretty format JSON output')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$area = $input->getOption('area');
$format = $input->getOption('format');
$options = [];
if (RenderOpenApi::HTML === $format) {
$rawHtmlConfig = json_decode($input->getOption('html-config'), true);
$options = is_array($rawHtmlConfig) ? $rawHtmlConfig : $this->defaultHtmlConfig;
} elseif (RenderOpenApi::JSON === $format) {
$options = [
'no-pretty' => $input->hasParameterOption(['--no-pretty']),
];
}
if ($input->getOption('server-url')) {
$options['server_url'] = $input->getOption('server-url');
}
$docs = $this->renderOpenApi->render($format, $area, $options);
$output->writeln($docs, OutputInterface::OUTPUT_RAW);
return 0;
}
}
@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Controller;
use Nelmio\ApiDocBundle\Exception\RenderInvalidArgumentException;
use Nelmio\ApiDocBundle\Render\RenderOpenApi;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final class DocumentationController
{
/**
* @var RenderOpenApi
*/
private $renderOpenApi;
public function __construct(RenderOpenApi $renderOpenApi)
{
$this->renderOpenApi = $renderOpenApi;
}
public function __invoke(Request $request, $area = 'default')
{
try {
return JsonResponse::fromJsonString(
$this->renderOpenApi->renderFromRequest($request, RenderOpenApi::JSON, $area)
);
} catch (RenderInvalidArgumentException $e) {
throw new BadRequestHttpException(sprintf('Area "%s" is not supported as it isn\'t defined in config.', $area));
}
}
}
@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Controller;
use Nelmio\ApiDocBundle\Exception\RenderInvalidArgumentException;
use Nelmio\ApiDocBundle\Render\Html\AssetsMode;
use Nelmio\ApiDocBundle\Render\RenderOpenApi;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final class SwaggerUiController
{
/**
* @var RenderOpenApi
*/
private $renderOpenApi;
public function __construct(RenderOpenApi $renderOpenApi)
{
$this->renderOpenApi = $renderOpenApi;
}
public function __invoke(Request $request, $area = 'default')
{
try {
$response = new Response(
$this->renderOpenApi->renderFromRequest($request, RenderOpenApi::HTML, $area, [
'assets_mode' => AssetsMode::BUNDLE,
]),
Response::HTTP_OK,
['Content-Type' => 'text/html']
);
return $response->setCharset('UTF-8');
} catch (RenderInvalidArgumentException $e) {
$advice = '';
if (false !== strpos($area, '.json')) {
$advice = ' Since the area provided contains `.json`, the issue is likely caused by route priorities. Try switching the Swagger UI / the json documentation routes order.';
}
throw new BadRequestHttpException(sprintf('Area "%s" is not supported as it isn\'t defined in config.%s', $area, $advice), $e);
}
}
}
@@ -0,0 +1,46 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Controller;
use InvalidArgumentException;
use Nelmio\ApiDocBundle\Render\RenderOpenApi;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final class YamlDocumentationController
{
/**
* @var RenderOpenApi
*/
private $renderOpenApi;
public function __construct(RenderOpenApi $renderOpenApi)
{
$this->renderOpenApi = $renderOpenApi;
}
public function __invoke(Request $request, $area = 'default')
{
try {
$response = new Response(
$this->renderOpenApi->renderFromRequest($request, RenderOpenApi::YAML, $area),
Response::HTTP_OK,
['Content-Type' => 'text/x-yaml']
);
return $response->setCharset('UTF-8');
} catch (InvalidArgumentException $e) {
throw new BadRequestHttpException(sprintf('Area "%s" is not supported as it isn\'t defined in config.', $area));
}
}
}
@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\DependencyInjection\Compiler;
use Nelmio\ApiDocBundle\ModelDescriber\FormModelDescriber;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Reference;
/**
* Enables the FormModelDescriber only if forms are enabled.
*
* @internal
*/
final class ConfigurationPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if ($container->hasDefinition('form.factory')) {
$container->register('nelmio_api_doc.model_describers.form', FormModelDescriber::class)
->setPublic(false)
->addArgument(new Reference('form.factory'))
->addArgument(new Reference('annotations.reader', ContainerInterface::NULL_ON_INVALID_REFERENCE))
->addArgument($container->getParameter('nelmio_api_doc.media_types'))
->addArgument($container->getParameter('nelmio_api_doc.use_validation_groups'))
->addArgument($container->getParameter('form.type_extension.csrf.enabled'))
->addTag('nelmio_api_doc.model_describer', ['priority' => 100]);
}
$container->getParameterBag()->remove('nelmio_api_doc.media_types');
}
}
@@ -0,0 +1,59 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Compiler Pass to identify and register custom processors.
* *
* @internal
*/
final class CustomProcessorPass implements CompilerPassInterface
{
use PriorityTaggedServiceTrait;
/**
* Process services tagged as 'swagger.processor'.
*
* @param ContainerBuilder $container The container builder
*/
public function process(ContainerBuilder $container): void
{
// Find the OpenAPI generator service.
$definition = $container->findDefinition('nelmio_api_doc.open_api.generator');
foreach ($this->findAndSortTaggedServices('nelmio_api_doc.swagger.processor', $container) as $reference) {
$id = (string) $reference;
$tags = $container->findDefinition($id)->getTags();
/**
* Before which processor should this processor be run?
*
* @var string|null
*/
$before = null;
// See if the processor has a 'before' attribute.
foreach ($tags as $tag) {
if (isset($tag['before'])) {
$before = $tag['before'];
}
}
$definition->addMethodCall('addProcessor', [new Reference($id), $before]);
}
}
}
@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* Enables the `PhpDocExtractor` manually if Symfony did not. Covers the cases where the `phpdocumentor/reflection-docblock` dependency is considered
* dev only and not automatically enabled by Symfony because `nelmio/api-doc-bundle` (which requires it) is required for dev environment only.
*
* @see https://github.com/symfony/symfony/blob/6.1/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php#L1889
*
* @internal
*/
final class PhpDocExtractorPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('property_info.php_doc_extractor')) {
$definition = $container->register('property_info.php_doc_extractor', 'Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor');
$definition->addTag('property_info.description_extractor', ['priority' => -1000]);
$definition->addTag('property_info.type_extractor', ['priority' => -1001]);
}
}
}
@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\DependencyInjection\Compiler;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* @internal
*/
final class TagDescribersPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
foreach ($container->findTaggedServiceIds('nelmio_api_doc.describer') as $id => $tags) {
$describer = $container->getDefinition($id);
foreach ($container->getParameter('nelmio_api_doc.areas') as $area) {
foreach ($tags as $tag) {
$describer->addTag(sprintf('nelmio_api_doc.describer.%s', $area), $tag);
}
}
}
}
}
@@ -0,0 +1,142 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
final class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('nelmio_api_doc');
if (method_exists($treeBuilder, 'getRootNode')) {
$rootNode = $treeBuilder->getRootNode();
} else {
// symfony < 4.2 support
$rootNode = $treeBuilder->root('nelmio_api_doc');
}
$rootNode
->children()
->booleanNode('use_validation_groups')
->info('If true, `groups` passed to @Model annotations will be used to limit validation constraints')
->defaultFalse()
->end()
->arrayNode('documentation')
->useAttributeAsKey('key')
->info('The documentation used as base')
->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(
[
'default' => [
'path_patterns' => [],
'host_patterns' => [],
'with_annotation' => false,
'documentation' => [],
'name_patterns' => [],
'disable_default_routes' => false,
],
]
)
->beforeNormalization()
->ifTrue(function ($v) {
return 0 === count($v) || isset($v['path_patterns']) || isset($v['host_patterns']) || isset($v['documentation']);
})
->then(function ($v) {
return ['default' => $v];
})
->end()
->validate()
->ifTrue(function ($v) {
return !isset($v['default']);
})
->thenInvalid('You must specify a `default` area under `nelmio_api_doc.areas`.')
->end()
->useAttributeAsKey('name')
->prototype('array')
->addDefaultsIfNotSet()
->children()
->arrayNode('path_patterns')
->defaultValue([])
->example(['^/api', '^/api(?!/admin)'])
->prototype('scalar')->end()
->end()
->arrayNode('host_patterns')
->defaultValue([])
->example(['^api\.'])
->prototype('scalar')->end()
->end()
->arrayNode('name_patterns')
->defaultValue([])
->example(['^api_v1'])
->prototype('scalar')->end()
->end()
->booleanNode('with_annotation')
->defaultFalse()
->info('whether to filter by annotation')
->end()
->booleanNode('disable_default_routes')
->defaultFalse()
->info('if set disables default routes without annotations')
->end()
->arrayNode('documentation')
->useAttributeAsKey('key')
->defaultValue([])
->info('The documentation used for area')
->example(['info' => ['title' => 'My App']])
->prototype('variable')->end()
->end()
->end()
->end()
->end()
->arrayNode('models')
->addDefaultsIfNotSet()
->children()
->booleanNode('use_jms')->defaultFalse()->end()
->end()
->children()
->arrayNode('names')
->prototype('array')
->children()
->scalarNode('alias')->isRequired()->end()
->scalarNode('type')->isRequired()->end()
->variableNode('groups')
->defaultValue(null)
->validate()
->ifTrue(function ($v) { return null !== $v && !is_array($v); })
->thenInvalid('Model groups must be either `null` or an array.')
->end()
->end()
->arrayNode('areas')
->defaultValue([])
->prototype('scalar')->end()
->end()
->end()
->end()
->end()
->end()
->end();
return $treeBuilder;
}
}
@@ -0,0 +1,300 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\DependencyInjection;
use FOS\RestBundle\Controller\Annotations\ParamInterface;
use JMS\Serializer\ContextFactory\SerializationContextFactoryInterface;
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\ModelDescriber\BazingaHateoasModelDescriber;
use Nelmio\ApiDocBundle\ModelDescriber\JMSModelDescriber;
use Nelmio\ApiDocBundle\ModelDescriber\ModelDescriberInterface;
use Nelmio\ApiDocBundle\Processor\MapQueryStringProcessor;
use Nelmio\ApiDocBundle\Processor\MapRequestPayloadProcessor;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\RouteArgumentDescriberInterface;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapQueryParameterDescriber;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapQueryStringDescriber;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapRequestPayloadDescriber;
use Nelmio\ApiDocBundle\Routing\FilteredRouteCollectionBuilder;
use OpenApi\Generator;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Routing\RouteCollection;
final class NelmioApiDocExtension extends Extension implements PrependExtensionInterface
{
/**
* {@inheritdoc}
*/
public function prepend(ContainerBuilder $container): void
{
$container->prependExtensionConfig('framework', ['property_info' => ['enabled' => true]]);
$bundles = $container->getParameter('kernel.bundles');
// JMS Serializer support
if (isset($bundles['JMSSerializerBundle'])) {
$container->prependExtensionConfig('nelmio_api_doc', ['models' => ['use_jms' => true]]);
}
}
/**
* {@inheritdoc}
*/
public function load(array $configs, ContainerBuilder $container): void
{
$config = $this->processConfiguration(new Configuration(), $configs);
$loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->load('services.xml');
// Filter routes
$routesDefinition = (new Definition(RouteCollection::class))
->setFactory([new Reference('router'), 'getRouteCollection']);
$container->setParameter('nelmio_api_doc.areas', array_keys($config['areas']));
$container->setParameter('nelmio_api_doc.media_types', $config['media_types']);
$container->setParameter('nelmio_api_doc.use_validation_groups', $config['use_validation_groups']);
// Register the OpenAPI Generator as a service.
$container->register('nelmio_api_doc.open_api.generator', Generator::class)
->setPublic(false);
foreach ($config['areas'] as $area => $areaConfig) {
$nameAliases = $this->findNameAliases($config['models']['names'], $area);
$container->register(sprintf('nelmio_api_doc.generator.%s', $area), ApiDocGenerator::class)
->setPublic(true)
->addMethodCall('setAlternativeNames', [$nameAliases])
->addMethodCall('setMediaTypes', [$config['media_types']])
->addMethodCall('setLogger', [new Reference('logger')])
->addMethodCall('setOpenApiVersion', [$config['documentation']['openapi'] ?? null])
->addTag('monolog.logger', ['channel' => 'nelmio_api_doc'])
->setArguments([
new TaggedIteratorArgument(sprintf('nelmio_api_doc.describer.%s', $area)),
new TaggedIteratorArgument('nelmio_api_doc.model_describer'),
null,
null,
new Reference('nelmio_api_doc.open_api.generator'),
]);
$container->register(sprintf('nelmio_api_doc.describers.route.%s', $area), RouteDescriber::class)
->setPublic(false)
->setArguments([
new Reference(sprintf('nelmio_api_doc.routes.%s', $area)),
new Reference('nelmio_api_doc.controller_reflector'),
new TaggedIteratorArgument('nelmio_api_doc.route_describer'),
])
->addTag(sprintf('nelmio_api_doc.describer.%s', $area), ['priority' => -400]);
$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('annotations.reader', ContainerInterface::NULL_ON_INVALID_REFERENCE), // We cannot use the cached version of the annotation reader since the construction of the annotations is context dependant...
new Reference('logger'),
])
->addTag(sprintf('nelmio_api_doc.describer.%s', $area), ['priority' => -200]);
$container->register(sprintf('nelmio_api_doc.describers.config.%s', $area), ExternalDocDescriber::class)
->setPublic(false)
->setArguments([
$areaConfig['documentation'],
true,
])
->addTag(sprintf('nelmio_api_doc.describer.%s', $area), ['priority' => 990]);
unset($areaConfig['documentation']);
if (0 === count($areaConfig['path_patterns'])
&& 0 === count($areaConfig['host_patterns'])
&& 0 === count($areaConfig['name_patterns'])
&& false === $areaConfig['with_annotation']
&& false === $areaConfig['disable_default_routes']
) {
$container->setDefinition(sprintf('nelmio_api_doc.routes.%s', $area), $routesDefinition)
->setPublic(false);
} else {
$container->register(sprintf('nelmio_api_doc.routes.%s', $area), RouteCollection::class)
->setPublic(false)
->setFactory([
(new Definition(FilteredRouteCollectionBuilder::class))
->setArguments(
[
new Reference('annotation_reader', ContainerInterface::NULL_ON_INVALID_REFERENCE), // Here we use the cached version as we don't deal with @OA annotations in this service
new Reference('nelmio_api_doc.controller_reflector'),
$area,
$areaConfig,
]
),
'filter',
])
->addArgument($routesDefinition);
}
}
$container->register('nelmio_api_doc.generator_locator', ServiceLocator::class)
->setPublic(false)
->addTag('container.service_locator')
->addArgument(array_combine(
array_keys($config['areas']),
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']);
// Add autoconfiguration for model describer
$container->registerForAutoconfiguration(ModelDescriberInterface::class)
->addTag('nelmio_api_doc.model_describer');
// 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']);
}
if (PHP_VERSION_ID > 80100) {
// Add autoconfiguration for route argument describer
$container->registerForAutoconfiguration(RouteArgumentDescriberInterface::class)
->addTag('nelmio_api_doc.route_argument_describer');
$container->register('nelmio_api_doc.route_describers.route_argument', RouteArgumentDescriber::class)
->setPublic(false)
->addTag('nelmio_api_doc.route_describer', ['priority' => -225])
->setArguments([
new Reference('argument_metadata_factory'),
new TaggedIteratorArgument('nelmio_api_doc.route_argument_describer'),
])
;
if (class_exists(MapQueryString::class)) {
$container->register('nelmio_api_doc.route_argument_describer.map_query_string', SymfonyMapQueryStringDescriber::class)
->setPublic(false)
->addTag('nelmio_api_doc.route_argument_describer', ['priority' => 0]);
$container->register('nelmio_api_doc.swagger.processor.map_query_string', MapQueryStringProcessor::class)
->setPublic(false)
->addTag('nelmio_api_doc.swagger.processor', ['priority' => 0]);
}
if (class_exists(MapRequestPayload::class)) {
$container->register('nelmio_api_doc.route_argument_describer.map_request_payload', SymfonyMapRequestPayloadDescriber::class)
->setPublic(false)
->addTag('nelmio_api_doc.route_argument_describer', ['priority' => 0]);
$container->register('nelmio_api_doc.swagger.processor.map_request_payload', MapRequestPayloadProcessor::class)
->setPublic(false)
->addTag('nelmio_api_doc.swagger.processor', ['priority' => 0]);
}
if (class_exists(MapQueryParameter::class)) {
$container->register('nelmio_api_doc.route_argument_describer.map_query_parameter', SymfonyMapQueryParameterDescriber::class)
->setPublic(false)
->addTag('nelmio_api_doc.route_argument_describer', ['priority' => 0]);
}
}
$bundles = $container->getParameter('kernel.bundles');
if (!isset($bundles['TwigBundle']) || !class_exists('Symfony\Component\Asset\Packages')) {
$container->removeDefinition('nelmio_api_doc.controller.swagger_ui');
$container->removeDefinition('nelmio_api_doc.render_docs.html');
$container->removeDefinition('nelmio_api_doc.render_docs.html.asset');
}
// ApiPlatform support
if (isset($bundles['ApiPlatformBundle']) && class_exists('ApiPlatform\Documentation\Documentation')) {
$loader->load('api_platform.xml');
}
// JMS metadata support
if ($config['models']['use_jms']) {
$jmsNamingStrategy = interface_exists(SerializationVisitorInterface::class) ? null : new Reference('jms_serializer.naming_strategy');
$contextFactory = interface_exists(SerializationContextFactoryInterface::class) ? new Reference('jms_serializer.serialization_context_factory') : null;
$container->register('nelmio_api_doc.model_describers.jms', JMSModelDescriber::class)
->setPublic(false)
->setArguments([
new Reference('jms_serializer.metadata_factory'),
new Reference('annotations.reader', ContainerInterface::NULL_ON_INVALID_REFERENCE),
$config['media_types'],
$jmsNamingStrategy,
$container->getParameter('nelmio_api_doc.use_validation_groups'),
$contextFactory,
])
->addTag('nelmio_api_doc.model_describer', ['priority' => 50]);
// Bazinga Hateoas metadata support
if (isset($bundles['BazingaHateoasBundle'])) {
$container->register('nelmio_api_doc.model_describers.jms.bazinga_hateoas', BazingaHateoasModelDescriber::class)
->setDecoratedService('nelmio_api_doc.model_describers.jms', 'nelmio_api_doc.model_describers.jms.inner')
->setPublic(false)
->setArguments([
new Reference('hateoas.configuration.metadata_factory'),
new Reference('nelmio_api_doc.model_describers.jms.inner'),
]);
}
} else {
$container->removeDefinition('nelmio_api_doc.model_describers.object_fallback');
}
// Import the base configuration
$container->getDefinition('nelmio_api_doc.describers.config')->replaceArgument(0, $config['documentation']);
// Compatibility Symfony
$controllerNameConverter = null;
if ($container->hasDefinition('.legacy_controller_name_converter')) { // 4.4
$controllerNameConverter = $container->getDefinition('.legacy_controller_name_converter');
} elseif ($container->hasDefinition('controller_name_converter')) { // < 4.4
$controllerNameConverter = $container->getDefinition('controller_name_converter');
}
if (null !== $controllerNameConverter) {
$container->getDefinition('nelmio_api_doc.controller_reflector')->setArgument(1, $controllerNameConverter);
}
}
private function findNameAliases(array $names, string $area): array
{
$nameAliases = array_filter($names, function (array $aliasInfo) use ($area) {
return empty($aliasInfo['areas']) || in_array($area, $aliasInfo['areas'], true);
});
$aliases = [];
foreach ($nameAliases as $nameAlias) {
$aliases[$nameAlias['alias']] = [
'type' => $nameAlias['type'],
'groups' => $nameAlias['groups'],
];
}
return $aliases;
}
}
@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Describer;
use ApiPlatform\Core\Swagger\Serializer\DocumentationNormalizer;
use ApiPlatform\Documentation\DocumentationInterface;
use ApiPlatform\OpenApi\OpenApi;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final class ApiPlatformDescriber extends ExternalDocDescriber
{
public function __construct(object $documentation, NormalizerInterface $normalizer)
{
if (!$documentation instanceof DocumentationInterface && !$documentation instanceof OpenApi) {
throw new \InvalidArgumentException(sprintf('Argument 1 passed to %s() must be an instance of %s or %s. The documentation provided is an instance of %s.', __METHOD__, Documentation::class, OpenApi::class, get_class($documentation)));
}
if (!$normalizer->supportsNormalization($documentation, 'json')) {
throw new \InvalidArgumentException(sprintf('Argument 2 passed to %s() must implement %s and support normalization of %s. The normalizer provided is an instance of %s.', __METHOD__, NormalizerInterface::class, Documentation::class, get_class($normalizer)));
}
parent::__construct(function () use ($documentation, $normalizer) {
$documentation = (array) $normalizer->normalize(
$documentation,
null,
class_exists(DocumentationNormalizer::class) ? [DocumentationNormalizer::SPEC_VERSION => 3] : []
);
// TODO: remove this
// Temporary fix: zircote/swagger-php does no longer support 3.0.x with x > 0
unset($documentation['openapi']);
unset($documentation['basePath']);
unset($documentation['servers']);
return $documentation;
});
}
}
@@ -0,0 +1,53 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Describer;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
/**
* Makes the swagger documentation valid even if there are missing fields.
*
* @author Ener-Getick <egetick@gmail.com>
*/
final class DefaultDescriber implements DescriberInterface
{
public function describe(OA\OpenApi $api)
{
// Info
/** @var OA\Info $info */
$info = Util::getChild($api, OA\Info::class);
if (Generator::UNDEFINED === $info->title) {
$info->title = '';
}
if (Generator::UNDEFINED === $info->version) {
$info->version = '0.0.0';
}
// Paths
if (Generator::UNDEFINED === $api->paths) {
$api->paths = [];
}
foreach ($api->paths as $path) {
foreach (Util::OPERATIONS as $method) {
/** @var OA\Operation $operation */
$operation = $path->{$method};
if (Generator::UNDEFINED !== $operation && null !== $operation && (Generator::UNDEFINED === $operation->responses || empty($operation->responses))) {
/** @var OA\Response $response */
$response = Util::getIndexedCollectionItem($operation, OA\Response::class, 'default');
$response->description = '';
}
}
}
}
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Describer;
use OpenApi\Annotations\OpenApi;
interface DescriberInterface
{
public function describe(OpenApi $api);
}
@@ -0,0 +1,49 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Describer;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
class ExternalDocDescriber implements DescriberInterface
{
private $externalDoc;
private $overwrite;
/**
* @param array|callable $externalDoc
*/
public function __construct($externalDoc, bool $overwrite = false)
{
$this->externalDoc = $externalDoc;
$this->overwrite = $overwrite;
}
public function describe(OA\OpenApi $api)
{
$externalDoc = $this->getExternalDoc();
if (!empty($externalDoc)) {
Util::merge($api, $externalDoc, $this->overwrite);
}
}
private function getExternalDoc()
{
if (is_callable($this->externalDoc)) {
return call_user_func($this->externalDoc);
}
return $this->externalDoc;
}
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Describer;
use Nelmio\ApiDocBundle\Model\ModelRegistry;
interface ModelRegistryAwareInterface
{
public function setModelRegistry(ModelRegistry $modelRegistry);
}
@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Describer;
use Nelmio\ApiDocBundle\Model\ModelRegistry;
trait ModelRegistryAwareTrait
{
/**
* @var ModelRegistry
*/
private $modelRegistry;
public function setModelRegistry(ModelRegistry $modelRegistry)
{
$this->modelRegistry = $modelRegistry;
}
}
@@ -0,0 +1,232 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Describer;
use Doctrine\Common\Annotations\Reader;
use Nelmio\ApiDocBundle\Annotation\Operation;
use Nelmio\ApiDocBundle\Annotation\Security;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use Nelmio\ApiDocBundle\Util\ControllerReflector;
use Nelmio\ApiDocBundle\Util\SetsContextTrait;
use OpenApi\Analysers\AttributeAnnotationFactory;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use Psr\Log\LoggerInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
// Help opcache.preload discover Swagger\Annotations\Swagger
class_exists(OA\OpenApi::class);
final class OpenApiPhpDescriber
{
use SetsContextTrait;
private $routeCollection;
private $controllerReflector;
/**
* @var Reader|null
*/
private $annotationReader;
private $logger;
private $overwrite;
public function __construct(RouteCollection $routeCollection, ControllerReflector $controllerReflector, ?Reader $annotationReader, LoggerInterface $logger, bool $overwrite = false)
{
$this->routeCollection = $routeCollection;
$this->controllerReflector = $controllerReflector;
$this->annotationReader = $annotationReader;
$this->logger = $logger;
$this->overwrite = $overwrite;
}
public function describe(OA\OpenApi $api)
{
$classAnnotations = [];
/** @var \ReflectionMethod $method */
foreach ($this->getMethodsToParse() as $method => [$path, $httpMethods, $routeName]) {
$declaringClass = $method->getDeclaringClass();
$path = Util::getPath($api, $path);
$context = Util::createContext(['nested' => $path], $path->_context);
$context->namespace = $declaringClass->getNamespaceName();
$context->class = $declaringClass->getShortName();
$context->method = $method->name;
$context->filename = $method->getFileName();
$this->setContext($context);
if (!array_key_exists($declaringClass->getName(), $classAnnotations)) {
$classAnnotations = [];
if (null !== $this->annotationReader) {
$classAnnotations = $this->annotationReader->getClassAnnotations($declaringClass);
}
$classAnnotations = array_filter($classAnnotations, function ($v) {
return $v instanceof OA\AbstractAnnotation;
});
$classAnnotations = array_merge($classAnnotations, $this->getAttributesAsAnnotation($declaringClass, $context));
$classAnnotations[$declaringClass->getName()] = $classAnnotations;
}
$annotations = [];
if (null !== $this->annotationReader) {
$annotations = array_filter($this->annotationReader->getMethodAnnotations($method), function ($v) {
return $v instanceof OA\AbstractAnnotation;
});
}
$annotations = array_merge($annotations, $this->getAttributesAsAnnotation($method, $context));
if (0 === count($annotations) && 0 === count($classAnnotations[$declaringClass->getName()])) {
continue;
}
$implicitAnnotations = [];
$mergeProperties = new \stdClass();
foreach (array_merge($annotations, $classAnnotations[$declaringClass->getName()]) as $annotation) {
if ($annotation instanceof Operation) {
foreach ($httpMethods as $httpMethod) {
$operation = Util::getOperation($path, $httpMethod);
$operation->mergeProperties($annotation);
}
continue;
}
if ($annotation instanceof OA\Operation) {
if (!in_array($annotation->method, $httpMethods, true)) {
continue;
}
if (Generator::UNDEFINED !== $annotation->path && $path->path !== $annotation->path) {
continue;
}
$operation = Util::getOperation($path, $annotation->method);
$operation->mergeProperties($annotation);
continue;
}
if ($annotation instanceof Security) {
$annotation->validate();
if (null === $annotation->name) {
$mergeProperties->security = [];
continue;
}
$mergeProperties->security[] = [$annotation->name => $annotation->scopes];
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));
}
$implicitAnnotations[] = $annotation;
}
if (empty($implicitAnnotations) && empty(get_object_vars($mergeProperties))) {
continue;
}
foreach ($httpMethods as $httpMethod) {
$operation = Util::getOperation($path, $httpMethod);
$operation->merge($implicitAnnotations);
$operation->mergeProperties($mergeProperties);
if (Generator::UNDEFINED === $operation->operationId) {
$operation->operationId = $httpMethod.'_'.$routeName;
}
}
}
// Reset the Generator after the parsing
$this->setContext(null);
}
private function getMethodsToParse(): \Generator
{
foreach ($this->routeCollection->all() as $routeName => $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, $routeName];
}
}
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;
}
/**
* @param \ReflectionClass|\ReflectionMethod $reflection
*
* @return OA\AbstractAnnotation[]
*/
private function getAttributesAsAnnotation($reflection, \OpenApi\Context $context): array
{
$attributesFactory = new AttributeAnnotationFactory();
$attributes = $attributesFactory->build($reflection, $context);
// The attributes factory removes the context after executing so we need to set it back...
$this->setContext($context);
return $attributes;
}
}
@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Describer;
use Nelmio\ApiDocBundle\RouteDescriber\RouteDescriberInterface;
use Nelmio\ApiDocBundle\Util\ControllerReflector;
use OpenApi\Annotations as OA;
use Symfony\Component\Routing\RouteCollection;
final class RouteDescriber implements DescriberInterface, ModelRegistryAwareInterface
{
use ModelRegistryAwareTrait;
private $routeCollection;
private $controllerReflector;
private $routeDescribers;
/**
* @param RouteDescriberInterface[]|iterable $routeDescribers
*/
public function __construct(RouteCollection $routeCollection, ControllerReflector $controllerReflector, $routeDescribers)
{
$this->routeCollection = $routeCollection;
$this->controllerReflector = $controllerReflector;
$this->routeDescribers = $routeDescribers;
}
public function describe(OA\OpenApi $api)
{
if (0 === count($this->routeDescribers)) {
return;
}
foreach ($this->routeCollection->all() as $route) {
if (!$route->hasDefault('_controller')) {
continue;
}
// if able to resolve the controller
$controller = $route->getDefault('_controller');
if ($method = $this->controllerReflector->getReflectionMethod($controller)) {
// Extract as many information as possible about this route
foreach ($this->routeDescribers as $describer) {
if ($describer instanceof ModelRegistryAwareInterface) {
$describer->setModelRegistry($this->modelRegistry);
}
$describer->describe($api, $route, $method);
}
}
}
}
}
@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Exception;
class RenderInvalidArgumentException extends \InvalidArgumentException
{
}
@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Exception;
/**
* @deprecated since 4.17, this exception is not used anymore
*/
class UndocumentedArrayItemsException extends \LogicException
{
private $class;
private $path;
public function __construct(string $class = null, string $path = '')
{
$this->class = $class;
$this->path = $path;
$propertyName = '';
if (null !== $class) {
$propertyName = $class.'::';
}
$propertyName .= $path;
parent::__construct(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 `@OA\Property(type="array", @OA\Items(type="string"))`.', $propertyName));
}
public function getClass()
{
return $this->class;
}
public function getPath()
{
return $this->path;
}
}
@@ -0,0 +1,44 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @author Aaron Scherer <aequasi@gmail.com>
*/
class DocumentationExtension extends AbstractTypeExtension
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->setAttribute('documentation', $options['documentation']);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['documentation' => []])
->setAllowedTypes('documentation', ['array', 'bool']);
}
public function getExtendedType()
{
return self::getExtendedTypes()[0];
}
public static function getExtendedTypes(): iterable
{
return [FormType::class];
}
}
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Nelmio
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+72
View File
@@ -0,0 +1,72 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Model;
use Symfony\Component\PropertyInfo\Type;
final class Model
{
private $type;
private $options;
private $serializationContext;
/**
* @param string[]|null $groups
*/
public function __construct(Type $type, array $groups = null, array $options = null, array $serializationContext = [])
{
$this->type = $type;
$this->options = $options;
$this->serializationContext = $serializationContext;
if (null !== $groups) {
$this->serializationContext['groups'] = $groups;
}
}
/**
* @return Type
*/
public function getType()
{
return $this->type;
}
/**
* @return string[]|null
*/
public function getGroups()
{
return $this->serializationContext['groups'] ?? null;
}
/**
* @return array<string, mixed>
*/
public function getSerializationContext(): array
{
return $this->serializationContext;
}
public function getHash(): string
{
return md5(serialize([$this->type, $this->getSerializationContext()]));
}
/**
* @return mixed[]|null
*/
public function getOptions()
{
return $this->options;
}
}
+223
View File
@@ -0,0 +1,223 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Model;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface;
use Nelmio\ApiDocBundle\ModelDescriber\ModelDescriberInterface;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
use Symfony\Component\PropertyInfo\Type;
final class ModelRegistry
{
use LoggerAwareTrait;
private $registeredModelNames = [];
private $alternativeNames = [];
private $unregistered = [];
private $models = [];
private $names = [];
private $modelDescribers = [];
private $api;
/**
* @param ModelDescriberInterface[]|iterable $modelDescribers
*
* @internal
*/
public function __construct($modelDescribers, OA\OpenApi $api, array $alternativeNames = [])
{
$this->modelDescribers = $modelDescribers;
$this->api = $api;
$this->logger = new NullLogger();
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->registeredModelNames[$alternativeName] = $model;
Util::getSchema($this->api, $alternativeName);
}
}
public function register(Model $model): string
{
$hash = $model->getHash();
if (!isset($this->models[$hash])) {
$this->models[$hash] = $model;
$this->unregistered[] = $hash;
}
if (!isset($this->names[$hash])) {
$this->names[$hash] = $this->generateModelName($model);
$this->registeredModelNames[$this->names[$hash]] = $model;
}
// Reserve the name
Util::getSchema($this->api, $this->names[$hash]);
return OA\Components::SCHEMA_REF.$this->names[$hash];
}
/**
* @internal
*/
public function registerSchemas(): void
{
while (count($this->unregistered)) {
$tmp = [];
foreach ($this->unregistered as $hash) {
$tmp[$this->names[$hash]] = $this->models[$hash];
}
$this->unregistered = [];
foreach ($tmp as $name => $model) {
$schema = null;
foreach ($this->modelDescribers as $modelDescriber) {
if ($modelDescriber instanceof ModelRegistryAwareInterface) {
$modelDescriber->setModelRegistry($this);
}
if ($modelDescriber->supports($model)) {
$schema = Util::getSchema($this->api, $name);
$modelDescriber->describe($model, $schema);
break;
}
}
if (null === $schema) {
$errorMessage = sprintf('Schema of type "%s" can\'t be generated, no describer supports it.', $this->typeToString($model->getType()));
if (Type::BUILTIN_TYPE_OBJECT === $model->getType()->getBuiltinType() && !class_exists($className = $model->getType()->getClassName())) {
$errorMessage .= sprintf(' Class "\\%s" does not exist, did you forget a use statement, or typed it wrong?', $className);
}
throw new \LogicException($errorMessage);
}
}
}
if (empty($this->unregistered) && !empty($this->alternativeNames)) {
foreach ($this->alternativeNames as $model) {
$this->register($model);
}
$this->alternativeNames = [];
$this->registerSchemas();
}
}
private function generateModelName(Model $model): string
{
$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 (\in_array($name, $names, true)) {
if (isset($this->registeredModelNames[$name])) {
$this->logger->info(sprintf('Can not assign a name for the model, the name "%s" has already been taken.', $name), [
'model' => $this->modelToArray($model),
'taken_by' => $this->modelToArray($this->registeredModelNames[$name]),
]);
}
++$i;
$name = $base.$i;
}
return $name;
}
private function modelToArray(Model $model): array
{
$getType = function (Type $type) use (&$getType) {
return [
'class' => $type->getClassName(),
'built_in_type' => $type->getBuiltinType(),
'nullable' => $type->isNullable(),
'collection' => $type->isCollection(),
'collection_key_types' => $type->isCollection() ? array_map($getType, $this->getCollectionKeyTypes($type)) : null,
'collection_value_types' => $type->isCollection() ? array_map($getType, $this->getCollectionValueTypes($type)) : null,
];
};
return [
'type' => $getType($model->getType()),
'options' => $model->getOptions(),
'groups' => $model->getGroups(),
'serialization_context' => $model->getSerializationContext(),
];
}
private function getTypeShortName(Type $type): string
{
if (null !== $collectionType = $this->getCollectionValueType($type)) {
return $this->getTypeShortName($collectionType).'[]';
}
if (Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType()) {
$parts = explode('\\', $type->getClassName());
return end($parts);
}
return $type->getBuiltinType();
}
private function typeToString(Type $type): string
{
if (Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType()) {
return '\\'.$type->getClassName();
} elseif ($type->isCollection()) {
if (null !== $collectionType = $this->getCollectionValueType($type)) {
return $this->typeToString($collectionType).'[]';
} else {
return 'mixed[]';
}
} else {
return $type->getBuiltinType();
}
}
private function getCollectionKeyTypes(Type $type): array
{
// BC layer, this condition should be removed after removing support for symfony < 5.3
if (!method_exists($type, 'getCollectionKeyTypes')) {
return null !== $type->getCollectionKeyType() ? [$type->getCollectionKeyType()] : [];
}
return $type->getCollectionKeyTypes();
}
private function getCollectionValueTypes(Type $type): array
{
// BC layer, this condition should be removed after removing support for symfony < 5.3
if (!method_exists($type, 'getCollectionValueTypes')) {
return null !== $type->getCollectionValueType() ? [$type->getCollectionValueType()] : [];
}
return $type->getCollectionValueTypes();
}
private function getCollectionValueType(Type $type): ?Type
{
// BC layer, this condition should be removed after removing support for symfony < 5.3
if (!method_exists($type, 'getCollectionValueTypes')) {
return $type->getCollectionValueType();
}
return $type->getCollectionValueTypes()[0] ?? null;
}
}
@@ -0,0 +1,74 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber\Annotations;
use Doctrine\Common\Annotations\Reader;
use Nelmio\ApiDocBundle\Model\ModelRegistry;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
/**
* @internal
*/
class AnnotationsReader
{
private $phpDocReader;
private $openApiAnnotationsReader;
private $symfonyConstraintAnnotationReader;
public function __construct(
?Reader $annotationsReader,
ModelRegistry $modelRegistry,
array $mediaTypes,
bool $useValidationGroups = false
) {
$this->phpDocReader = new PropertyPhpDocReader();
$this->openApiAnnotationsReader = new OpenApiAnnotationsReader($annotationsReader, $modelRegistry, $mediaTypes);
$this->symfonyConstraintAnnotationReader = new SymfonyConstraintAnnotationReader(
$annotationsReader,
$useValidationGroups
);
}
public function updateDefinition(\ReflectionClass $reflectionClass, OA\Schema $schema): UpdateClassDefinitionResult
{
$this->openApiAnnotationsReader->updateSchema($reflectionClass, $schema);
$this->symfonyConstraintAnnotationReader->setSchema($schema);
return new UpdateClassDefinitionResult(
$this->shouldDescribeModelProperties($schema)
);
}
public function getPropertyName($reflection, string $default): string
{
return $this->openApiAnnotationsReader->getPropertyName($reflection, $default);
}
public function updateProperty($reflection, OA\Property $property, array $serializationGroups = null): void
{
$this->openApiAnnotationsReader->updateProperty($reflection, $property, $serializationGroups);
$this->phpDocReader->updateProperty($reflection, $property);
$this->symfonyConstraintAnnotationReader->updateProperty($reflection, $property, $serializationGroups);
}
/**
* if an objects schema type and ref are undefined OR the object was manually
* defined as an object, then we're good to do the normal describe flow of
* class properties.
*/
private function shouldDescribeModelProperties(OA\Schema $schema): bool
{
return (Generator::UNDEFINED === $schema->type || 'object' === $schema->type)
&& Generator::UNDEFINED === $schema->ref;
}
}
@@ -0,0 +1,118 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber\Annotations;
use Doctrine\Common\Annotations\Reader;
use Nelmio\ApiDocBundle\Model\ModelRegistry;
use Nelmio\ApiDocBundle\OpenApiPhp\ModelRegister;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use Nelmio\ApiDocBundle\Util\SetsContextTrait;
use OpenApi\Analysis;
use OpenApi\Annotations as OA;
use OpenApi\Context;
use OpenApi\Generator;
/**
* @internal
*/
class OpenApiAnnotationsReader
{
use SetsContextTrait;
/**
* @var Reader|null
*/
private $annotationsReader;
private $modelRegister;
public function __construct(?Reader $annotationsReader, ModelRegistry $modelRegistry, array $mediaTypes)
{
$this->annotationsReader = $annotationsReader;
$this->modelRegister = new ModelRegister($modelRegistry, $mediaTypes);
}
public function updateSchema(\ReflectionClass $reflectionClass, OA\Schema $schema): void
{
/** @var OA\Schema|null $oaSchema */
if (!$oaSchema = $this->getAnnotation($schema->_context, $reflectionClass, OA\Schema::class)) {
return;
}
// Read @Model annotations
$this->modelRegister->__invoke(new Analysis([$oaSchema], Util::createContext()));
if (!$oaSchema->validate()) {
return;
}
$schema->mergeProperties($oaSchema);
}
public function getPropertyName($reflection, string $default): string
{
/** @var OA\Property|null $oaProperty */
if (!$oaProperty = $this->getAnnotation(new Context(), $reflection, OA\Property::class)) {
return $default;
}
return Generator::UNDEFINED !== $oaProperty->property ? $oaProperty->property : $default;
}
public function updateProperty($reflection, OA\Property $property, array $serializationGroups = null): void
{
/** @var OA\Property|null $oaProperty */
if (!$oaProperty = $this->getAnnotation($property->_context, $reflection, OA\Property::class)) {
return;
}
// Read @Model annotations
$this->modelRegister->__invoke(new Analysis([$oaProperty], Util::createContext()), $serializationGroups);
if (!$oaProperty->validate()) {
return;
}
$property->mergeProperties($oaProperty);
}
/**
* @param \ReflectionClass|\ReflectionProperty|\ReflectionMethod $reflection
*
* @return mixed
*/
private function getAnnotation(Context $parentContext, $reflection, string $className)
{
$this->setContextFromReflection($parentContext, $reflection);
try {
if (\PHP_VERSION_ID >= 80100) {
if (null !== $attribute = $reflection->getAttributes($className, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null) {
return $attribute->newInstance();
}
}
if (null !== $this->annotationsReader) {
if ($reflection instanceof \ReflectionClass) {
return $this->annotationsReader->getClassAnnotation($reflection, $className);
} elseif ($reflection instanceof \ReflectionProperty) {
return $this->annotationsReader->getPropertyAnnotation($reflection, $className);
} elseif ($reflection instanceof \ReflectionMethod) {
return $this->annotationsReader->getMethodAnnotation($reflection, $className);
}
}
} finally {
$this->setContext(null);
}
return null;
}
}
@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber\Annotations;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use phpDocumentor\Reflection\DocBlock\Tags\Var_;
use phpDocumentor\Reflection\DocBlockFactory;
/**
* Extract information about properties of a model from the DocBlock comment.
*
* @internal
*/
class PropertyPhpDocReader
{
private $docBlockFactory;
public function __construct()
{
$this->docBlockFactory = DocBlockFactory::createInstance();
}
/**
* Update the Swagger information with information from the DocBlock comment.
*/
public function updateProperty($reflection, OA\Property $property): void
{
try {
$docBlock = $this->docBlockFactory->create($reflection);
} catch (\Exception $e) {
// ignore
return;
}
if (!$title = $docBlock->getSummary()) {
/** @var Var_ $var */
foreach ($docBlock->getTagsByName('var') as $var) {
if (!method_exists($var, 'getDescription') || !$description = $var->getDescription()) {
continue;
}
$title = $description->render();
if ($title) {
break;
}
}
}
if (Generator::UNDEFINED === $property->title && $title) {
$property->title = $title;
}
if (Generator::UNDEFINED === $property->description && $docBlock->getDescription() && $docBlock->getDescription()->render()) {
$property->description = $docBlock->getDescription()->render();
}
}
}
@@ -0,0 +1,233 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber\Annotations;
use Doctrine\Common\Annotations\Reader;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use Nelmio\ApiDocBundle\Util\SetsContextTrait;
use OpenApi\Annotations as OA;
use OpenApi\Context;
use OpenApi\Generator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @internal
*/
class SymfonyConstraintAnnotationReader
{
use SetsContextTrait;
/**
* @var Reader|null
*/
private $annotationsReader;
/**
* @var OA\Schema
*/
private $schema;
/**
* @var bool
*/
private $useValidationGroups;
public function __construct(?Reader $annotationsReader, bool $useValidationGroups = false)
{
$this->annotationsReader = $annotationsReader;
$this->useValidationGroups = $useValidationGroups;
}
/**
* Update the given property and schema with defined Symfony constraints.
*
* @param \ReflectionProperty|\ReflectionMethod $reflection
*/
public function updateProperty($reflection, OA\Property $property, ?array $validationGroups = null): void
{
foreach ($this->getAnnotations($property->_context, $reflection, $validationGroups) as $outerAnnotation) {
$innerAnnotations = $outerAnnotation instanceof Assert\Compound || $outerAnnotation instanceof Assert\Sequentially
? $outerAnnotation->constraints
: [$outerAnnotation];
$this->processPropertyAnnotations($reflection, $property, $innerAnnotations);
}
}
private function processPropertyAnnotations($reflection, OA\Property $property, $annotations)
{
foreach ($annotations as $annotation) {
if ($annotation instanceof Assert\NotBlank || $annotation instanceof Assert\NotNull) {
// To support symfony/validator < 4.3
if ($annotation instanceof Assert\NotBlank && \property_exists($annotation, 'allowNull') && $annotation->allowNull) {
// The field is optional
return;
}
// The field is required
if (null === $this->schema) {
return;
}
$propertyName = Util::getSchemaPropertyName($this->schema, $property);
if (null === $propertyName) {
return;
}
$existingRequiredFields = Generator::UNDEFINED !== $this->schema->required ? $this->schema->required : [];
$existingRequiredFields[] = $propertyName;
$this->schema->required = array_values(array_unique($existingRequiredFields));
} elseif ($annotation instanceof Assert\Length) {
if (isset($annotation->min)) {
$property->minLength = (int) $annotation->min;
}
if (isset($annotation->max)) {
$property->maxLength = (int) $annotation->max;
}
} elseif ($annotation instanceof Assert\Regex) {
$this->appendPattern($property, $annotation->getHtmlPattern());
} elseif ($annotation instanceof Assert\Count) {
if (isset($annotation->min)) {
$property->minItems = (int) $annotation->min;
}
if (isset($annotation->max)) {
$property->maxItems = (int) $annotation->max;
}
} elseif ($annotation instanceof Assert\Choice) {
$this->applyEnumFromChoiceConstraint($property, $annotation, $reflection);
} elseif ($annotation instanceof Assert\Range) {
if (\is_int($annotation->min)) {
$property->minimum = $annotation->min;
}
if (\is_int($annotation->max)) {
$property->maximum = $annotation->max;
}
} elseif ($annotation instanceof Assert\LessThan) {
if (\is_int($annotation->value)) {
$property->exclusiveMaximum = true;
$property->maximum = $annotation->value;
}
} elseif ($annotation instanceof Assert\LessThanOrEqual) {
if (\is_int($annotation->value)) {
$property->maximum = $annotation->value;
}
} elseif ($annotation instanceof Assert\GreaterThan) {
if (\is_int($annotation->value)) {
$property->exclusiveMinimum = true;
$property->minimum = $annotation->value;
}
} elseif ($annotation instanceof Assert\GreaterThanOrEqual) {
if (\is_int($annotation->value)) {
$property->minimum = $annotation->value;
}
}
}
}
public function setSchema($schema): void
{
$this->schema = $schema;
}
/**
* Append the pattern from the constraint to the existing pattern.
*/
private function appendPattern(OA\Schema $property, $newPattern): void
{
if (null === $newPattern) {
return;
}
if (Generator::UNDEFINED !== $property->pattern) {
$property->pattern = sprintf('%s, %s', $property->pattern, $newPattern);
} else {
$property->pattern = $newPattern;
}
}
/**
* @param \ReflectionProperty|\ReflectionMethod $reflection
*/
private function applyEnumFromChoiceConstraint(OA\Schema $property, Assert\Choice $choice, $reflection): void
{
if ($choice->callback) {
$enumValues = call_user_func(is_array($choice->callback) ? $choice->callback : [$reflection->class, $choice->callback]);
} else {
$enumValues = $choice->choices;
}
$setEnumOnThis = $property;
if ($choice->multiple) {
$setEnumOnThis = Util::getChild($property, OA\Items::class);
}
$setEnumOnThis->enum = array_values($enumValues);
}
/**
* @param \ReflectionProperty|\ReflectionMethod $reflection
*/
private function getAnnotations(Context $parentContext, $reflection, ?array $validationGroups): iterable
{
// To correctly load OA annotations
$this->setContextFromReflection($parentContext, $reflection);
foreach ($this->locateAnnotations($reflection) as $annotation) {
if (!$annotation instanceof Constraint) {
continue;
}
if (!$this->useValidationGroups || $this->isConstraintInGroup($annotation, $validationGroups)) {
yield $annotation;
}
}
$this->setContext(null);
}
/**
* @param \ReflectionProperty|\ReflectionMethod $reflection
*/
private function locateAnnotations($reflection): \Traversable
{
if (\PHP_VERSION_ID >= 80000 && class_exists(Constraint::class)) {
foreach ($reflection->getAttributes(Constraint::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
yield $attribute->newInstance();
}
}
if (null !== $this->annotationsReader) {
if ($reflection instanceof \ReflectionProperty) {
yield from $this->annotationsReader->getPropertyAnnotations($reflection);
} elseif ($reflection instanceof \ReflectionMethod) {
yield from $this->annotationsReader->getMethodAnnotations($reflection);
}
}
}
/**
* Check to see if the given constraint is in the provided serialization groups.
*
* If no groups are provided the validator would run in the Constraint::DEFAULT_GROUP,
* and constraints without any `groups` passed to them would be in that same
* default group. So even with a null $validationGroups passed here there still
* has to be a check on the default group.
*/
private function isConstraintInGroup(Constraint $annotation, ?array $validationGroups): bool
{
return count(array_intersect(
$validationGroups ?: [Constraint::DEFAULT_GROUP],
(array) $annotation->groups
)) > 0;
}
}
@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber\Annotations;
/**
* result object returned from `AnnotationReader::updateDefinition` as a way
* to pass back information about manually defined schema elements.
*
* @internal
*/
final class UpdateClassDefinitionResult
{
/**
* Whether or not the model describer shoudl continue reading class properties
* after updating the open api schema from an `OA\Schema` definition.
*
* Users may maually define a `type` or `ref` on a schema, and if that's the case
* model describers should _probably_ not describe any additional properties or try
* to merge in properties.
*/
private $shouldDescribeModelProperties;
public function __construct(bool $shouldDescribeModelProperties)
{
$this->shouldDescribeModelProperties = $shouldDescribeModelProperties;
}
public function shouldDescribeModelProperties(): bool
{
return $this->shouldDescribeModelProperties;
}
}
@@ -0,0 +1,63 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber;
use Nelmio\ApiDocBundle\Model\Model;
use Nelmio\ApiDocBundle\Model\ModelRegistry;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
use Symfony\Component\PropertyInfo\Type;
/**
* Contains helper methods that add `discriminator` and `oneOf` values to
* Open API schemas to support poly morphism.
*
* @see https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/
*
* @internal
*/
trait ApplyOpenApiDiscriminatorTrait
{
/**
* @param Model $model the model that's being described, This is used to pass groups and config
* down to the children models in `oneOf`
* @param OA\Schema $schema The Open API schema to which `oneOf` and `discriminator` properties
* will be added
* @param string $discriminatorProperty The property that determine which model will be unsierailized
* @param array<string, string> $typeMap the map of $discriminatorProperty values to their
* types
*/
protected function applyOpenApiDiscriminator(
Model $model,
OA\Schema $schema,
ModelRegistry $modelRegistry,
string $discriminatorProperty,
array $typeMap
): void {
$weakContext = Util::createWeakContext($schema->_context);
$schema->oneOf = [];
$schema->discriminator = new OA\Discriminator(['_context' => $weakContext]);
$schema->discriminator->propertyName = $discriminatorProperty;
$schema->discriminator->mapping = [];
foreach ($typeMap as $propertyValue => $className) {
$oneOfSchema = new OA\Schema(['_context' => $weakContext]);
$oneOfSchema->ref = $modelRegistry->register(new Model(
new Type(Type::BUILTIN_TYPE_OBJECT, false, $className),
$model->getGroups(),
$model->getOptions()
));
$schema->oneOf[] = $oneOfSchema;
$schema->discriminator->mapping[$propertyValue] = $oneOfSchema->ref;
}
}
}
@@ -0,0 +1,146 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber;
use Hateoas\Configuration\Metadata\ClassMetadata;
use Hateoas\Configuration\Relation;
use Hateoas\Serializer\Metadata\RelationPropertyMetadata;
use Metadata\MetadataFactoryInterface;
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
{
use ModelRegistryAwareTrait;
private $factory;
private $JMSModelDescriber;
public function __construct(MetadataFactoryInterface $factory, JMSModelDescriber $JMSModelDescriber)
{
$this->factory = $factory;
$this->JMSModelDescriber = $JMSModelDescriber;
}
public function setModelRegistry(ModelRegistry $modelRegistry)
{
$this->modelRegistry = $modelRegistry;
$this->JMSModelDescriber->setModelRegistry($modelRegistry);
}
/**
* {@inheritdoc}
*/
public function describe(Model $model, OA\Schema $schema): void
{
$this->JMSModelDescriber->describe($model, $schema);
/**
* @var ClassMetadata
*/
$metadata = $this->getHateoasMetadata($model);
if (null === $metadata) {
return;
}
$schema->type = 'object';
$context = $this->JMSModelDescriber->getSerializationContext($model);
/** @var Relation $relation */
foreach ($metadata->getRelations() as $relation) {
if (!$relation->getEmbedded() && !$relation->getHref()) {
continue;
}
$item = new RelationPropertyMetadata($relation->getExclusion(), $relation);
if (null !== $context->getExclusionStrategy() && $context->getExclusionStrategy()->shouldSkipProperty($item, $context)) {
continue;
}
$context->pushPropertyMetadata($item);
$embedded = $relation->getEmbedded();
$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->type = 'object';
}
if ($relation->getHref()) {
$hrefProp = Util::getProperty($property, 'href');
$hrefProp->type = 'string';
$this->setAttributeProperties($relation, $property);
}
$context->popPropertyMetadata();
}
}
private function getHateoasMetadata(Model $model)
{
$className = $model->getType()->getClassName();
try {
if ($metadata = $this->factory->getMetadataForClass($className)) {
return $metadata;
}
} catch (\ReflectionException $e) {
}
return null;
}
/**
* {@inheritdoc}
*/
public function supports(Model $model): bool
{
return $this->JMSModelDescriber->supports($model) || null !== $this->getHateoasMetadata($model);
}
private function setAttributeProperties(Relation $relation, OA\Property $subProperty): void
{
foreach ($relation->getAttributes() as $attribute => $value) {
$subSubProp = Util::getProperty($subProperty, $attribute);
switch (gettype($value)) {
case 'integer':
$subSubProp->type = 'integer';
$subSubProp->default = $value;
break;
case 'double':
case 'float':
$subSubProp->type = 'number';
$subSubProp->default = $value;
break;
case 'boolean':
$subSubProp->type = 'boolean';
$subSubProp->default = $value;
break;
case 'string':
$subSubProp->type = 'string';
$subSubProp->default = $value;
break;
}
}
}
}
@@ -0,0 +1,39 @@
<?php
namespace Nelmio\ApiDocBundle\ModelDescriber;
use Nelmio\ApiDocBundle\Model\Model;
use OpenApi\Annotations\Schema;
use Symfony\Component\PropertyInfo\Type;
class EnumModelDescriber implements ModelDescriberInterface
{
public function describe(Model $model, Schema $schema)
{
$enumClass = $model->getType()->getClassName();
$enums = [];
foreach ($enumClass::cases() as $enumCase) {
$enums[] = $enumCase->value;
}
$reflectionEnum = new \ReflectionEnum($enumClass);
if ($reflectionEnum->isBacked() && 'int' === $reflectionEnum->getBackingType()->getName()) {
$schema->type = 'integer';
} else {
$schema->type = 'string';
}
$schema->enum = $enums;
}
public function supports(Model $model): bool
{
if (!function_exists('enum_exists')) {
return false;
}
return Type::BUILTIN_TYPE_OBJECT === $model->getType()->getBuiltinType()
&& enum_exists($model->getType()->getClassName())
&& is_subclass_of($model->getType()->getClassName(), \BackedEnum::class);
}
}
@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber;
use Nelmio\ApiDocBundle\Model\Model;
use OpenApi\Annotations as OA;
use Symfony\Component\PropertyInfo\Type;
class FallbackObjectModelDescriber implements ModelDescriberInterface
{
public function describe(Model $model, OA\Schema $schema)
{
}
public function supports(Model $model): bool
{
return Type::BUILTIN_TYPE_OBJECT === $model->getType()->getBuiltinType();
}
}
@@ -0,0 +1,358 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber;
use Doctrine\Common\Annotations\Reader;
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\ModelRegister;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use Nelmio\ApiDocBundle\Util\SetsContextTrait;
use OpenApi\Analysis;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormConfigInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\Form\ResolvedFormTypeInterface;
use Symfony\Component\PropertyInfo\Type;
/**
* @internal
*/
final class FormModelDescriber implements ModelDescriberInterface, ModelRegistryAwareInterface
{
use ModelRegistryAwareTrait;
use SetsContextTrait;
private $formFactory;
/**
* @var Reader|null
*/
private $doctrineReader;
private $mediaTypes;
private $useValidationGroups;
private $isFormCsrfExtensionEnabled;
public function __construct(
FormFactoryInterface $formFactory = null,
Reader $reader = null,
array $mediaTypes = null,
bool $useValidationGroups = false,
bool $isFormCsrfExtensionEnabled = false
) {
$this->formFactory = $formFactory;
$this->doctrineReader = $reader;
if (null === $mediaTypes) {
$mediaTypes = ['json'];
trigger_deprecation('nelmio/api-doc-bundle', '4.1', 'Not passing media types to the constructor of %s is deprecated and won\'t be allowed in version 5.', self::class);
}
$this->mediaTypes = $mediaTypes;
$this->useValidationGroups = $useValidationGroups;
$this->isFormCsrfExtensionEnabled = $isFormCsrfExtensionEnabled;
}
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.');
}
if (null === $this->formFactory) {
throw new \LogicException('You need to enable forms in your application to use a form as a model.');
}
$class = $model->getType()->getClassName();
$annotationsReader = new AnnotationsReader(
$this->doctrineReader,
$this->modelRegistry,
$this->mediaTypes,
$this->useValidationGroups
);
$classResult = $annotationsReader->updateDefinition(new \ReflectionClass($class), $schema);
if (!$classResult->shouldDescribeModelProperties()) {
return;
}
$schema->type = 'object';
$this->setContextFromReflection($schema->_context, new \ReflectionClass($class));
$form = $this->formFactory->create($class, null, $model->getOptions() ?? []);
$this->parseForm($schema, $form);
$this->setContext(null);
}
public function supports(Model $model): bool
{
return is_a($model->getType()->getClassName(), FormTypeInterface::class, true);
}
private function parseForm(OA\Schema $schema, FormInterface $form)
{
foreach ($form as $name => $child) {
$config = $child->getConfig();
// This field must not be documented
if ($config->hasOption('documentation') && false === $config->getOption('documentation')) {
continue;
}
$property = Util::getProperty($schema, $name);
if ($config->getRequired()) {
$required = Generator::UNDEFINED !== $schema->required ? $schema->required : [];
$required[] = $name;
$schema->required = $required;
}
if ($config->hasOption('documentation')) {
$property->mergeProperties($config->getOption('documentation'));
// Parse inner @Model annotations
$modelRegister = new ModelRegister($this->modelRegistry, $this->mediaTypes);
$modelRegister->__invoke(new Analysis([$property], Util::createContext()));
}
if (Generator::UNDEFINED !== $property->type || Generator::UNDEFINED !== $property->ref) {
continue; // Type manually defined
}
$this->findFormType($config, $property);
}
if ($this->isFormCsrfExtensionEnabled && $form->getConfig()->getOption('csrf_protection', false)) {
$tokenFieldName = $form->getConfig()->getOption('csrf_field_name');
$property = Util::getProperty($schema, $tokenFieldName);
$property->type = 'string';
$property->description = 'CSRF token';
if (Generator::isDefault($schema->required)) {
$schema->required = [];
}
$schema->required[] = $tokenFieldName;
}
}
/**
* Finds and sets the schema type on $property based on $config info.
*
* Returns true if a native OpenAPi type was found, false otherwise
*/
private function findFormType(FormConfigInterface $config, OA\Schema $property)
{
$type = $config->getType();
if (!$builtinFormType = $this->getBuiltinFormType($type)) {
// if form type is not builtin in Form component.
$model = new Model(
new Type(Type::BUILTIN_TYPE_OBJECT, false, get_class($type->getInnerType())),
null,
$config->getOptions()
);
$ref = $this->modelRegistry->register($model);
// We need to use allOf for description and title to be displayed
if ($config->hasOption('documentation') && !empty($config->getOption('documentation'))) {
$property->oneOf = [new OA\Schema(['ref' => $ref])];
} else {
$property->ref = $ref;
}
return;
}
do {
$blockPrefix = $builtinFormType->getBlockPrefix();
if ('text' === $blockPrefix) {
$property->type = 'string';
break;
}
if ('number' === $blockPrefix) {
$property->type = 'number';
break;
}
if ('integer' === $blockPrefix) {
$property->type = 'integer';
break;
}
if ('date' === $blockPrefix) {
$property->type = 'string';
$property->format = 'date';
break;
}
if ('datetime' === $blockPrefix) {
$property->type = 'string';
$property->format = 'date-time';
break;
}
if ('choice' === $blockPrefix) {
if ($config->getOption('multiple')) {
$property->type = 'array';
} else {
$property->type = 'string';
}
if (($choices = $config->getOption('choices')) && is_array($choices) && count($choices)) {
$enums = array_values($choices);
if ($this->isNumbersArray($enums)) {
$type = 'number';
} elseif ($this->isBooleansArray($enums)) {
$type = 'boolean';
} else {
$type = 'string';
}
if ($config->getOption('multiple')) {
$property->items = Util::createChild($property, OA\Items::class, ['type' => $type, 'enum' => $enums]);
} else {
$property->type = $type;
$property->enum = $enums;
}
}
break;
}
if ('checkbox' === $blockPrefix) {
$property->type = 'boolean';
break;
}
if ('password' === $blockPrefix) {
$property->type = 'string';
$property->format = 'password';
break;
}
if ('repeated' === $blockPrefix) {
$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(), Util::getProperty($property, $subName));
}
break;
}
if ('collection' === $blockPrefix) {
$subType = $config->getOption('entry_type');
$subOptions = $config->getOption('entry_options');
$subForm = $this->formFactory->create($subType, null, $subOptions);
$property->type = 'array';
$property->items = Util::createChild($property, OA\Items::class);
$this->findFormType($subForm->getConfig(), $property->items);
break;
}
// The DocumentType is bundled with the DoctrineMongoDBBundle
if ('entity' === $blockPrefix || 'document' === $blockPrefix) {
$entityClass = $config->getOption('class');
if ($config->getOption('multiple')) {
$property->format = sprintf('[%s id]', $entityClass);
$property->type = 'array';
$property->items = Util::createChild($property, OA\Items::class, ['type' => 'string']);
} else {
$property->type = 'string';
$property->format = sprintf('%s id', $entityClass);
}
break;
}
} while ($builtinFormType = $builtinFormType->getParent());
}
/**
* @return bool true if $array contains only numbers, false otherwise
*/
private function isNumbersArray(array $array): bool
{
foreach ($array as $item) {
if (!is_numeric($item)) {
return false;
}
}
return true;
}
/**
* @return bool true if $array contains only booleans, false otherwise
*/
private function isBooleansArray(array $array): bool
{
foreach ($array as $item) {
if (!is_bool($item)) {
return false;
}
}
return true;
}
/**
* @return ResolvedFormTypeInterface|null
*/
private function getBuiltinFormType(ResolvedFormTypeInterface $type)
{
do {
$class = get_class($type->getInnerType());
if (FormType::class === $class) {
return null;
}
if ('entity' === $type->getBlockPrefix() || 'document' === $type->getBlockPrefix()) {
return $type;
}
if (0 === strpos($class, 'Symfony\Component\Form\Extension\Core\Type\\')) {
return $type;
}
} while ($type = $type->getParent());
return null;
}
}
@@ -0,0 +1,366 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber;
use Doctrine\Common\Annotations\Reader;
use JMS\Serializer\Context;
use JMS\Serializer\ContextFactory\SerializationContextFactoryInterface;
use JMS\Serializer\Exclusion\GroupsExclusionStrategy;
use JMS\Serializer\Naming\PropertyNamingStrategyInterface;
use JMS\Serializer\SerializationContext;
use Metadata\MetadataFactoryInterface;
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 OpenApi\Generator;
use Symfony\Component\PropertyInfo\Type;
/**
* Uses the JMS metadata factory to extract input/output model information.
*/
class JMSModelDescriber implements ModelDescriberInterface, ModelRegistryAwareInterface
{
use ModelRegistryAwareTrait;
private $factory;
private $contextFactory;
private $namingStrategy;
/**
* @var Reader|null
*/
private $doctrineReader;
private $contexts = [];
private $metadataStacks = [];
private $mediaTypes;
/**
* @var array
*/
private $propertyTypeUseGroupsCache = [];
/**
* @var bool
*/
private $useValidationGroups;
public function __construct(
MetadataFactoryInterface $factory,
?Reader $reader,
array $mediaTypes,
?PropertyNamingStrategyInterface $namingStrategy = null,
bool $useValidationGroups = false,
?SerializationContextFactoryInterface $contextFactory = null
) {
$this->factory = $factory;
$this->namingStrategy = $namingStrategy;
$this->doctrineReader = $reader;
$this->mediaTypes = $mediaTypes;
$this->useValidationGroups = $useValidationGroups;
$this->contextFactory = $contextFactory;
}
/**
* {@inheritdoc}
*/
public function describe(Model $model, OA\Schema $schema)
{
$className = $model->getType()->getClassName();
$metadata = $this->factory->getMetadataForClass($className);
if (null === $metadata) {
throw new \InvalidArgumentException(sprintf('No metadata found for class %s.', $className));
}
$annotationsReader = new AnnotationsReader(
$this->doctrineReader,
$this->modelRegistry,
$this->mediaTypes,
$this->useValidationGroups
);
$classResult = $annotationsReader->updateDefinition(new \ReflectionClass($className), $schema);
if (!$classResult->shouldDescribeModelProperties()) {
return;
}
$schema->type = 'object';
$isJmsV1 = null !== $this->namingStrategy;
$context = $this->getSerializationContext($model);
$context->pushClassMetadata($metadata);
foreach ($metadata->propertyMetadata as $item) {
// filter groups
if (null !== $context->getExclusionStrategy() && $context->getExclusionStrategy()->shouldSkipProperty($item, $context)) {
continue;
}
$context->pushPropertyMetadata($item);
$name = true === $isJmsV1 ? $this->namingStrategy->translateName($item) : $item->serializedName;
// read property options from Swagger Property annotation if it exists
$reflections = [];
if (true === $isJmsV1 && property_exists($item, 'reflection') && null !== $item->reflection) {
$reflections[] = $item->reflection;
} elseif (\property_exists($item->class, $item->name)) {
$reflections[] = new \ReflectionProperty($item->class, $item->name);
}
if (null !== $item->getter) {
try {
$reflections[] = new \ReflectionMethod($item->class, $item->getter);
} catch (\ReflectionException $ignored) {
}
}
if (null !== $item->setter) {
try {
$reflections[] = new \ReflectionMethod($item->class, $item->setter);
} catch (\ReflectionException $ignored) {
}
}
$groups = $this->computeGroups($context, $item->type);
if (true === $item->inline && isset($item->type['name'])) {
// currently array types can not be documented :-/
if (!in_array($item->type['name'], ['array', 'ArrayCollection'], true)) {
$inlineModel = new Model(new Type(Type::BUILTIN_TYPE_OBJECT, false, $item->type['name']), $groups);
$this->describe($inlineModel, $schema);
}
$context->popPropertyMetadata();
continue;
}
foreach ($reflections as $reflection) {
$name = $annotationsReader->getPropertyName($reflection, $name);
}
$property = Util::getProperty($schema, $name);
foreach ($reflections as $reflection) {
$annotationsReader->updateProperty($reflection, $property, $groups);
}
if (Generator::UNDEFINED !== $property->type || Generator::UNDEFINED !== $property->ref) {
$context->popPropertyMetadata();
continue;
}
if (Generator::UNDEFINED === $property->default && $item->hasDefault) {
$property->default = $item->defaultValue;
}
if (null === $item->type) {
$key = Util::searchIndexedCollectionItem($schema->properties, 'property', $name);
unset($schema->properties[$key]);
$context->popPropertyMetadata();
continue;
}
$this->describeItem($item->type, $property, $context);
$context->popPropertyMetadata();
}
$context->popClassMetadata();
}
/**
* @internal
*/
public function getSerializationContext(Model $model): SerializationContext
{
if (isset($this->contexts[$model->getHash()])) {
$context = $this->contexts[$model->getHash()];
$stack = $context->getMetadataStack();
while (!$stack->isEmpty()) {
$stack->pop();
}
foreach ($this->metadataStacks[$model->getHash()] as $metadataCopy) {
$stack->unshift($metadataCopy);
}
} else {
$context = $this->contextFactory ? $this->contextFactory->createSerializationContext() : SerializationContext::create();
if (null !== $model->getGroups()) {
$context->addExclusionStrategy(new GroupsExclusionStrategy($model->getGroups()));
}
}
return $context;
}
private function computeGroups(Context $context, array $type = null)
{
if (null === $type || true !== $this->propertyTypeUsesGroups($type)) {
return null;
}
$groupsExclusion = $context->getExclusionStrategy();
if (!($groupsExclusion instanceof GroupsExclusionStrategy)) {
return null;
}
$groups = $groupsExclusion->getGroupsFor($context);
if ([GroupsExclusionStrategy::DEFAULT_GROUP] === $groups) {
return null;
}
return $groups;
}
/**
* {@inheritdoc}
*/
public function supports(Model $model): bool
{
$className = $model->getType()->getClassName();
try {
if ($this->factory->getMetadataForClass($className)) {
return true;
}
} catch (\ReflectionException $e) {
}
return false;
}
/**
* @internal
*/
public function describeItem(array $type, OA\Schema $property, Context $context)
{
$nestedTypeInfo = $this->getNestedTypeInArray($type);
if (null !== $nestedTypeInfo) {
[$nestedType, $isHash] = $nestedTypeInfo;
if ($isHash) {
$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->additionalProperties, $context);
return;
}
$property->type = 'array';
$property->items = Util::createChild($property, OA\Items::class);
$this->describeItem($nestedType, $property->items, $context);
} elseif ('array' === $type['name']) {
$property->type = 'object';
$property->additionalProperties = true;
} elseif ('string' === $type['name']) {
$property->type = 'string';
} elseif (in_array($type['name'], ['bool', 'boolean'], true)) {
$property->type = 'boolean';
} elseif (in_array($type['name'], ['int', 'integer'], true)) {
$property->type = 'integer';
} elseif (in_array($type['name'], ['double', 'float'], true)) {
$property->type = 'number';
$property->format = $type['name'];
} elseif (is_a($type['name'], \DateTimeInterface::class, true)) {
$property->type = 'string';
$property->format = 'date-time';
} else {
// See https://github.com/schmittjoh/serializer/blob/5a5a03a/src/Metadata/Driver/EnumPropertiesDriver.php#L51
if ('enum' === $type['name']
&& isset($type['params'][0])
&& function_exists('enum_exists')
&& enum_exists($type['params'][0])
) {
$type = ['name' => $type['params'][0]];
}
$groups = $this->computeGroups($context, $type);
$model = new Model(new Type(Type::BUILTIN_TYPE_OBJECT, false, $type['name']), $groups);
$modelRef = $this->modelRegistry->register($model);
$customFields = (array) $property->jsonSerialize();
unset($customFields['property']);
if (empty($customFields)) { // no custom fields
$property->ref = $modelRef;
} else {
$weakContext = Util::createWeakContext($property->_context);
$property->oneOf = [new OA\Schema(['ref' => $modelRef, '_context' => $weakContext])];
}
$this->contexts[$model->getHash()] = $context;
$this->metadataStacks[$model->getHash()] = clone $context->getMetadataStack();
}
}
private function getNestedTypeInArray(array $type)
{
if ('array' !== $type['name'] && 'ArrayCollection' !== $type['name']) {
return null;
}
// array<string, MyNamespaceMyObject>
if (isset($type['params'][1]['name'])) {
return [$type['params'][1], true];
}
// array<MyNamespaceMyObject>
if (isset($type['params'][0]['name'])) {
return [$type['params'][0], false];
}
return null;
}
/**
* @return bool|null
*/
private function propertyTypeUsesGroups(array $type)
{
if (array_key_exists($type['name'], $this->propertyTypeUseGroupsCache)) {
return $this->propertyTypeUseGroupsCache[$type['name']];
}
try {
$metadata = $this->factory->getMetadataForClass($type['name']);
foreach ($metadata->propertyMetadata as $item) {
if (null !== $item->groups && $item->groups != [GroupsExclusionStrategy::DEFAULT_GROUP]) {
$this->propertyTypeUseGroupsCache[$type['name']] = true;
return true;
}
}
$this->propertyTypeUseGroupsCache[$type['name']] = false;
return false;
} catch (\ReflectionException $e) {
$this->propertyTypeUseGroupsCache[$type['name']] = null;
return null;
}
}
}
@@ -0,0 +1,22 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber;
use Nelmio\ApiDocBundle\Model\Model;
use OpenApi\Annotations\Schema;
interface ModelDescriberInterface
{
public function describe(Model $model, Schema $schema);
public function supports(Model $model): bool;
}
@@ -0,0 +1,226 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber;
use Doctrine\Common\Annotations\Reader;
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 OpenApi\Generator;
use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwareInterface
{
use ModelRegistryAwareTrait;
use ApplyOpenApiDiscriminatorTrait;
/** @var PropertyInfoExtractorInterface */
private $propertyInfo;
/** @var ClassMetadataFactoryInterface|null */
private $classMetadataFactory;
/** @var Reader|null */
private $doctrineReader;
/** @var PropertyDescriberInterface|PropertyDescriberInterface[] */
private $propertyDescriber;
/** @var string[] */
private $mediaTypes;
/** @var NameConverterInterface|null */
private $nameConverter;
/** @var bool */
private $useValidationGroups;
/**
* @param PropertyDescriberInterface|PropertyDescriberInterface[] $propertyDescribers
*/
public function __construct(
PropertyInfoExtractorInterface $propertyInfo,
?Reader $reader,
$propertyDescribers,
array $mediaTypes,
NameConverterInterface $nameConverter = null,
bool $useValidationGroups = false,
ClassMetadataFactoryInterface $classMetadataFactory = null
) {
if (is_array($propertyDescribers)) {
trigger_deprecation('nelmio/api-doc-bundle', '4.17', 'Passing an array of PropertyDescriberInterface to %s() is deprecated. Pass a single PropertyDescriberInterface instead.', __METHOD__);
} else {
if (!$propertyDescribers instanceof PropertyDescriberInterface) {
throw new \InvalidArgumentException(sprintf('Argument 3 passed to %s() must be an array of %s or a single %s.', __METHOD__, PropertyDescriberInterface::class, PropertyDescriberInterface::class));
}
}
$this->propertyInfo = $propertyInfo;
$this->doctrineReader = $reader;
$this->propertyDescriber = $propertyDescribers;
$this->mediaTypes = $mediaTypes;
$this->nameConverter = $nameConverter;
$this->useValidationGroups = $useValidationGroups;
$this->classMetadataFactory = $classMetadataFactory;
}
public function describe(Model $model, OA\Schema $schema)
{
$class = $model->getType()->getClassName();
$schema->_context->class = $class;
$context = ['serializer_groups' => null];
if (null !== $model->getGroups()) {
$context['serializer_groups'] = array_filter($model->getGroups(), 'is_string');
}
$reflClass = new \ReflectionClass($class);
$annotationsReader = new AnnotationsReader(
$this->doctrineReader,
$this->modelRegistry,
$this->mediaTypes,
$this->useValidationGroups
);
$classResult = $annotationsReader->updateDefinition($reflClass, $schema);
if (!$classResult->shouldDescribeModelProperties()) {
return;
}
$schema->type = 'object';
$mapping = false;
if (null !== $this->classMetadataFactory) {
$mapping = $this->classMetadataFactory
->getMetadataFor($class)
->getClassDiscriminatorMapping();
}
if ($mapping && Generator::UNDEFINED === $schema->discriminator) {
$this->applyOpenApiDiscriminator(
$model,
$schema,
$this->modelRegistry,
$mapping->getTypeProperty(),
$mapping->getTypesMapping()
);
}
$propertyInfoProperties = $this->propertyInfo->getProperties($class, $context);
if (null === $propertyInfoProperties) {
return;
}
// Fix for https://github.com/nelmio/NelmioApiDocBundle/issues/1756
// The SerializerExtractor does expose private/protected properties for some reason, so we eliminate them here
$propertyInfoProperties = array_intersect($propertyInfoProperties, $this->propertyInfo->getProperties($class, []) ?? []);
$defaultValues = array_filter($reflClass->getDefaultProperties(), static function ($value) {
return null !== $value;
});
foreach ($propertyInfoProperties as $propertyName) {
$serializedName = null !== $this->nameConverter ? $this->nameConverter->normalize($propertyName, $class, null, $model->getSerializationContext()) : $propertyName;
$reflections = $this->getReflections($reflClass, $propertyName);
// Check if a custom name is set
foreach ($reflections as $reflection) {
$serializedName = $annotationsReader->getPropertyName($reflection, $serializedName);
}
$property = Util::getProperty($schema, $serializedName);
// Interpret additional options
$groups = $model->getGroups();
if (isset($groups[$propertyName]) && is_array($groups[$propertyName])) {
$groups = $model->getGroups()[$propertyName];
}
foreach ($reflections as $reflection) {
$annotationsReader->updateProperty($reflection, $property, $groups);
}
// If type manually defined
if (Generator::UNDEFINED !== $property->type || Generator::UNDEFINED !== $property->ref) {
continue;
}
if (Generator::UNDEFINED === $property->default && array_key_exists($propertyName, $defaultValues)) {
$property->default = $defaultValues[$propertyName];
}
$types = $this->propertyInfo->getTypes($class, $propertyName);
if (null === $types || 0 === count($types)) {
throw new \LogicException(sprintf('The PropertyInfo component was not able to guess the type of %s::$%s. You may need to add a `@var` annotation or use `@OA\Property(type="")` to make its type explicit.', $class, $propertyName));
}
$this->describeProperty($types, $model, $property, $propertyName, $schema);
}
}
/**
* @return \ReflectionProperty[]|\ReflectionMethod[]
*/
private function getReflections(\ReflectionClass $reflClass, string $propertyName): array
{
$reflections = [];
if ($reflClass->hasProperty($propertyName)) {
$reflections[] = $reflClass->getProperty($propertyName);
}
$camelProp = $this->camelize($propertyName);
foreach (['', 'get', 'is', 'has', 'can', 'add', 'remove', 'set'] as $prefix) {
if ($reflClass->hasMethod($prefix.$camelProp)) {
$reflections[] = $reflClass->getMethod($prefix.$camelProp);
}
}
return $reflections;
}
/**
* Camelizes a given string.
*/
private function camelize(string $string): string
{
return str_replace(' ', '', ucwords(str_replace('_', ' ', $string)));
}
/**
* @param Type[] $types
*/
private function describeProperty(array $types, Model $model, OA\Schema $property, string $propertyName, OA\Schema $schema)
{
$propertyDescribers = is_array($this->propertyDescriber) ? $this->propertyDescriber : [$this->propertyDescriber];
foreach ($propertyDescribers as $propertyDescriber) {
if ($propertyDescriber instanceof ModelRegistryAwareInterface) {
$propertyDescriber->setModelRegistry($this->modelRegistry);
}
if ($propertyDescriber->supports($types)) {
$propertyDescriber->describe($types, $property, $model->getGroups(), $schema, $model->getSerializationContext());
return;
}
}
throw new \Exception(sprintf('Type "%s" is not supported in %s::$%s. You may use the `@OA\Property(type="")` annotation to specify it manually.', $types[0]->getBuiltinType(), $model->getType()->getClassName(), $propertyName));
}
public function supports(Model $model): bool
{
return Type::BUILTIN_TYPE_OBJECT === $model->getType()->getBuiltinType()
&& (class_exists($model->getType()->getClassName()) || interface_exists($model->getType()->getClassName()));
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber;
use Nelmio\ApiDocBundle\Model\Model;
use OpenApi\Annotations as OA;
class SelfDescribingModelDescriber implements ModelDescriberInterface
{
public function describe(Model $model, OA\Schema $schema): void
{
call_user_func([$model->getType()->getClassName(), 'describe'], $schema, $model);
}
public function supports(Model $model): bool
{
return $model->getType()->getClassName()
&& class_exists($model->getType()->getClassName())
&& is_a($model->getType()->getClassName(), SelfDescribingModelInterface::class, true);
}
}
@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\ModelDescriber;
use Nelmio\ApiDocBundle\Model\Model;
use OpenApi\Annotations\Schema;
/**
* A self-describing model is a model able to describe its own schema through a static method call.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
interface SelfDescribingModelInterface
{
public static function describe(Schema $schema, Model $model): void;
}
+33
View File
@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle;
use Nelmio\ApiDocBundle\DependencyInjection\Compiler\ConfigurationPass;
use Nelmio\ApiDocBundle\DependencyInjection\Compiler\CustomProcessorPass;
use Nelmio\ApiDocBundle\DependencyInjection\Compiler\PhpDocExtractorPass;
use Nelmio\ApiDocBundle\DependencyInjection\Compiler\TagDescribersPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
final class NelmioApiDocBundle extends Bundle
{
/**
* {@inheritdoc}
*/
public function build(ContainerBuilder $container): void
{
$container->addCompilerPass(new ConfigurationPass());
$container->addCompilerPass(new TagDescribersPass());
$container->addCompilerPass(new PhpDocExtractorPass());
$container->addCompilerPass(new CustomProcessorPass());
}
}
@@ -0,0 +1,177 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\OpenApiPhp;
use Nelmio\ApiDocBundle\Annotation\Model as ModelAnnotation;
use Nelmio\ApiDocBundle\Model\Model;
use Nelmio\ApiDocBundle\Model\ModelRegistry;
use OpenApi\Analysis;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use Symfony\Component\PropertyInfo\Type;
/**
* Resolves the path in SwaggerPhp annotation when needed.
*
* @internal
*/
final class ModelRegister
{
/** @var ModelRegistry */
private $modelRegistry;
/** @var string[] */
private $mediaTypes;
public function __construct(ModelRegistry $modelRegistry, array $mediaTypes)
{
$this->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, $model->serializationContext));
// 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, $model->serializationContext)),
];
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, $model->serializationContext)),
])]);
// It is no longer an unmerged annotation
$this->detach($model, $annotation, $analysis);
}
}
private function getGroups(ModelAnnotation $model, array $parentGroups = null): ?array
{
if (null === $model->groups) {
return $parentGroups;
}
return array_merge($parentGroups ?? [], $model->groups);
}
private function detach(ModelAnnotation $model, OA\AbstractAnnotation $annotation, Analysis $analysis): void
{
if (Generator::UNDEFINED !== $annotation->attachables) {
foreach ($annotation->attachables as $key => $attachable) {
if ($attachable === $model) {
unset($annotation->attachables[$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
{
if (Generator::UNDEFINED !== $annotation->attachables) {
foreach ($annotation->attachables as $attachable) {
if ($attachable instanceof ModelAnnotation) {
return $attachable;
}
}
}
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 types '%s'. It must be one of 'json' or 'xml'.", implode(',', $this->mediaTypes)));
}
$annotation->merge([$modelAnnotation]);
$analysis->addAnnotation($modelAnnotation, $properties['_context']);
}
}
+519
View File
@@ -0,0 +1,519 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\OpenApiPhp;
use OpenApi\Annotations as OA;
use OpenApi\Context;
use OpenApi\Generator;
/**
* Class Util.
*
* This class acts as compatibility layer between NelmioApiDocBundle and swagger-php.
*
* It was written to replace the GuilhemN/swagger layer as a lower effort to maintain alternative.
*
* The main purpose of this class is to search for and create child Annotations
* of swagger Annotation classes with the following convenience methods
* to get or create the respective Annotation instances if not found
*
* @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::getPath()
* @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::getSchema()
* @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::getProperty()
* @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::getOperation()
* @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::getOperationParameter()
*
* which in turn get or create the Annotation instances through the following more general methods
* @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::getChild()
* @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::getCollectionItem()
* @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::getIndexedCollectionItem()
*
* which then searches for an existing Annotation through
* @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::searchCollectionItem()
* @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::searchIndexedCollectionItem()
*
* and if not found the Annotation creates it through
* @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::createCollectionItem()
* @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::createContext()
*
* The merge method @see \Nelmio\ApiDocBundle\OpenApiPhp\Util::merge() has the main purpose to be able
* to merge properties from an deeply nested array of Annotation properties in the structure of a
* generated swagger json decoded array.
*/
final class Util
{
/**
* All http method verbs as known by swagger.
*
* @var array
*/
public const OPERATIONS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
/**
* Return an existing PathItem object from $api->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 string $path
*/
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 string $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(['_context' => self::createWeakContext($api->_context)]);
}
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 string $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 string $method
*/
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 string $name
* @param string $in
*/
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
*/
public static function getChild(OA\AbstractAnnotation $parent, $class, array $properties = []): OA\AbstractAnnotation
{
$nested = $parent::$_nested;
$property = $nested[$class];
if (null === $parent->{$property} || Generator::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 string $class
*/
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} && Generator::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 string $class
* @param mixed $value
*/
public static function getIndexedCollectionItem(OA\AbstractAnnotation $parent, $class, $value): OA\AbstractAnnotation
{
$nested = $parent::$_nested;
[$collection, $property] = $nested[$class];
$key = self::searchIndexedCollectionItem(
$parent->{$collection} && Generator::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.
*
* @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 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 string $collection
* @param string $class
*/
public static function createCollectionItem(OA\AbstractAnnotation $parent, $collection, $class, array $properties = []): int
{
if (Generator::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 string $class
*
* @throws \InvalidArgumentException at an attempt to pass in properties that are found in $parent::$_nested
*/
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
*/
public static function createContext(array $properties = [], Context $parent = null): Context
{
return new Context($properties, $parent);
}
/**
* Create a new Context by copying the properties of the parent, but without a reference to the parent.
*
* @see Context
*/
public static function createWeakContext(Context $parent = null, array $additionalProperties = []): Context
{
$propsToCopy = [
'version',
'line',
'character',
'namespace',
'class',
'interface',
'trait',
'method',
'property',
'logger',
];
$filteredProps = [];
foreach ($propsToCopy as $prop) {
$value = $parent->{$prop} ?? null;
if (null === $value) {
continue;
}
$filteredProps[$prop] = $value;
}
return new Context(array_merge($filteredProps, $additionalProperties));
}
/**
* 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 array|\ArrayObject|OA\AbstractAnnotation $from
*/
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);
}
}
/**
* Get assigned property name for property schema.
*/
public static function getSchemaPropertyName(OA\Schema $schema, OA\Schema $property): ?string
{
if (Generator::UNDEFINED === $schema->properties) {
return null;
}
foreach ($schema->properties as $schemaProperty) {
if ($schemaProperty === $property) {
return Generator::UNDEFINED !== $schemaProperty->property ? $schemaProperty->property : null;
}
}
return null;
}
private static function mergeFromArray(OA\AbstractAnnotation $annotation, array $properties, bool $overwrite)
{
$done = [];
$defaults = \get_class_vars(\get_class($annotation));
foreach ($annotation::$_nested as $className => $propertyName) {
if (\is_string($propertyName)) {
if (array_key_exists($propertyName, $properties)) {
if (!is_bool($properties[$propertyName])) {
self::mergeChild($annotation, $className, $properties[$propertyName], $overwrite);
} elseif ($overwrite || $annotation->{$propertyName} === $defaults[$propertyName]) {
// Support for boolean values (for instance for additionalProperties)
$annotation->{$propertyName} = $properties[$propertyName];
}
$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;
}
}
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 (array_key_exists($propertyName, $defaults) && !\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, '[')) {
$innerType = substr($type, 1, -1);
if (!$annotation->{$propertyName} || Generator::UNDEFINED === $annotation->{$propertyName}) {
$annotation->{$propertyName} = [];
}
if (!class_exists($innerType)) {
/* type is declared as array in @see OA\AbstractAnnotation::$_types */
$annotation->{$propertyName} = array_unique(array_merge(
$annotation->{$propertyName},
$properties[$propertyName]
));
return;
}
// $type == [Schema] for instance
foreach ($properties[$propertyName] as $child) {
$annotation->{$propertyName}[] = $annot = self::createChild($annotation, $innerType, []);
self::merge($annot, $child, $overwrite);
}
} 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;
},
$class::$_nested
));
}
/**
* Helper method to modify an annotation value only if its value has not yet been set.
*/
public static function modifyAnnotationValue(OA\AbstractAnnotation $parameter, string $property, $value): void
{
if (!Generator::isDefault($parameter->{$property})) {
return;
}
$parameter->{$property} = $value;
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Nelmio\ApiDocBundle\Processor;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapQueryStringDescriber;
use OpenApi\Analysis;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use OpenApi\Processors\ProcessorInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
/**
* A processor that adds query parameters to operations that have a MapQueryString attribute.
* A processor is used to ensure that a Model has been created.
*
* @see SymfonyMapQueryStringDescriber
*/
final class MapQueryStringProcessor implements ProcessorInterface
{
public function __invoke(Analysis $analysis)
{
/** @var OA\Operation[] $operations */
$operations = $analysis->getAnnotationsOfType(OA\Operation::class);
foreach ($operations as $operation) {
if (!isset($operation->_context->{SymfonyMapQueryStringDescriber::CONTEXT_KEY})) {
continue;
}
$mapQueryStringContexts = $operation->_context->{SymfonyMapQueryStringDescriber::CONTEXT_KEY};
if (!is_array($mapQueryStringContexts)) {
throw new \LogicException(sprintf('MapQueryString contexts not found for operation "%s"', $operation->operationId));
}
foreach ($mapQueryStringContexts as $mapQueryStringContext) {
$this->addQueryParameters($analysis, $operation, $mapQueryStringContext);
}
}
}
private function addQueryParameters(Analysis $analysis, OA\Operation $operation, array $mapQueryStringContext): void
{
$argumentMetaData = $mapQueryStringContext[SymfonyMapQueryStringDescriber::CONTEXT_ARGUMENT_METADATA];
if (!$argumentMetaData instanceof ArgumentMetadata) {
throw new \LogicException(sprintf('MapQueryString ArgumentMetaData not found for operation "%s"', $operation->operationId));
}
$modelRef = $mapQueryStringContext[SymfonyMapQueryStringDescriber::CONTEXT_MODEL_REF];
if (!isset($modelRef)) {
throw new \LogicException(sprintf('MapQueryString Model reference not found for operation "%s"', $operation->operationId));
}
$nativeModelName = str_replace(OA\Components::SCHEMA_REF, '', $modelRef);
$schemaModel = Util::getSchema($analysis->openapi, $nativeModelName);
// There are no properties to map to query parameters
if (Generator::UNDEFINED === $schemaModel->properties) {
return;
}
$isModelOptional = $argumentMetaData->hasDefaultValue() || $argumentMetaData->isNullable();
foreach ($schemaModel->properties as $property) {
$name = 'array' === $property->type
? $property->property.'[]'
: $property->property;
$operationParameter = Util::getOperationParameter($operation, $name, 'query');
// Remove incompatible properties
$propertyVars = get_object_vars($property);
unset($propertyVars['property']);
$schema = new OA\Schema($propertyVars);
Util::modifyAnnotationValue($operationParameter, 'schema', $schema);
Util::modifyAnnotationValue($operationParameter, 'name', $property->property);
Util::modifyAnnotationValue($operationParameter, 'description', $schema->description);
Util::modifyAnnotationValue($operationParameter, 'required', $schema->required);
Util::modifyAnnotationValue($operationParameter, 'deprecated', $schema->deprecated);
Util::modifyAnnotationValue($operationParameter, 'example', $schema->example);
if ($isModelOptional) {
Util::modifyAnnotationValue($operationParameter, 'required', false);
} elseif (is_array($schemaModel->required) && in_array($property->property, $schemaModel->required, true)) {
Util::modifyAnnotationValue($operationParameter, 'required', true);
} else {
Util::modifyAnnotationValue($operationParameter, 'required', false);
}
}
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Nelmio\ApiDocBundle\Processor;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapRequestPayloadDescriber;
use OpenApi\Analysis;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use OpenApi\Processors\ProcessorInterface;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
/**
* A processor that adds query parameters to operations that have a MapRequestPayload attribute.
* A processor is used to ensure that a Model has been created.
*
* @see SymfonyMapRequestPayloadDescriber
*/
final class MapRequestPayloadProcessor implements ProcessorInterface
{
public function __invoke(Analysis $analysis)
{
/** @var OA\Operation[] $operations */
$operations = $analysis->getAnnotationsOfType(OA\Operation::class);
foreach ($operations as $operation) {
if (!isset($operation->_context->{SymfonyMapRequestPayloadDescriber::CONTEXT_ARGUMENT_METADATA})) {
continue;
}
$argumentMetaData = $operation->_context->{SymfonyMapRequestPayloadDescriber::CONTEXT_ARGUMENT_METADATA};
if (!$argumentMetaData instanceof ArgumentMetadata) {
throw new \LogicException(sprintf('MapRequestPayload ArgumentMetaData not found for operation "%s"', $operation->operationId));
}
/** @var MapRequestPayload $attribute */
if (!$attribute = $argumentMetaData->getAttributes(MapRequestPayload::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null) {
throw new \LogicException(sprintf('Operation "%s" does not contain attribute of "%s', $operation->operationId, MapRequestPayload::class));
}
$modelRef = $operation->_context->{SymfonyMapRequestPayloadDescriber::CONTEXT_MODEL_REF};
if (!isset($modelRef)) {
throw new \LogicException(sprintf('MapRequestPayload Model reference not found for operation "%s"', $operation->operationId));
}
/** @var OA\RequestBody $requestBody */
$requestBody = Util::getChild($operation, OA\RequestBody::class);
Util::modifyAnnotationValue($requestBody, 'required', !($argumentMetaData->hasDefaultValue() || $argumentMetaData->isNullable()));
$formats = $attribute->acceptFormat;
if (!is_array($formats)) {
$formats = [$attribute->acceptFormat ?? 'json'];
}
foreach ($formats as $format) {
if (!Generator::isDefault($requestBody->content)) {
continue;
}
$contentSchema = $this->getContentSchemaForType($requestBody, $format);
Util::modifyAnnotationValue($contentSchema, 'ref', $modelRef);
if ($argumentMetaData->isNullable()) {
$contentSchema->nullable = true;
}
}
}
}
private function getContentSchemaForType(OA\RequestBody $requestBody, string $type): OA\Schema
{
Util::modifyAnnotationValue($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])) {
$weakContext = Util::createWeakContext($requestBody->_context);
$requestBody->content[$contentType] = new OA\MediaType(
[
'mediaType' => $contentType,
'_context' => $weakContext,
]
);
}
return Util::getChild(
$requestBody->content[$contentType],
OA\Schema::class
);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Nelmio\ApiDocBundle\Processor;
use OpenApi\Analysis;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use OpenApi\Processors\ProcessorInterface;
/**
* Processor to clean up the generated OpenAPI documentation for nullable properties.
*/
final class NullablePropertyProcessor implements ProcessorInterface
{
public function __invoke(Analysis $analysis): void
{
if (Generator::isDefault($analysis->openapi->components) || Generator::isDefault($analysis->openapi->components->schemas)) {
return;
}
/** @var OA\Schema[] $schemas */
$schemas = $analysis->openapi->components->schemas;
foreach ($schemas as $schema) {
if (Generator::UNDEFINED === $schema->properties) {
continue;
}
foreach ($schema->properties as $property) {
if (Generator::UNDEFINED !== $property->nullable) {
if (!$property->nullable) {
// if already false mark it as undefined (so it does not show up as `nullable: false`)
$property->nullable = Generator::UNDEFINED;
}
}
}
}
}
}
@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
class ArrayPropertyDescriber implements PropertyDescriberInterface, ModelRegistryAwareInterface, PropertyDescriberAwareInterface
{
use ModelRegistryAwareTrait;
use PropertyDescriberAwareTrait;
public function describe(array $types, OA\Schema $property, array $groups = null, ?OA\Schema $schema = null, array $context = [])
{
$property->type = 'array';
$property = Util::getChild($property, OA\Items::class);
foreach ($types[0]->getCollectionValueTypes() as $type) {
// Handle list pseudo type
// https://symfony.com/doc/current/components/property_info.html#type-getcollectionkeytypes-type-getcollectionvaluetypes
if ($this->supports([$type]) && empty($type->getCollectionValueTypes())) {
continue;
}
$this->propertyDescriber->describe([$type], $property, $groups, $schema, $context);
}
}
public function supports(array $types): bool
{
return 1 === count($types)
&& $types[0]->isCollection();
}
}
@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
use OpenApi\Annotations as OA;
use Symfony\Component\PropertyInfo\Type;
class BooleanPropertyDescriber implements PropertyDescriberInterface
{
public function describe(array $types, OA\Schema $property, array $groups = null, ?OA\Schema $schema = null, array $context = [])
{
$property->type = 'boolean';
}
public function supports(array $types): bool
{
return 1 === count($types) && Type::BUILTIN_TYPE_BOOL === $types[0]->getBuiltinType();
}
}
@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
class CompoundPropertyDescriber implements PropertyDescriberInterface, ModelRegistryAwareInterface, PropertyDescriberAwareInterface
{
use ModelRegistryAwareTrait;
use PropertyDescriberAwareTrait;
public function describe(array $types, OA\Schema $property, array $groups = null, ?OA\Schema $schema = null, array $context = [])
{
$property->oneOf = Generator::UNDEFINED !== $property->oneOf ? $property->oneOf : [];
foreach ($types as $type) {
$property->oneOf[] = $schema = Util::createChild($property, OA\Schema::class, []);
$this->propertyDescriber->describe([$type], $schema, $groups, $schema, $context);
}
}
public function supports(array $types): bool
{
return count($types) >= 2;
}
}
@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
use OpenApi\Annotations as OA;
use Symfony\Component\PropertyInfo\Type;
class DateTimePropertyDescriber implements PropertyDescriberInterface
{
public function describe(array $types, OA\Schema $property, array $groups = null, ?OA\Schema $schema = null, array $context = [])
{
$property->type = 'string';
$property->format = 'date-time';
}
public function supports(array $types): bool
{
return 1 === count($types)
&& Type::BUILTIN_TYPE_OBJECT === $types[0]->getBuiltinType()
&& is_a($types[0]->getClassName(), \DateTimeInterface::class, true);
}
}
@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
use OpenApi\Annotations as OA;
use Symfony\Component\PropertyInfo\Type;
class FloatPropertyDescriber implements PropertyDescriberInterface
{
public function describe(array $types, OA\Schema $property, array $groups = null, ?OA\Schema $schema = null, array $context = [])
{
$property->type = 'number';
$property->format = 'float';
}
public function supports(array $types): bool
{
return 1 === count($types) && Type::BUILTIN_TYPE_FLOAT === $types[0]->getBuiltinType();
}
}
@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
use OpenApi\Annotations as OA;
use Symfony\Component\PropertyInfo\Type;
class IntegerPropertyDescriber implements PropertyDescriberInterface
{
public function describe(array $types, OA\Schema $property, array $groups = null, ?OA\Schema $schema = null, array $context = [])
{
$property->type = 'integer';
}
public function supports(array $types): bool
{
return 1 === count($types) && Type::BUILTIN_TYPE_INT === $types[0]->getBuiltinType();
}
}
@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
final class NullablePropertyDescriber implements PropertyDescriberInterface, PropertyDescriberAwareInterface
{
use PropertyDescriberAwareTrait;
public function describe(array $types, OA\Schema $property, array $groups = null, ?OA\Schema $schema = null, array $context = [])
{
if (Generator::UNDEFINED === $property->nullable) {
$property->nullable = true;
}
$this->propertyDescriber->describe($types, $property, $groups, $schema, $context);
}
public function supports(array $types): bool
{
foreach ($types as $type) {
if ($type->isNullable()) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use Symfony\Component\PropertyInfo\Type;
/**
* @deprecated Since 4.17, {@see NullablePropertyDescriber} instead.
*/
trait NullablePropertyTrait
{
protected function setNullableProperty(Type $type, OA\Schema $property, ?OA\Schema $schema, array $context = []): void
{
if (Generator::UNDEFINED !== $property->nullable) {
if (!$property->nullable) {
// if already false mark it as undefined (so it does not show up as `nullable: false`)
$property->nullable = Generator::UNDEFINED;
}
return;
}
if ($type->isNullable()) {
$property->nullable = true;
}
if (!$type->isNullable() && Generator::UNDEFINED !== $property->default) {
return;
}
if (!$type->isNullable() && null !== $schema) {
$propertyName = Util::getSchemaPropertyName($schema, $property);
if (null === $propertyName) {
return;
}
$existingRequiredFields = Generator::UNDEFINED !== $schema->required ? $schema->required : [];
$existingRequiredFields[] = $propertyName;
$schema->required = array_values(array_unique($existingRequiredFields));
}
}
}
@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
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\PropertyInfo\Type;
class ObjectPropertyDescriber implements PropertyDescriberInterface, ModelRegistryAwareInterface
{
use ModelRegistryAwareTrait;
public function describe(array $types, OA\Schema $property, array $groups = null, ?OA\Schema $schema = null, array $context = [])
{
$type = new Type(
$types[0]->getBuiltinType(),
false,
$types[0]->getClassName(),
$types[0]->isCollection(),
// BC layer for symfony < 5.3
method_exists($types[0], 'getCollectionKeyTypes') ? $types[0]->getCollectionKeyTypes() : $types[0]->getCollectionKeyType(),
method_exists($types[0], 'getCollectionValueTypes') ?
($types[0]->getCollectionValueTypes()[0] ?? null) :
$types[0]->getCollectionValueType()
); // ignore nullable field
if ($types[0]->isNullable()) {
$weakContext = Util::createWeakContext($property->_context);
$schemas = [new OA\Schema(['ref' => $this->modelRegistry->register(new Model($type, $groups, null, $context)), '_context' => $weakContext])];
if (function_exists('enum_exists') && enum_exists($type->getClassName())) {
$property->allOf = $schemas;
} else {
$property->oneOf = $schemas;
}
return;
}
$property->ref = $this->modelRegistry->register(new Model($type, $groups, null, $context));
}
public function supports(array $types): bool
{
return 1 === count($types)
&& Type::BUILTIN_TYPE_OBJECT === $types[0]->getBuiltinType();
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Nelmio\ApiDocBundle\PropertyDescriber;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait;
use OpenApi\Annotations as OA;
final class PropertyDescriber implements PropertyDescriberInterface, ModelRegistryAwareInterface
{
use ModelRegistryAwareTrait;
/** @var array<string, PropertyDescriberInterface[]> Recursion helper */
private $called = [];
/** @var PropertyDescriberInterface[] */
private $propertyDescribers;
public function __construct(
iterable $propertyDescribers
) {
$this->propertyDescribers = $propertyDescribers;
}
public function describe(array $types, OA\Schema $property, array $groups = null, ?OA\Schema $schema = null, array $context = []): void
{
if (!$propertyDescriber = $this->getPropertyDescriber($types)) {
return;
}
$this->called[$this->getHash($types)][] = $propertyDescriber;
$propertyDescriber->describe($types, $property, $groups, $schema, $context);
$this->called = []; // Reset recursion helper
}
public function supports(array $types): bool
{
return null !== $this->getPropertyDescriber($types);
}
private function getHash(array $types): string
{
return md5(serialize($types));
}
private function getPropertyDescriber(array $types): ?PropertyDescriberInterface
{
foreach ($this->propertyDescribers as $propertyDescriber) {
/* BC layer for Symfony < 6.3 @see https://symfony.com/doc/6.3/service_container/tags.html#reference-tagged-services */
if ($propertyDescriber instanceof self) {
continue;
}
// Prevent infinite recursion
if (key_exists($this->getHash($types), $this->called)) {
if (in_array($propertyDescriber, $this->called[$this->getHash($types)], true)) {
continue;
}
}
if ($propertyDescriber instanceof ModelRegistryAwareInterface) {
$propertyDescriber->setModelRegistry($this->modelRegistry);
}
if ($propertyDescriber instanceof PropertyDescriberAwareInterface) {
$propertyDescriber->setPropertyDescriber($this);
}
if ($propertyDescriber->supports($types)) {
return $propertyDescriber;
}
}
return null;
}
}
@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
interface PropertyDescriberAwareInterface
{
public function setPropertyDescriber(PropertyDescriberInterface $propertyDescriber): void;
}
@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
trait PropertyDescriberAwareTrait
{
/**
* @var PropertyDescriberInterface
*/
protected $propertyDescriber;
public function setPropertyDescriber(PropertyDescriberInterface $propertyDescriber): void
{
$this->propertyDescriber = $propertyDescriber;
}
}
@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
use OpenApi\Annotations\Schema;
use Symfony\Component\PropertyInfo\Type;
interface PropertyDescriberInterface
{
/**
* @param Type[] $types
* @param string[]|null $groups Deprecated use $context['groups'] instead
* @param Schema $schema Allows to make changes inside of the schema (e.g. adding required fields)
* @param array<string, mixed> $context Context options for describing the property
*/
public function describe(array $types, Schema $property, array $groups = null /* , ?Schema $schema = null */ /* , array $context = [] */);
/**
* @param Type[] $types
*/
public function supports(array $types): bool;
}
@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
/**
* Mark a property as required if it is not nullable.
*/
final class RequiredPropertyDescriber implements PropertyDescriberInterface, PropertyDescriberAwareInterface
{
use PropertyDescriberAwareTrait;
public function describe(array $types, OA\Schema $property, array $groups = null, ?OA\Schema $schema = null, array $context = [])
{
$this->propertyDescriber->describe($types, $property, $groups, $schema, $context);
if (!$property instanceof OA\Property) {
return;
}
if (null === $schema) {
return;
}
if (true === $property->nullable || !Generator::isDefault($property->default)) {
return;
}
$existingRequiredFields = Generator::UNDEFINED !== $schema->required ? $schema->required : [];
$existingRequiredFields[] = $property->property;
$schema->required = array_values(array_unique($existingRequiredFields));
}
public function supports(array $types): bool
{
return true;
}
}
@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\PropertyDescriber;
use OpenApi\Annotations as OA;
use Symfony\Component\PropertyInfo\Type;
class StringPropertyDescriber implements PropertyDescriberInterface
{
public function describe(array $types, OA\Schema $property, array $groups = null, ?OA\Schema $schema = null, array $context = [])
{
$property->type = 'string';
}
public function supports(array $types): bool
{
return 1 === count($types) && Type::BUILTIN_TYPE_STRING === $types[0]->getBuiltinType();
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Render\Html;
class AssetsMode
{
public const BUNDLE = 'bundle';
public const CDN = 'cdn';
public const OFFLINE = 'offline';
}
@@ -0,0 +1,96 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Render\Html;
use Symfony\Bridge\Twig\Extension\AssetExtension;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* @internal
*/
class GetNelmioAsset extends AbstractExtension
{
private $assetExtension;
private $resourcesDir;
private $cdnUrl;
public function __construct(AssetExtension $assetExtension)
{
$this->assetExtension = $assetExtension;
$this->cdnUrl = 'https://cdn.jsdelivr.net/gh/nelmio/NelmioApiDocBundle/Resources/public';
$this->resourcesDir = __DIR__.'/../../Resources/public';
}
public function getFunctions(): array
{
return [
new TwigFunction('nelmioAsset', $this, ['is_safe' => ['html']]),
];
}
public function __invoke($defaultAssetsMode, $asset)
{
[$extension, $mode] = $this->getExtension($defaultAssetsMode, $asset);
[$resource, $isInline] = $this->getResource($asset, $mode);
if ('js' == $extension) {
return $this->renderJavascript($resource, $isInline);
} elseif ('css' == $extension) {
return $this->renderCss($resource, $isInline);
} else {
return $resource;
}
}
private function getExtension($assetsMode, $asset)
{
$extension = mb_substr($asset, -3, 3, 'utf-8');
if ('.js' === $extension) {
return ['js', $assetsMode];
} elseif ('png' === $extension) {
return ['png', AssetsMode::OFFLINE == $assetsMode ? AssetsMode::CDN : $assetsMode];
} else {
return ['css', $assetsMode];
}
}
private function getResource($asset, $mode)
{
if (filter_var($asset, FILTER_VALIDATE_URL)) {
return [$asset, false];
} elseif (AssetsMode::OFFLINE === $mode) {
return [file_get_contents($this->resourcesDir.'/'.$asset), true];
} elseif (AssetsMode::CDN === $mode) {
return [$this->cdnUrl.'/'.$asset, false];
} else {
return [$this->assetExtension->getAssetUrl(sprintf('bundles/nelmioapidoc/%s', $asset)), false];
}
}
private function renderJavascript(string $script, bool $isInline)
{
if ($isInline) {
return sprintf('<script>%s</script>', $script);
} else {
return sprintf('<script src="%s"></script>', $script);
}
}
private function renderCss(string $stylesheet, bool $isInline)
{
if ($isInline) {
return sprintf('<style>%s</style>', $stylesheet);
} else {
return sprintf('<link rel="stylesheet" href="%s">', $stylesheet);
}
}
}
@@ -0,0 +1,57 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Render\Html;
use InvalidArgumentException;
use Nelmio\ApiDocBundle\Render\OpenApiRenderer;
use Nelmio\ApiDocBundle\Render\RenderOpenApi;
use OpenApi\Annotations\OpenApi;
use Twig\Environment;
/**
* @internal
*/
class HtmlOpenApiRenderer implements OpenApiRenderer
{
/** @var Environment|\Twig_Environment */
private $twig;
public function __construct($twig)
{
if (!$twig instanceof \Twig_Environment && !$twig instanceof Environment) {
throw new InvalidArgumentException(sprintf('Providing an instance of "%s" as twig is not supported.', get_class($twig)));
}
$this->twig = $twig;
}
public function getFormat(): string
{
return RenderOpenApi::HTML;
}
public function render(OpenApi $spec, array $options = []): string
{
$options += [
'assets_mode' => AssetsMode::CDN,
'swagger_ui_config' => [],
];
return $this->twig->render(
'@NelmioApiDoc/SwaggerUi/index.html.twig',
[
'swagger_data' => ['spec' => json_decode($spec->toJson(), true)],
'assets_mode' => $options['assets_mode'],
'swagger_ui_config' => $options['swagger_ui_config'],
]
);
}
}
@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Render\Json;
use Nelmio\ApiDocBundle\Render\OpenApiRenderer;
use Nelmio\ApiDocBundle\Render\RenderOpenApi;
use OpenApi\Annotations\OpenApi;
/**
* @internal
*/
class JsonOpenApiRenderer implements OpenApiRenderer
{
public function getFormat(): string
{
return RenderOpenApi::JSON;
}
public function render(OpenApi $spec, array $options = []): string
{
$options += [
'no-pretty' => false,
];
$flags = $options['no-pretty'] ? 0 : JSON_PRETTY_PRINT;
return json_encode($spec, $flags | JSON_UNESCAPED_SLASHES);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Render;
use OpenApi\Annotations\OpenApi;
/**
* @internal
*/
interface OpenApiRenderer
{
public function getFormat(): string;
public function render(OpenApi $spec, array $options = []): string;
}
+103
View File
@@ -0,0 +1,103 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Render;
use Nelmio\ApiDocBundle\Exception\RenderInvalidArgumentException;
use OpenApi\Annotations\OpenApi;
use OpenApi\Annotations\Server;
use OpenApi\Context;
use OpenApi\Generator;
use Psr\Container\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
class RenderOpenApi
{
public const HTML = 'html';
public const JSON = 'json';
public const YAML = 'yaml';
/** @var ContainerInterface */
private $generatorLocator;
/** @var array<string, OpenApiRenderer|null> */
private $openApiRenderers = [];
public function __construct(ContainerInterface $generatorLocator, ?OpenApiRenderer ...$openApiRenderers)
{
$this->generatorLocator = $generatorLocator;
foreach ($openApiRenderers as $openApiRenderer) {
if (null === $openApiRenderer) {
continue;
}
$this->openApiRenderers[$openApiRenderer->getFormat()] = $openApiRenderer;
}
}
public function getAvailableFormats(): array
{
return array_keys($this->openApiRenderers);
}
public function renderFromRequest(Request $request, string $format, $area, array $extraOptions = [])
{
$options = [];
if ('' !== $request->getBaseUrl()) {
$options += [
'fallback_url' => $request->getSchemeAndHttpHost().$request->getBaseUrl(),
];
}
$options += $extraOptions;
return $this->render($format, $area, $options);
}
/**
* @throws InvalidArgumentException If the area to dump is not valid
*/
public function render(string $format, string $area, array $options = []): string
{
if (!$this->generatorLocator->has($area)) {
throw new RenderInvalidArgumentException(sprintf('Area "%s" is not supported.', $area));
} elseif (!array_key_exists($format, $this->openApiRenderers)) {
throw new RenderInvalidArgumentException(sprintf('Format "%s" is not supported.', $format));
}
/** @var OpenApi $spec */
$spec = $this->generatorLocator->get($area)->generate();
$tmpServers = $spec->servers;
try {
$spec->servers = $this->getServersFromOptions($spec, $options);
return $this->openApiRenderers[$format]->render($spec, $options);
} finally {
$spec->servers = $tmpServers; // Restore original value as we should not modify OpenApi object from the generator
}
}
private function getServersFromOptions(OpenApi $spec, array $options)
{
if (array_key_exists('server_url', $options) && $options['server_url']) {
return [new Server(['url' => $options['server_url'], '_context' => new Context()])];
}
if (Generator::UNDEFINED !== $spec->servers) {
return $spec->servers;
}
if (array_key_exists('fallback_url', $options) && $options['fallback_url']) {
return [new Server(['url' => $options['fallback_url'], '_context' => new Context()])];
}
return Generator::UNDEFINED;
}
}
@@ -0,0 +1,32 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Render\Yaml;
use Nelmio\ApiDocBundle\Render\OpenApiRenderer;
use Nelmio\ApiDocBundle\Render\RenderOpenApi;
use OpenApi\Annotations\OpenApi;
/**
* @internal
*/
class YamlOpenApiRenderer implements OpenApiRenderer
{
public function getFormat(): string
{
return RenderOpenApi::YAML;
}
public function render(OpenApi $spec, array $options = []): string
{
return $spec->toYaml();
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="nelmio_api_doc.describers.api_platform" class="Nelmio\ApiDocBundle\Describer\ApiPlatformDescriber" public="false">
<argument type="service" id="nelmio_api_doc.describers.api_platform.openapi" />
<argument type="service" id="api_platform.openapi.normalizer" />
<tag name="nelmio_api_doc.describer" priority="-100" />
</service>
<service id="nelmio_api_doc.describers.api_platform.openapi" class="ApiPlatform\OpenApi\OpenApi" public="false">
<factory service="api_platform.openapi.factory" method="__invoke" />
</service>
</services>
</container>
@@ -0,0 +1,15 @@
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="nelmio_api_doc.route_describers.fos_rest" class="Nelmio\ApiDocBundle\RouteDescriber\FosRestDescriber" public="false">
<argument type="service" id="annotation_reader" on-invalid="null"/> <!-- we don't deal with @OA annotations in this describer so we can use the cached reader -->
<argument />
<tag name="nelmio_api_doc.route_describer" priority="-250" />
</service>
</services>
</container>
@@ -0,0 +1,12 @@
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="nelmio_api_doc.route_describers.php_doc" class="Nelmio\ApiDocBundle\RouteDescriber\PhpDocDescriber" public="false">
<tag name="nelmio_api_doc.route_describer" priority="-275" />
</service>
</services>
</container>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" ?>
<routes xmlns="http://symfony.com/schema/routing"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/routing
http://symfony.com/schema/routing/routing-1.0.xsd">
<route id="nelmio_api_doc.swagger_ui" path="/" methods="GET">
<default key="_controller">nelmio_api_doc.controller.swagger_ui</default>
</route>
</routes>
@@ -0,0 +1,155 @@
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<!-- Commands -->
<service id="nelmio_api_doc.command.dump" class="Nelmio\ApiDocBundle\Command\DumpCommand" public="true">
<argument type="service" id="nelmio_api_doc.render_docs" />
<tag name="console.command" command="nelmio:apidoc:dump" />
</service>
<!-- Controllers -->
<service id="nelmio_api_doc.controller.swagger_ui" class="Nelmio\ApiDocBundle\Controller\SwaggerUiController" public="true">
<argument type="service" id="nelmio_api_doc.render_docs" />
</service>
<service id="nelmio_api_doc.controller.swagger" alias="nelmio_api_doc.controller.swagger_json" public="true" />
<service id="nelmio_api_doc.controller.swagger_json" class="Nelmio\ApiDocBundle\Controller\DocumentationController" public="true">
<argument type="service" id="nelmio_api_doc.render_docs" />
</service>
<service id="nelmio_api_doc.controller.swagger_yaml" class="Nelmio\ApiDocBundle\Controller\YamlDocumentationController" public="true">
<argument type="service" id="nelmio_api_doc.render_docs" />
</service>
<!-- Render -->
<service id="nelmio_api_doc.render_docs" class="Nelmio\ApiDocBundle\Render\RenderOpenApi" public="true">
<argument type="service" id="nelmio_api_doc.generator_locator" />
<argument type="service" id="nelmio_api_doc.render_docs.json" />
<argument type="service" id="nelmio_api_doc.render_docs.yaml" />
<argument type="service" id="nelmio_api_doc.render_docs.html" on-invalid="ignore" />
</service>
<service id="nelmio_api_doc.render_docs.html" class="Nelmio\ApiDocBundle\Render\Html\HtmlOpenApiRenderer" public="false">
<argument type="service" id="twig" />
</service>
<service id="nelmio_api_doc.render_docs.html.asset" class="Nelmio\ApiDocBundle\Render\Html\GetNelmioAsset" public="false">
<argument type="service" id="twig.extension.assets" />
<tag name="twig.extension" />
</service>
<service id="nelmio_api_doc.render_docs.json" class="Nelmio\ApiDocBundle\Render\Json\JsonOpenApiRenderer" public="false">
</service>
<service id="nelmio_api_doc.render_docs.yaml" class="Nelmio\ApiDocBundle\Render\Yaml\YamlOpenApiRenderer" public="false">
</service>
<!-- Swagger Spec Generator -->
<service id="nelmio_api_doc.generator" alias="nelmio_api_doc.generator.default" public="true" />
<service id="nelmio_api_doc.controller_reflector" class="Nelmio\ApiDocBundle\Util\ControllerReflector" public="false">
<argument type="service" id="service_container" />
</service>
<!-- Describers -->
<service id="nelmio_api_doc.describers.config" class="Nelmio\ApiDocBundle\Describer\ExternalDocDescriber" public="false">
<argument type="collection" />
<tag name="nelmio_api_doc.describer" priority="1000" />
</service>
<service id="nelmio_api_doc.describers.default" class="Nelmio\ApiDocBundle\Describer\DefaultDescriber" public="false">
<tag name="nelmio_api_doc.describer" priority="-1000" />
</service>
<!-- Routing Describers -->
<service id="nelmio_api_doc.route_describers.route_metadata" class="Nelmio\ApiDocBundle\RouteDescriber\RouteMetadataDescriber" public="false">
<tag name="nelmio_api_doc.route_describer" priority="-300" />
</service>
<!-- Model Describers -->
<service id="nelmio_api_doc.model_describers.self_describing" class="Nelmio\ApiDocBundle\ModelDescriber\SelfDescribingModelDescriber" public="false">
<tag name="nelmio_api_doc.model_describer" priority="1000" />
</service>
<service id="nelmio_api_doc.model_describers.object" class="Nelmio\ApiDocBundle\ModelDescriber\ObjectModelDescriber" public="false">
<argument type="service" id="property_info" />
<argument type="service" id="annotations.reader" on-invalid="null"/>
<argument type="service" id="nelmio_api_doc.object_model.property_describer" />
<argument />
<argument type="service" id="serializer.name_converter.metadata_aware" on-invalid="ignore" />
<argument>%nelmio_api_doc.use_validation_groups%</argument>
<argument type="service" id="serializer.mapping.class_metadata_factory" on-invalid="ignore" />
<tag name="nelmio_api_doc.model_describer" />
</service>
<service id="nelmio_api_doc.model_describers.enum" class="Nelmio\ApiDocBundle\ModelDescriber\EnumModelDescriber" public="false">
<tag name="nelmio_api_doc.model_describer" priority="100"/>
</service>
<service id="nelmio_api_doc.model_describers.object_fallback" class="Nelmio\ApiDocBundle\ModelDescriber\FallbackObjectModelDescriber" public="false">
<tag name="nelmio_api_doc.model_describer" priority="-1000" />
</service>
<!-- Property Describers -->
<service id="nelmio_api_doc.object_model.property_describer" class="Nelmio\ApiDocBundle\PropertyDescriber\PropertyDescriber" public="false">
<argument type="tagged" tag="nelmio_api_doc.object_model.property_describer" />
<tag name="nelmio_api_doc.object_model.property_describer" priority="100" />
</service>
<service id="nelmio_api_doc.object_model.property_describers.array" class="Nelmio\ApiDocBundle\PropertyDescriber\ArrayPropertyDescriber" public="false">
<tag name="nelmio_api_doc.object_model.property_describer" priority="-1000" />
</service>
<service id="nelmio_api_doc.object_model.property_describers.boolean" class="Nelmio\ApiDocBundle\PropertyDescriber\BooleanPropertyDescriber" public="false">
<tag name="nelmio_api_doc.object_model.property_describer" priority="-1000" />
</service>
<service id="nelmio_api_doc.object_model.property_describers.float" class="Nelmio\ApiDocBundle\PropertyDescriber\FloatPropertyDescriber" public="false">
<tag name="nelmio_api_doc.object_model.property_describer" priority="-1000" />
</service>
<service id="nelmio_api_doc.object_model.property_describers.integer" class="Nelmio\ApiDocBundle\PropertyDescriber\IntegerPropertyDescriber" public="false">
<tag name="nelmio_api_doc.object_model.property_describer" priority="-1000" />
</service>
<service id="nelmio_api_doc.object_model.property_describers.string" class="Nelmio\ApiDocBundle\PropertyDescriber\StringPropertyDescriber" public="false">
<tag name="nelmio_api_doc.object_model.property_describer" priority="-1000" />
</service>
<service id="nelmio_api_doc.object_model.property_describers.date_time" class="Nelmio\ApiDocBundle\PropertyDescriber\DateTimePropertyDescriber" public="false">
<tag name="nelmio_api_doc.object_model.property_describer" priority="-1000" />
</service>
<service id="nelmio_api_doc.object_model.property_describers.nullable" class="Nelmio\ApiDocBundle\PropertyDescriber\NullablePropertyDescriber" public="false">
<tag name="nelmio_api_doc.object_model.property_describer" />
</service>
<service id="nelmio_api_doc.object_model.property_describers.required" class="Nelmio\ApiDocBundle\PropertyDescriber\RequiredPropertyDescriber" public="false">
<tag name="nelmio_api_doc.object_model.property_describer" />
</service>
<service id="nelmio_api_doc.object_model.property_describers.object" class="Nelmio\ApiDocBundle\PropertyDescriber\ObjectPropertyDescriber" public="false">
<tag name="nelmio_api_doc.object_model.property_describer" priority="-1000" />
</service>
<service id="nelmio_api_doc.object_model.property_describers.compound" class="Nelmio\ApiDocBundle\PropertyDescriber\CompoundPropertyDescriber" public="false">
<tag name="nelmio_api_doc.object_model.property_describer" priority="-1000" />
</service>
<!-- Form Type Extensions -->
<service id="nelmio_api_doc.form.documentation_extension" class="Nelmio\ApiDocBundle\Form\Extension\DocumentationExtension">
<tag name="form.type_extension" extended-type="Symfony\Component\Form\Extension\Core\Type\FormType"/>
</service>
<!-- Swagger processors -->
<service id="nelmio_api_doc.swagger.processor.nullable_property" class="Nelmio\ApiDocBundle\Processor\NullablePropertyProcessor">
<tag name="nelmio_api_doc.swagger.processor" />
</service>
</services>
</container>
@@ -0,0 +1,52 @@
// This file is part of the API Platform project.
//
// (c) Kévin Dunglas <dunglas@gmail.com>
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
function loadSwaggerUI(userOptions = {}) {
const data = JSON.parse(document.getElementById('swagger-data').innerText);
const defaultOptions = {
spec: data.spec,
dom_id: '#swagger-ui',
validatorUrl: null,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: 'StandaloneLayout'
};
const options = Object.assign({}, defaultOptions, userOptions);
const ui = SwaggerUIBundle(options);
const storageKey = 'nelmio_api_auth';
// if we have auth in storage use it
if (sessionStorage.getItem(storageKey)) {
try {
ui.authActions.authorize(JSON.parse(sessionStorage.getItem(storageKey)));
} catch (ignored) {
// catch any errors here so it does not stop script execution
}
}
// hook into authorize to store the auth in local storage when user performs authorization
const currentAuthorize = ui.authActions.authorize;
ui.authActions.authorize = function (payload) {
sessionStorage.setItem(storageKey, JSON.stringify(payload));
return currentAuthorize(payload);
};
// hook into logout to clear auth from storage if user logs out
const currentLogout = ui.authActions.logout;
ui.authActions.logout = function (payload) {
sessionStorage.removeItem(storageKey);
return currentLogout(payload);
};
window.ui = ui;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

+297
View File
@@ -0,0 +1,297 @@
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body {
margin:70px 0 0;
background: #f0f0f0;
}
/** HEADER **/
header:before {
content:"";
background-color:#27848E;
height:70px;
width:100%;
text-align:center;
position:fixed;
top:0;
z-index:100;
box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
}
header #logo {
position:fixed;
top : 35px;
right:40px;
z-index:102;
transform:translateY(-50%);
}
header #logo img {
height:48px;
background-color:rgba(40, 134, 144, 0.4)
}
/** INCREASE WRAPPER BLOC PADDING **/
#swagger-ui.api-platform .wrapper {
padding:0px 60px;
}
/** INFORMATIONS BLOC **/
#swagger-ui.api-platform .information-container.wrapper {
margin:0;
padding:10px 0 0;
width:100%;
max-width:100%;
background-color:white;
border-bottom:1px solid #ccc;
margin-bottom:30px;
}
#swagger-ui.api-platform .info .title {
color:#3caab5;
}
#swagger-ui.api-platform .info {
width: 100%;
max-width: 1460px;
padding: 0px 50px;
margin: 0px auto;
}
/** METHODS BLOCS **/
#swagger-ui.api-platform .opblock.opblock-get .opblock-summary-method {
background-color:#3CAAB5;
}
#swagger-ui.api-platform .opblock.opblock-put .opblock-summary-method {
background-color:#E6C229;
}
#swagger-ui.api-platform .opblock.opblock-post .opblock-summary-method {
background-color:#78BC61;
}
#swagger-ui.api-platform .opblock.opblock-delete .opblock-summary-method {
background-color:#ED6A5A;
}
#swagger-ui.api-platform .opblock.opblock-deprecated .opblock-summary-method {
background-color:#ebebeb;
}
#swagger-ui.api-platform .opblock.opblock-get .opblock-summary {
border-color:#3CAAB5;
}
#swagger-ui.api-platform .opblock.opblock-put .opblock-summary {
border-color:#E6C229;
}
#swagger-ui.api-platform .opblock.opblock-post .opblock-summary {
border-color:#78BC61;
}
#swagger-ui.api-platform .opblock.opblock-delete .opblock-summary {
border-color:#ED6A5A;
}
#swagger-ui.api-platform .opblock.opblock-deprecated .opblock-summary {
border-color:#ebebeb;
}
#swagger-ui.api-platform .opblock-summary-method {
border-radius:0;
padding:10px;
}
#swagger-ui.api-platform .opblock-summary {
padding:0;
}
#swagger-ui.api-platform .opblock-tag {
padding:5px 0;
margin:0 0 10px;
}
#swagger-ui.api-platform .opblock-tag:hover {
background-color:rgba(0,0,0,.1);
transform:scale(1.01);
}
#swagger-ui.api-platform .opblock-section-header, #swagger-ui.api-platform .opblock.opblock-get .opblock-section-header {
background-color:rgba(60,170,181,0.1);
box-shadow:none;
}
#swagger-ui.api-platform .opblock.opblock-post .opblock-section-header {
background-color:rgba(120,188,97,0.1);
}
#swagger-ui.api-platform .opblock.opblock-put .opblock-section-header {
background-color:rgba(230, 194, 41, 0.1);
}
#swagger-ui.api-platform .opblock.opblock-delete .opblock-section-header {
background-color:rgba(237,106,90,0.1);
}
#swagger-ui.api-platform .opblock.opblock-deprecated .opblock-section-header {
background-color:rgba(235,235,235,0.1);
}
#swagger-ui.api-platform .opblock {
border-radius:0;
background-color:white;
box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
margin:0 0 10px;
padding:0;
border:none!important;
}
#swagger-ui .topbar {
display: none;
}
/** FORMATS **/
#formats {
text-align:right;
font-family: sans-serif;
width: 100%;
max-width: 1460px;
padding: 0px 60px;
margin:0 auto;
}
/** BUTTONS **/
#swagger-ui.api-platform .btn.execute {
background-color:#3CAAB5;
border-color:#3CAAB5;
animation:none;
transition:all ease 0.3s;
}
#swagger-ui.api-platform .btn.execute:hover {
background-color:#288690;
border-color:#288690;
}
#swagger-ui.api-platform .execute-wrapper {
text-align:center;
}
#swagger-ui.api-platform .execute-wrapper .btn {
width:auto;
padding:10px 40px;
}
#swagger-ui.api-platform .btn-group {
max-width: 500px;
margin-left: auto;
margin-right: auto;
}
#swagger-ui.api-platform .btn-group .btn {
padding:10px 40px;
}
#swagger-ui.api-platform .btn {
transition:all ease 0.2s;
box-shadow:none;
background-color: #f7f7f7
}
#swagger-ui.api-platform .btn:hover {
background-color:rgba(65,68,78,0.1);
border-color:transparent;
}
#swagger-ui.api-platform .btn.cancel:hover {
background-color:rgba(237,106,90,0.1);
}
#swagger-ui.api-platform .btn.authorize:hover {
background-color:rgba(120,188,97,0.1);
}
#swagger-ui.api-platform select {
box-shadow:none;
cursor:pointer;
}
/** FIX TABS SEPARATOR **/
#swagger-ui.api-platform .tab li:first-of-type:after {
content : none;
}
#swagger-ui.api-platform .tab li {
padding:0px 5px;
border-right:1px solid rgba(0,0,0,.2);
}
#swagger-ui.api-platform .tab li:last-of-type {
border-right:none;
}
/** REMOVE HIGHLIGHTS FOCUS INPUTS **/
#swagger-ui.api-platform input:focus,
#swagger-ui.api-platform select:focus,
#swagger-ui.api-platform textarea:focus,
#swagger-ui.api-platform button:focus {
outline: none;
}
/** REMOVE TITILIUM FONT **/
.swagger-ui .opblock-tag,
.swagger-ui .opblock .opblock-section-header label,
.swagger-ui .opblock .opblock-section-header h4,
.swagger-ui .opblock .opblock-summary-method,
.swagger-ui .tab li,
.swagger-ui .scheme-container .schemes>label,
.swagger-ui .loading-container .loading:after,
.swagger-ui .btn,
.swagger-ui .btn.cancel,
.swagger-ui select,
.swagger-ui label,
.swagger-ui .dialog-ux .modal-ux-content h4,
.swagger-ui .dialog-ux .modal-ux-header h3,
.swagger-ui section.models h4,
.swagger-ui section.models h5,
.swagger-ui .model-title,
.swagger-ui .parameter__name,
.swagger-ui .topbar a,
.swagger-ui .topbar .download-url-wrapper .download-url-button,
.swagger-ui .info .title small pre,
.swagger-ui .scopes h2,
.swagger-ui .errors-wrapper hgroup h4 {
font-family: sans-serif !important;
}
#swagger-ui-logos {
position: absolute;
width:0;
height:0;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,85 @@
{# This file is part of the API Platform project.
(c) Kévin Dunglas <dunglas@gmail.com>
For the full copyright and license information, please view the LICENSE
file that was distributed with this source code. #}
<!DOCTYPE html>
<html>
<head>
{% block meta %}
<meta charset="UTF-8">
{% endblock meta %}
<title>{% block title %}{{ swagger_data.spec.info.title }}{% endblock title %}</title>
{% block stylesheets %}
{{ nelmioAsset(assets_mode, 'swagger-ui/swagger-ui.css') }}
{{ nelmioAsset(assets_mode, 'style.css') }}
{% endblock stylesheets %}
{% block swagger_data %}
{# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #}
<script id="swagger-data" type="application/json">{{ swagger_data|json_encode(65)|raw }}</script>
{% endblock swagger_data %}
</head>
<body>
{% block svg_icons %}
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="swagger-ui-logos">
<defs>
<symbol viewBox="0 0 20 20" id="unlocked">
<path d="M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V6h2v-.801C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8z"></path>
</symbol>
<symbol viewBox="0 0 20 20" id="locked">
<path d="M15.8 8H14V5.6C14 2.703 12.665 1 10 1 7.334 1 6 2.703 6 5.6V8H4c-.553 0-1 .646-1 1.199V17c0 .549.428 1.139.951 1.307l1.197.387C5.672 18.861 6.55 19 7.1 19h5.8c.549 0 1.428-.139 1.951-.307l1.196-.387c.524-.167.953-.757.953-1.306V9.199C17 8.646 16.352 8 15.8 8zM12 8H8V5.199C8 3.754 8.797 3 10 3c1.203 0 2 .754 2 2.199V8z"></path>
</symbol>
<symbol viewBox="0 0 20 20" id="close">
<path d="M14.348 14.849c-.469.469-1.229.469-1.697 0L10 11.819l-2.651 3.029c-.469.469-1.229.469-1.697 0-.469-.469-.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-.469-.469-.469-1.228 0-1.697.469-.469 1.228-.469 1.697 0L10 8.183l2.651-3.031c.469-.469 1.228-.469 1.697 0 .469.469.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c.469.469.469 1.229 0 1.698z"></path>
</symbol>
<symbol viewBox="0 0 20 20" id="large-arrow">
<path d="M13.25 10L6.109 2.58c-.268-.27-.268-.707 0-.979.268-.27.701-.27.969 0l7.83 7.908c.268.271.268.709 0 .979l-7.83 7.908c-.268.271-.701.27-.969 0-.268-.269-.268-.707 0-.979L13.25 10z"></path>
</symbol>
<symbol viewBox="0 0 20 20" id="large-arrow-down">
<path d="M17.418 6.109c.272-.268.709-.268.979 0s.271.701 0 .969l-7.908 7.83c-.27.268-.707.268-.979 0l-7.908-7.83c-.27-.268-.27-.701 0-.969.271-.268.709-.268.979 0L10 13.25l7.418-7.141z"></path>
</symbol>
<symbol viewBox="0 0 24 24" id="jump-to">
<path d="M19 7v4H5.83l3.58-3.59L8 6l-6 6 6 6 1.41-1.41L5.83 13H21V7z"></path>
</symbol>
<symbol viewBox="0 0 24 24" id="expand">
<path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z"></path>
</symbol>
</defs>
</svg>
{% endblock svg_icons %}
{% block header_block %}
<header>
{% block header %}
<a id="logo" href="https://github.com/nelmio/NelmioApiDocBundle">
<img src="{{ nelmioAsset(assets_mode, 'logo.png') }}" alt="NelmioApiDocBundle">
</a>
{% endblock header %}
</header>
{% endblock header_block %}
{% block swagger_ui %}
<div id="swagger-ui" class="api-platform"></div>
{% endblock %}
{% block javascripts %}
{{ nelmioAsset(assets_mode, 'swagger-ui/swagger-ui-bundle.js') }}
{{ nelmioAsset(assets_mode, 'swagger-ui/swagger-ui-standalone-preset.js') }}
{% endblock javascripts %}
{{ nelmioAsset(assets_mode, 'init-swagger-ui.js') }}
{% block swagger_initialization %}
<script type="text/javascript">
(function () {
var swaggerUI = {{ swagger_ui_config|json_encode(65)|raw }};
window.onload = loadSwaggerUI(swaggerUI);
})();
</script>
{% endblock swagger_initialization %}
</body>
</html>
@@ -0,0 +1,226 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\RouteDescriber;
use Doctrine\Common\Annotations\Reader;
use FOS\RestBundle\Controller\Annotations\QueryParam;
use FOS\RestBundle\Controller\Annotations\RequestParam;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use Symfony\Component\Routing\Route;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints\Choice;
use Symfony\Component\Validator\Constraints\DateTime;
use Symfony\Component\Validator\Constraints\Regex;
final class FosRestDescriber implements RouteDescriberInterface
{
use RouteDescriberTrait;
/** @var Reader|null */
private $annotationReader;
/** @var string[] */
private $mediaTypes;
public function __construct(?Reader $annotationReader, array $mediaTypes)
{
$this->annotationReader = $annotationReader;
$this->mediaTypes = $mediaTypes;
}
public function describe(OA\OpenApi $api, Route $route, \ReflectionMethod $reflectionMethod)
{
$annotations = null !== $this->annotationReader
? $this->annotationReader->getMethodAnnotations($reflectionMethod)
: [];
$annotations = array_filter($annotations, static function ($value) {
return $value instanceof RequestParam || $value instanceof QueryParam;
});
$annotations = array_merge($annotations, $this->getAttributesAsAnnotation($reflectionMethod, RequestParam::class));
$annotations = array_merge($annotations, $this->getAttributesAsAnnotation($reflectionMethod, QueryParam::class));
foreach ($this->getOperations($api, $route) as $operation) {
foreach ($annotations as $annotation) {
$parameterName = $annotation->key ?? $annotation->getName(); // the key used by fosrest
if ($annotation instanceof QueryParam) {
$name = $parameterName.($annotation->map ? '[]' : '');
$parameter = Util::getOperationParameter($operation, $name, 'query');
$parameter->allowEmptyValue = $annotation->nullable && $annotation->allowBlank;
$parameter->required = !$annotation->nullable && $annotation->strict;
if (Generator::UNDEFINED === $parameter->description) {
$parameter->description = $annotation->description;
}
if ($annotation->map) {
$parameter->explode = true;
}
$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 (!$annotation->nullable && $annotation->strict) {
$requiredParameters = is_array($contentSchema->required) ? $contentSchema->required : [];
$requiredParameters[] = $parameterName;
$contentSchema->required = array_values(array_unique($requiredParameters));
}
$this->describeCommonSchemaFromAnnotation($schema, $annotation);
}
}
}
}
}
private function getPattern($requirements)
{
if (is_array($requirements) && isset($requirements['rule'])) {
return (string) $requirements['rule'];
}
if (is_string($requirements)) {
return $requirements;
}
if ($requirements instanceof Regex) {
return $requirements->getHtmlPattern();
}
return null;
}
private function getFormat($requirements)
{
if ($requirements instanceof Constraint && !$requirements instanceof Regex) {
if ($requirements instanceof DateTime) {
// As defined per RFC3339
if (\DateTime::RFC3339 === $requirements->format || 'c' === $requirements->format) {
return 'date-time';
}
if ('Y-m-d' === $requirements->format) {
return 'date';
}
return null;
}
$reflectionClass = new \ReflectionClass($requirements);
return $reflectionClass->getShortName();
}
return null;
}
private function getEnum($requirements)
{
if ($requirements instanceof Choice) {
return $requirements->choices;
}
return null;
}
private function getContentSchemaForType(OA\RequestBody $requestBody, string $type): OA\Schema
{
$requestBody->content = Generator::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])) {
$weakContext = Util::createWeakContext($requestBody->_context);
$requestBody->content[$contentType] = new OA\MediaType(
[
'mediaType' => $contentType,
'_context' => $weakContext,
]
);
/** @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 (Generator::UNDEFINED === $schema->type) {
$schema->type = $annotation->map ? 'array' : 'string';
}
if ($annotation->map) {
$schema->type = 'array';
$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;
}
$enum = $this->getEnum($annotation->requirements);
if (null !== $enum) {
$schema->enum = $enum;
}
}
/**
* @return OA\AbstractAnnotation[]
*/
private function getAttributesAsAnnotation(\ReflectionMethod $reflection, string $className): array
{
$annotations = [];
if (\PHP_VERSION_ID < 80100) {
return $annotations;
}
foreach ($reflection->getAttributes($className, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$annotations[] = $attribute->newInstance();
}
return $annotations;
}
}
@@ -0,0 +1,68 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\RouteDescriber;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use phpDocumentor\Reflection\DocBlockFactory;
use phpDocumentor\Reflection\DocBlockFactoryInterface;
use Symfony\Component\Routing\Route;
final class PhpDocDescriber implements RouteDescriberInterface
{
use RouteDescriberTrait;
private $docBlockFactory;
public function __construct(DocBlockFactoryInterface $docBlockFactory = null)
{
if (null === $docBlockFactory) {
$docBlockFactory = DocBlockFactory::createInstance();
}
$this->docBlockFactory = $docBlockFactory;
}
public function describe(OA\OpenApi $api, Route $route, \ReflectionMethod $reflectionMethod)
{
$classDocBlock = null;
$docBlock = null;
try {
$classDocBlock = $this->docBlockFactory->create($reflectionMethod->getDeclaringClass());
} catch (\Exception $e) {
}
try {
$docBlock = $this->docBlockFactory->create($reflectionMethod);
} catch (\Exception $e) {
}
foreach ($this->getOperations($api, $route) as $operation) {
if (null !== $docBlock) {
if (Generator::UNDEFINED === $operation->summary && '' !== $docBlock->getSummary()) {
$operation->summary = $docBlock->getSummary();
}
if (Generator::UNDEFINED === $operation->description && '' !== (string) $docBlock->getDescription()) {
$operation->description = (string) $docBlock->getDescription();
}
if ($docBlock->hasTag('deprecated')) {
$operation->deprecated = true;
}
}
if (null !== $classDocBlock) {
if ($classDocBlock->hasTag('deprecated')) {
$operation->deprecated = true;
}
}
}
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Nelmio\ApiDocBundle\RouteDescriber;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\RouteArgumentDescriberInterface;
use OpenApi\Annotations as OA;
use ReflectionMethod;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactoryInterface;
use Symfony\Component\Routing\Route;
final class RouteArgumentDescriber implements RouteDescriberInterface, ModelRegistryAwareInterface
{
use RouteDescriberTrait;
use ModelRegistryAwareTrait;
/**
* @param RouteArgumentDescriberInterface[] $inlineParameterDescribers
*/
public function __construct(
private ArgumentMetadataFactoryInterface $argumentMetadataFactory,
private iterable $inlineParameterDescribers
) {
}
public function describe(OA\OpenApi $api, Route $route, ReflectionMethod $reflectionMethod): void
{
$controller = $route->getDefault('_controller');
try {
$argumentMetaDataList = $this->argumentMetadataFactory->createArgumentMetadata($controller, $reflectionMethod);
} catch (\ReflectionException) {
return;
}
if (!$argumentMetaDataList) {
return;
}
foreach ($this->getOperations($api, $route) as $operation) {
foreach ($argumentMetaDataList as $argumentMetadata) {
foreach ($this->inlineParameterDescribers as $inlineParameterDescriber) {
if ($inlineParameterDescriber instanceof ModelRegistryAwareInterface) {
$inlineParameterDescriber->setModelRegistry($this->modelRegistry);
}
$inlineParameterDescriber->describe($argumentMetadata, $operation);
}
}
}
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber;
use OpenApi\Annotations as OA;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
interface RouteArgumentDescriberInterface
{
public function describe(ArgumentMetadata $argumentMetadata, OA\Operation $operation): void;
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use OpenApi\Processors\Concerns\TypesTrait;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
final class SymfonyMapQueryParameterDescriber implements RouteArgumentDescriberInterface
{
use TypesTrait;
public function describe(ArgumentMetadata $argumentMetadata, OA\Operation $operation): void
{
/** @var MapQueryParameter $attribute */
if (!$attribute = $argumentMetadata->getAttributes(MapQueryParameter::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null) {
return;
}
$name = $attribute->name ?? $argumentMetadata->getName();
$name = 'array' === $argumentMetadata->getType()
? $name.'[]'
: $name;
$operationParameter = Util::getOperationParameter($operation, $name, 'query');
Util::modifyAnnotationValue($operationParameter, 'required', !($argumentMetadata->hasDefaultValue() || $argumentMetadata->isNullable()));
/** @var OA\Schema $schema */
$schema = Util::getChild($operationParameter, OA\Schema::class);
if ($argumentMetadata->hasDefaultValue()) {
Util::modifyAnnotationValue($schema, 'default', $argumentMetadata->getDefaultValue());
}
if (Generator::UNDEFINED === $schema->nullable && $argumentMetadata->isNullable()) {
Util::modifyAnnotationValue($schema, 'nullable', true);
}
$defaultFilter = match ($argumentMetadata->getType()) {
'array' => null,
'string' => \FILTER_DEFAULT,
'int' => \FILTER_VALIDATE_INT,
'float' => \FILTER_VALIDATE_FLOAT,
'bool' => \FILTER_VALIDATE_BOOL,
default => null,
};
$properties = $this->describeValidateFilter($attribute->filter ?? $defaultFilter, $attribute->flags, $attribute->options);
if ('array' === $argumentMetadata->getType()) {
$schema->type = 'array';
Util::getChild($schema, OA\Items::class, $properties);
} else {
foreach ($properties as $key => $value) {
Util::modifyAnnotationValue($schema, $key, $value);
}
}
}
/**
* @see https://www.php.net/manual/en/filter.filters.validate.php
*/
private function describeValidateFilter(?int $filter, int $flags, array $options): array
{
if (null === $filter) {
return [];
}
if (FILTER_VALIDATE_BOOLEAN === $filter) {
return ['type' => 'boolean'];
}
if (FILTER_VALIDATE_DOMAIN === $filter) {
return ['type' => 'string', 'format' => 'hostname'];
}
if (FILTER_VALIDATE_EMAIL === $filter) {
return ['type' => 'string', 'format' => 'email'];
}
if (FILTER_VALIDATE_FLOAT === $filter) {
return ['type' => 'number', 'format' => 'float'];
}
if (FILTER_VALIDATE_INT === $filter) {
$props = [];
if ($options['min_range'] ?? false) {
$props['minimum'] = $options['min_range'];
}
if ($options['max_range'] ?? false) {
$props['maximum'] = $options['max_range'];
}
return ['type' => 'integer', ...$props];
}
if (FILTER_VALIDATE_IP === $filter) {
$format = match ($flags) {
FILTER_FLAG_IPV4 => 'ipv4',
FILTER_FLAG_IPV6 => 'ipv6',
default => 'ip',
};
return ['type' => 'string', 'format' => $format];
}
if (FILTER_VALIDATE_MAC === $filter) {
return ['type' => 'string', 'format' => 'mac'];
}
if (FILTER_VALIDATE_REGEXP === $filter) {
return ['type' => 'string', 'pattern' => $options['regexp']];
}
if (FILTER_VALIDATE_URL === $filter) {
return ['type' => 'string', 'format' => 'uri'];
}
if (FILTER_DEFAULT === $filter) {
return ['type' => 'string'];
}
return [];
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait;
use Nelmio\ApiDocBundle\Model\Model;
use OpenApi\Annotations as OA;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Validator\Constraints\GroupSequence;
final class SymfonyMapQueryStringDescriber implements RouteArgumentDescriberInterface, ModelRegistryAwareInterface
{
public const CONTEXT_KEY = 'nelmio_api_doc_bundle.map_query_string.'.self::class;
public const CONTEXT_ARGUMENT_METADATA = 'nelmio_api_doc_bundle.argument_metadata.'.self::class;
public const CONTEXT_MODEL_REF = 'nelmio_api_doc_bundle.model_ref.'.self::class;
use ModelRegistryAwareTrait;
public function describe(ArgumentMetadata $argumentMetadata, OA\Operation $operation): void
{
/** @var MapQueryString $attribute */
if (!$attribute = $argumentMetadata->getAttributes(MapQueryString::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null) {
return;
}
$modelRef = $this->modelRegistry->register(new Model(
new Type(Type::BUILTIN_TYPE_OBJECT, $argumentMetadata->isNullable(), $argumentMetadata->getType()),
groups: $this->getGroups($attribute),
serializationContext: $attribute->serializationContext,
));
if (!isset($operation->_context->{self::CONTEXT_KEY})) {
$operation->_context->{self::CONTEXT_KEY} = [];
}
$data = [
self::CONTEXT_ARGUMENT_METADATA => $argumentMetadata,
self::CONTEXT_MODEL_REF => $modelRef,
];
$operation->_context->{self::CONTEXT_KEY}[] = $data;
}
/**
* @return string[]|null
*/
private function getGroups(MapQueryString $attribute): ?array
{
if (null === $attribute->validationGroups) {
return null;
}
if (is_string($attribute->validationGroups)) {
return [$attribute->validationGroups];
}
if (is_array($attribute->validationGroups)) {
return $attribute->validationGroups;
}
if ($attribute->validationGroups instanceof GroupSequence) {
return $attribute->validationGroups->groups;
}
return null;
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface;
use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait;
use Nelmio\ApiDocBundle\Model\Model;
use OpenApi\Annotations as OA;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Validator\Constraints\GroupSequence;
final class SymfonyMapRequestPayloadDescriber implements RouteArgumentDescriberInterface, ModelRegistryAwareInterface
{
public const CONTEXT_ARGUMENT_METADATA = 'nelmio_api_doc_bundle.argument_metadata.'.self::class;
public const CONTEXT_MODEL_REF = 'nelmio_api_doc_bundle.model_ref.'.self::class;
use ModelRegistryAwareTrait;
public function describe(ArgumentMetadata $argumentMetadata, OA\Operation $operation): void
{
/** @var MapRequestPayload $attribute */
if (!$attribute = $argumentMetadata->getAttributes(MapRequestPayload::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null) {
return;
}
$modelRef = $this->modelRegistry->register(new Model(
new Type(Type::BUILTIN_TYPE_OBJECT, false, $argumentMetadata->getType()),
groups: $this->getGroups($attribute),
serializationContext: $attribute->serializationContext,
));
$operation->_context->{self::CONTEXT_ARGUMENT_METADATA} = $argumentMetadata;
$operation->_context->{self::CONTEXT_MODEL_REF} = $modelRef;
}
/**
* @return string[]|null
*/
private function getGroups(MapRequestPayload $attribute): ?array
{
if (null === $attribute->validationGroups) {
return null;
}
if (is_string($attribute->validationGroups)) {
return [$attribute->validationGroups];
}
if (is_array($attribute->validationGroups)) {
return $attribute->validationGroups;
}
if ($attribute->validationGroups instanceof GroupSequence) {
return $attribute->validationGroups->groups;
}
return null;
}
}
@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\RouteDescriber;
use OpenApi\Annotations\OpenApi;
use Symfony\Component\Routing\Route;
interface RouteDescriberInterface
{
public function describe(OpenApi $api, Route $route, \ReflectionMethod $reflectionMethod);
}
@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\RouteDescriber;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
use OpenApi\Annotations\OpenApi;
use Symfony\Component\Routing\Route;
trait RouteDescriberTrait
{
/**
* @internal
*
* @return OA\Operation[]
*/
private function getOperations(OpenApi $api, Route $route): array
{
$operations = [];
$path = Util::getPath($api, $this->normalizePath($route->getPath()));
$methods = $route->getMethods() ?: Util::OPERATIONS;
foreach ($methods as $method) {
$method = strtolower($method);
if (!in_array($method, Util::OPERATIONS)) {
continue;
}
$operations[] = Util::getOperation($path, $method);
}
return $operations;
}
private function normalizePath(string $path): string
{
if ('.{_format}' === substr($path, -10)) {
$path = substr($path, 0, -10);
}
return $path;
}
}
@@ -0,0 +1,139 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\RouteDescriber;
use LogicException;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use Symfony\Component\Routing\Route;
/**
* Should be last route describer executed to make sure all params are set.
*/
final class RouteMetadataDescriber implements RouteDescriberInterface
{
use RouteDescriberTrait;
private const ALPHANUM_EXPANDED_REGEX = '/^[-a-zA-Z0-9_]*$/';
public function describe(OA\OpenApi $api, Route $route, \ReflectionMethod $reflectionMethod)
{
foreach ($this->getOperations($api, $route) as $operation) {
$requirements = $route->getRequirements();
$compiledRoute = $route->compile();
$existingParams = $this->getRefParams($api, $operation);
// Don't include host requirements
foreach ($compiledRoute->getPathVariables() as $pathVariable) {
if ('_format' === $pathVariable) {
continue;
}
$paramId = $pathVariable.'/path';
/** @var OA\Parameter $parameter */
$parameter = $existingParams[$paramId] ?? null;
if (null !== $parameter) {
if (!$parameter->required || Generator::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 = Util::getOperationParameter($operation, $pathVariable, 'path');
$parameter->required = true;
$parameter->schema = Util::getChild($parameter, OA\Schema::class);
if (Generator::UNDEFINED === $parameter->schema->type) {
$parameter->schema->type = 'string';
}
// handle pattern and possible enum values
if (isset($requirements[$pathVariable])) {
$req = $requirements[$pathVariable];
$enumValues = $this->getPossibleEnumValues($req);
if ($enumValues && Generator::UNDEFINED === $parameter->schema->pattern) {
$parameter->schema->enum = $enumValues;
}
// add the pattern anyway
if (Generator::UNDEFINED === $parameter->schema->pattern) {
$parameter->schema->pattern = $requirements[$pathVariable];
}
}
}
}
}
/**
* The '$ref' parameters need special handling, since their objects are missing 'name' and 'in'.
*
* @return OA\Parameter[] existing $ref parameters
*/
private function getRefParams(OA\OpenApi $api, OA\Operation $operation): array
{
/** @var OA\Parameter[] $globalParams */
$globalParams = Generator::UNDEFINED !== $api->components && Generator::UNDEFINED !== $api->components->parameters ? $api->components->parameters : [];
$globalParams = array_column($globalParams, null, 'parameter'); // update the indexes of the array with the reference names actually used
$existingParams = [];
$operationParameters = Generator::UNDEFINED !== $operation->parameters ? $operation->parameters : [];
/** @var OA\Parameter $parameter */
foreach ($operationParameters as $id => $parameter) {
$ref = $parameter->ref;
if (Generator::UNDEFINED === $ref) {
// we only concern ourselves with '$ref' parameters, so continue the loop
continue;
}
$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;
}
$refParameter = $globalParams[$ref];
// param ids are in form {name}/{in}
$existingParams[\sprintf('%s/%s', $refParameter->name, $refParameter->in)] = $refParameter;
}
return $existingParams;
}
/**
* returns array of separated alphanumeric (including '-', '_') strings from a simple OR regex requirement pattern.
* (routing parameters containing enums have already been resolved to that format at this time).
*
* @param string $reqPattern a requirement pattern to match, e.g. 'a.html|b.html'
*
* @return array<string>
*/
private function getPossibleEnumValues(string $reqPattern): array
{
$requirements = [];
if (false !== strpos($reqPattern, '|')) {
$parts = explode('|', $reqPattern);
foreach ($parts as $part) {
if ('' === $part || 0 === preg_match(self::ALPHANUM_EXPANDED_REGEX, $part)) {
// we check a complex regex expression containing | - abort in that case
return [];
} else {
$requirements[] = $part;
}
}
}
return $requirements;
}
}
@@ -0,0 +1,210 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Routing;
use Doctrine\Common\Annotations\Reader;
use Nelmio\ApiDocBundle\Annotation\Areas;
use Nelmio\ApiDocBundle\Util\ControllerReflector;
use OpenApi\Annotations\AbstractAnnotation;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
final class FilteredRouteCollectionBuilder
{
/** @var Reader|null */
private $annotationReader;
/** @var ControllerReflector */
private $controllerReflector;
/** @var string */
private $area;
/** @var array */
private $options;
public function __construct(
?Reader $annotationReader,
ControllerReflector $controllerReflector,
string $area,
array $options = []
) {
$resolver = new OptionsResolver();
$resolver
->setDefaults([
'path_patterns' => [],
'host_patterns' => [],
'name_patterns' => [],
'with_annotation' => false,
'disable_default_routes' => false,
])
->setAllowedTypes('path_patterns', 'string[]')
->setAllowedTypes('host_patterns', 'string[]')
->setAllowedTypes('name_patterns', 'string[]')
->setAllowedTypes('with_annotation', 'boolean')
->setAllowedTypes('disable_default_routes', 'boolean')
;
if (array_key_exists(0, $options)) {
trigger_deprecation('nelmio/api-doc-bundle', '3.2', 'Passing an indexed array with a collection of path patterns as argument 1 for `%s()` is deprecated since 3.2.0, expected structure is an array containing parameterized options.', __METHOD__);
$normalizedOptions = ['path_patterns' => $options];
$options = $normalizedOptions;
}
$this->annotationReader = $annotationReader;
$this->controllerReflector = $controllerReflector;
$this->area = $area;
$this->options = $resolver->resolve($options);
}
public function filter(RouteCollection $routes): RouteCollection
{
$filteredRoutes = new RouteCollection();
foreach ($routes->all() as $name => $route) {
if ($this->matchPath($route)
&& $this->matchHost($route)
&& $this->matchAnnotation($route)
&& $this->matchName($name)
&& $this->defaultRouteDisabled($route)
) {
$filteredRoutes->add($name, $route);
}
}
return $filteredRoutes;
}
private function matchPath(Route $route): bool
{
foreach ($this->options['path_patterns'] as $pathPattern) {
if (preg_match('{'.$pathPattern.'}', $route->getPath())) {
return true;
}
}
return 0 === count($this->options['path_patterns']);
}
private function matchHost(Route $route): bool
{
foreach ($this->options['host_patterns'] as $hostPattern) {
if (preg_match('{'.$hostPattern.'}', $route->getHost())) {
return true;
}
}
return 0 === count($this->options['host_patterns']);
}
private function matchName(string $name): bool
{
foreach ($this->options['name_patterns'] as $namePattern) {
if (preg_match('{'.$namePattern.'}', $name)) {
return true;
}
}
return 0 === count($this->options['name_patterns']);
}
private function matchAnnotation(Route $route): bool
{
if (false === $this->options['with_annotation']) {
return true;
}
$reflectionMethod = $this->controllerReflector->getReflectionMethod($route->getDefault('_controller'));
if (null === $reflectionMethod) {
return false;
}
/** @var Areas|null $areas */
$areas = $this->getAttributesAsAnnotation($reflectionMethod, Areas::class)[0] ?? null;
if (null === $areas) {
/** @var Areas|null $areas */
$areas = $this->getAttributesAsAnnotation($reflectionMethod->getDeclaringClass(), Areas::class)[0] ?? null;
if (null === $areas && null !== $this->annotationReader) {
/** @var Areas|null $areas */
$areas = $this->annotationReader->getMethodAnnotation(
$reflectionMethod,
Areas::class
);
if (null === $areas) {
$areas = $this->annotationReader->getClassAnnotation($reflectionMethod->getDeclaringClass(), Areas::class);
}
}
}
return (null !== $areas) ? $areas->has($this->area) : false;
}
private function defaultRouteDisabled(Route $route): bool
{
if (false === $this->options['disable_default_routes']) {
return true;
}
$method = $this->controllerReflector->getReflectionMethod(
$route->getDefault('_controller') ?? ''
);
if (null === $method) {
return false;
}
$annotations = null !== $this->annotationReader
? $this->annotationReader->getMethodAnnotations($method)
: [];
if (method_exists(\ReflectionMethod::class, 'getAttributes')) {
$annotations = array_merge($annotations, array_map(function (\ReflectionAttribute $attribute) {
return $attribute->newInstance();
}, $method->getAttributes(AbstractAnnotation::class, \ReflectionAttribute::IS_INSTANCEOF)));
}
foreach ($annotations as $annotation) {
if (false !== strpos(get_class($annotation), 'Nelmio\\ApiDocBundle\\Annotation')
|| false !== strpos(get_class($annotation), 'OpenApi\\Annotations')
|| false !== strpos(get_class($annotation), 'OpenApi\\Attributes')
) {
return true;
}
}
return false;
}
/**
* @param \ReflectionClass|\ReflectionMethod $reflection
*
* @return Areas[]
*/
private function getAttributesAsAnnotation($reflection, string $className): array
{
$annotations = [];
if (\PHP_VERSION_ID < 80100) {
return $annotations;
}
foreach ($reflection->getAttributes($className, \ReflectionAttribute::IS_INSTANCEOF) as $attribute) {
$annotations[] = $attribute->newInstance();
}
return $annotations;
}
}
@@ -0,0 +1,131 @@
<?php
/*
* This file is part of the NelmioApiDocBundle package.
*
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Nelmio\ApiDocBundle\Util;
use Symfony\Bundle\FrameworkBundle\Controller\ControllerNameParser;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Kernel;
/**
* @internal
*/
class ControllerReflector
{
private $container;
private $controllerNameParser;
private $controllers = [];
public function __construct(ContainerInterface $container)
{
$this->container = $container;
if (1 < \func_num_args() && func_get_arg(1) instanceof ControllerNameParser) {
$this->controllerNameParser = func_get_arg(1);
}
}
/**
* Returns the ReflectionMethod for the given controller string.
*
* @return \ReflectionMethod|null
*/
public function getReflectionMethod($controller)
{
if (is_string($controller)) {
$controller = $this->getClassAndMethod($controller);
}
if (null === $controller) {
return null;
}
return $this->geReflectionMethodByClassNameAndMethodName(...$controller);
}
/**
* @return \ReflectionMethod|null
*/
public function geReflectionMethodByClassNameAndMethodName(string $class, string $method)
{
try {
return new \ReflectionMethod($class, $method);
} catch (\ReflectionException $e) {
// In case we can't reflect the controller, we just
// ignore the route
}
return null;
}
private function getClassAndMethod(string $controller)
{
if (isset($this->controllers[$controller])) {
return $this->controllers[$controller];
}
if ($this->controllerNameParser && false === strpos($controller, '::') && 2 === substr_count($controller, ':')) {
$deprecatedNotation = $controller;
try {
$controller = $this->controllerNameParser->parse($controller);
trigger_deprecation('nelmio/api-doc-bundle', '3.6', 'Referencing controllers with %s is deprecated since Symfony 4.1, use "%s" instead.', $deprecatedNotation, $controller);
} catch (\InvalidArgumentException $e) {
// unable to optimize unknown notation
}
}
if (preg_match('#(.+)::([\w]+)#', $controller, $matches)) {
$class = $matches[1];
$method = $matches[2];
// Since symfony 4.1 routes are defined like service_id::method_name
if (Kernel::VERSION_ID >= 40100 && !class_exists($class)) {
if ($this->container->has($class)) {
$class = get_class($this->container->get($class));
if (class_exists(ClassUtils::class)) {
$class = ClassUtils::getRealClass($class);
}
}
}
} elseif (class_exists($controller)) {
$class = $controller;
$method = '__invoke';
} else {
// Has to be removed when dropping support of symfony < 4.1
if (preg_match('#(.+):([\w]+)#', $controller, $matches)) {
$controller = $matches[1];
$method = $matches[2];
}
if ($this->container->has($controller)) {
$class = get_class($this->container->get($controller));
if (class_exists(ClassUtils::class)) {
$class = ClassUtils::getRealClass($class);
}
if (!isset($method) && method_exists($class, '__invoke')) {
$method = '__invoke';
}
}
}
if (!isset($class) || !isset($method)) {
$this->controllers[$controller] = null;
return null;
}
return $this->controllers[$controller] = [$class, $method];
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Nelmio\ApiDocBundle\Util;
use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use OpenApi\Context;
/**
* @internal
*/
trait SetsContextTrait
{
private function setContext(?Context $context): void
{
// zircote/swagger-php ^4.0
\OpenApi\Generator::$context = $context;
}
private function setContextFromReflection(Context $parentContext, $reflection): void
{
// In order to have nicer errors
if ($reflection instanceof \ReflectionClass) {
$this->setContext(Util::createWeakContext($parentContext, [
'namespace' => $reflection->getNamespaceName(),
'class' => $reflection->getShortName(),
'filename' => $reflection->getFileName(),
]));
} else {
$declaringClass = $reflection->getDeclaringClass();
$this->setContext(Util::createWeakContext($parentContext, [
'namespace' => $declaringClass->getNamespaceName(),
'class' => $declaringClass->getShortName(),
'property' => $reflection->name,
'filename' => $declaringClass->getFileName(),
]));
}
}
}

Some files were not shown because too many files have changed in this diff Show More