Merge pull request #973 from nelmio/FILTERED_COLLECTION

[3.0] Get ride of Swagger-Php parser
This commit is contained in:
Guilhem Niot 2017-03-17 19:29:48 +01:00 committed by GitHub
commit 1b7eb1c69c
9 changed files with 216 additions and 280 deletions

21
Annotation/Operation.php Normal file
View File

@ -0,0 +1,21 @@
<?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 Swagger\Annotations\Operation as BaseOperation;
/**
* @Annotation
*/
class Operation extends BaseOperation
{
}

View File

@ -13,11 +13,10 @@ namespace Nelmio\ApiDocBundle\DependencyInjection;
use FOS\RestBundle\Controller\Annotations\ParamInterface; use FOS\RestBundle\Controller\Annotations\ParamInterface;
use phpDocumentor\Reflection\DocBlockFactory; use phpDocumentor\Reflection\DocBlockFactory;
use Swagger\Annotations\Swagger;
use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\HttpKernel\DependencyInjection\Extension;
final class NelmioApiDocExtension extends Extension implements PrependExtensionInterface final class NelmioApiDocExtension extends Extension implements PrependExtensionInterface
@ -41,7 +40,7 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI
$loader->load('services.xml'); $loader->load('services.xml');
// Filter routes // Filter routes
$routeCollectionBuilder = $container->getDefinition('nelmio_api_doc.describers.route.filtered_route_collection_builder'); $routeCollectionBuilder = $container->getDefinition('nelmio_api_doc.filtered_route_collection_builder');
$routeCollectionBuilder->replaceArgument(0, $config['routes']['path_patterns']); $routeCollectionBuilder->replaceArgument(0, $config['routes']['path_patterns']);
// Import services needed for each library // Import services needed for each library

View File

@ -11,13 +11,15 @@
namespace Nelmio\ApiDocBundle\Describer; namespace Nelmio\ApiDocBundle\Describer;
use Doctrine\Common\Annotations\Reader;
use EXSyst\Component\Swagger\Swagger;
use Nelmio\ApiDocBundle\Annotation\Operation;
use Nelmio\ApiDocBundle\SwaggerPhp\AddDefaults; use Nelmio\ApiDocBundle\SwaggerPhp\AddDefaults;
use Nelmio\ApiDocBundle\SwaggerPhp\ModelRegister; use Nelmio\ApiDocBundle\SwaggerPhp\ModelRegister;
use Nelmio\ApiDocBundle\SwaggerPhp\OperationResolver;
use Nelmio\ApiDocBundle\Util\ControllerReflector; use Nelmio\ApiDocBundle\Util\ControllerReflector;
use Swagger\Analyser;
use Swagger\Analysis; use Swagger\Analysis;
use Symfony\Component\Finder\Finder; use Swagger\Annotations as SWG;
use Swagger\Context;
use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouteCollection;
final class SwaggerPhpDescriber extends ExternalDocDescriber implements ModelRegistryAwareInterface final class SwaggerPhpDescriber extends ExternalDocDescriber implements ModelRegistryAwareInterface
@ -26,57 +28,138 @@ final class SwaggerPhpDescriber extends ExternalDocDescriber implements ModelReg
private $routeCollection; private $routeCollection;
private $controllerReflector; private $controllerReflector;
private $annotationReader;
public function __construct(RouteCollection $routeCollection, ControllerReflector $controllerReflector, bool $overwrite = false) public function __construct(RouteCollection $routeCollection, ControllerReflector $controllerReflector, Reader $annotationReader, bool $overwrite = false)
{ {
$this->routeCollection = $routeCollection; $this->routeCollection = $routeCollection;
$this->controllerReflector = $controllerReflector; $this->controllerReflector = $controllerReflector;
$this->annotationReader = $annotationReader;
parent::__construct(function () { parent::__construct(function () {
$whitelist = Analyser::$whitelist; $analysis = $this->getAnnotations();
Analyser::$whitelist = false;
try {
$options = ['processors' => $this->getProcessors()];
$annotation = \Swagger\scan($this->getFinder(), $options);
return json_decode(json_encode($annotation)); $analysis->process($this->getProcessors());
} finally { $analysis->validate();
Analyser::$whitelist = $whitelist;
} return json_decode(json_encode($analysis->swagger));
}, $overwrite); }, $overwrite);
} }
private function getFinder()
{
$files = [];
foreach ($this->routeCollection->all() as $route) {
if (!$route->hasDefault('_controller')) {
continue;
}
// if able to resolve the controller
$controller = $route->getDefault('_controller');
if ($callable = $this->controllerReflector->getReflectionClassAndMethod($controller)) {
list($class, $method) = $callable;
$files[$class->getFileName()] = true;
}
}
$finder = new Finder();
$finder->append(array_keys($files));
return $finder;
}
private function getProcessors(): array private function getProcessors(): array
{ {
$processors = [ $processors = [
new AddDefaults(), new AddDefaults(),
new ModelRegister($this->modelRegistry), new ModelRegister($this->modelRegistry),
new OperationResolver($this->routeCollection, $this->controllerReflector),
]; ];
return array_merge($processors, Analysis::processors()); return array_merge($processors, Analysis::processors());
} }
private function getAnnotations(): Analysis
{
$analysis = new Analysis();
$operationAnnotations = [
'get' => SWG\Get::class,
'post' => SWG\Post::class,
'put' => SWG\Put::class,
'patch' => SWG\Patch::class,
'delete' => SWG\Delete::class,
'options' => SWG\Options::class,
'head' => SWG\Head::class,
];
foreach ($this->getMethodsToParse() as $method => list($path, $httpMethods)) {
$annotations = array_filter($this->annotationReader->getMethodAnnotations($method), function ($v) {
return $v instanceof SWG\AbstractAnnotation;
});
if (0 === count($annotations)) {
continue;
}
$declaringClass = $method->getDeclaringClass();
$context = new Context([
'namespace' => $method->getNamespaceName(),
'class' => $declaringClass->getShortName(),
'method' => $method->name,
'filename' => $method->getFileName(),
]);
$implicitAnnotations = [];
foreach ($annotations as $annotation) {
$annotation->_context = $context;
if ($annotation instanceof Operation) {
foreach ($httpMethods as $httpMethod) {
$annotationClass = $operationAnnotations[$httpMethod];
$operation = new $annotationClass(['_context' => $context]);
$operation->path = $path;
$operation->mergeProperties($annotation);
$analysis->addAnnotation($operation, null);
}
continue;
}
if ($annotation instanceof SWG\Operation) {
if (null === $annotation->path) {
$annotation = clone $annotation;
$annotation->path = $path;
}
$analysis->addAnnotation($annotation, null);
continue;
}
if (!$annotation instanceof SWG\Response && !$annotation instanceof SWG\Parameter && !$annotation instanceof SWG\ExternalDocumentation) {
throw new \LogicException(sprintf('Using the annotation "%s" as a root annotation in "%s::%s()" is not allowed.', get_class($annotation), $method->getDeclaringClass()->name, $method->name));
}
$implicitAnnotations[] = $annotation;
}
if (0 === count($implicitAnnotations)) {
continue;
}
foreach ($httpMethods as $httpMethod) {
$annotationClass = $operationAnnotations[$httpMethod];
$operation = new $annotationClass(['_context' => $context, 'path' => $path, 'value' => $implicitAnnotations]);
$analysis->addAnnotation($operation, null);
}
}
return $analysis;
}
private function getMethodsToParse()
{
foreach ($this->routeCollection->all() as $route) {
if (!$route->hasDefault('_controller')) {
continue;
}
$controller = $route->getDefault('_controller');
if ($callable = $this->controllerReflector->getReflectionClassAndMethod($controller)) {
list($class, $method) = $callable;
$path = $this->normalizePath($route->getPath());
$httpMethods = $route->getMethods() ?: Swagger::$METHODS;
$httpMethods = array_map('strtolower', $httpMethods);
yield $method => [$path, $httpMethods];
}
}
}
private function normalizePath(string $path)
{
if (substr($path, -10) === '.{_format}') {
$path = substr($path, 0, -10);
}
return $path;
}
} }

View File

@ -14,6 +14,19 @@
<argument type="service" id="controller_name_converter" /> <argument type="service" id="controller_name_converter" />
</service> </service>
<service id="nelmio_api_doc.filtered_route_collection" class="Symfony\Component\Routing\RouteCollection" public="false">
<factory service="nelmio_api_doc.filtered_route_collection_builder" method="filter" />
<argument type="service">
<service class="Symfony\Component\Routing\RouteCollection">
<factory service="router" method="getRouteCollection" />
</service>
</argument>
</service>
<service id="nelmio_api_doc.filtered_route_collection_builder" class="Nelmio\ApiDocBundle\Routing\FilteredRouteCollectionBuilder" public="false">
<argument type="collection" /> <!-- Path patterns -->
</service>
<!-- Describers --> <!-- Describers -->
<service id="nelmio_api_doc.describers.config" class="Nelmio\ApiDocBundle\Describer\ExternalDocDescriber" public="false"> <service id="nelmio_api_doc.describers.config" class="Nelmio\ApiDocBundle\Describer\ExternalDocDescriber" public="false">
<argument type="collection" /> <argument type="collection" />
@ -21,21 +34,8 @@
<tag name="nelmio_api_doc.describer" priority="1000" /> <tag name="nelmio_api_doc.describer" priority="1000" />
</service> </service>
<service id="nelmio_api_doc.describers.route.filtered_route_collection_builder" class="Nelmio\ApiDocBundle\Routing\FilteredRouteCollectionBuilder" public="false">
<argument type="collection" /> <!-- Path patterns -->
</service>
<service id="nelmio_api_doc.describers.route" class="Nelmio\ApiDocBundle\Describer\RouteDescriber" public="false"> <service id="nelmio_api_doc.describers.route" class="Nelmio\ApiDocBundle\Describer\RouteDescriber" public="false">
<argument type="service"> <argument type="service" id="nelmio_api_doc.filtered_route_collection" />
<service class="Symfony\Component\Routing\RouteCollection">
<factory service="nelmio_api_doc.describers.route.filtered_route_collection_builder" method="filter" />
<argument type="service">
<service class="Symfony\Component\Routing\RouteCollection">
<factory service="router" method="getRouteCollection" />
</service>
</argument>
</service>
</argument>
<argument type="service" id="nelmio_api_doc.controller_reflector" /> <argument type="service" id="nelmio_api_doc.controller_reflector" />
<argument type="collection" /> <argument type="collection" />

View File

@ -5,12 +5,9 @@
<services> <services>
<service id="nelmio_api_doc.describers.swagger_php" class="Nelmio\ApiDocBundle\Describer\SwaggerPhpDescriber" public="false"> <service id="nelmio_api_doc.describers.swagger_php" class="Nelmio\ApiDocBundle\Describer\SwaggerPhpDescriber" public="false">
<argument type="service"> <argument type="service" id="nelmio_api_doc.filtered_route_collection" />
<service class="Symfony\Component\Routing\RouteCollection">
<factory service="router" method="getRouteCollection" />
</service>
</argument>
<argument type="service" id="nelmio_api_doc.controller_reflector" /> <argument type="service" id="nelmio_api_doc.controller_reflector" />
<argument type="service" id="annotation_reader" />
<tag name="nelmio_api_doc.describer" priority="-200" /> <tag name="nelmio_api_doc.describer" priority="-200" />
</service> </service>

View File

@ -38,38 +38,51 @@ final class ModelRegister
public function __invoke(Analysis $analysis) public function __invoke(Analysis $analysis)
{ {
foreach ($analysis->annotations as $annotation) { foreach ($analysis->annotations as $annotation) {
if (!$annotation instanceof ModelAnnotation || $annotation->_context->not('nested')) { if ($annotation instanceof Response) {
continue; $annotationClass = Schema::class;
} } elseif ($annotation instanceof Parameter) {
if ('array' === $annotation->type) {
if (!is_string($annotation->type)) { $annotationClass = Items::class;
// Ignore invalid annotations, they are validated later } else {
continue; $annotationClass = Schema::class;
} }
} elseif ($annotation instanceof Schema) {
$parent = $annotation->_context->nested;
if (!$parent instanceof Response && !$parent instanceof Parameter && !$parent instanceof Schema) {
continue;
}
$annotationClass = Schema::class;
if ($parent instanceof Schema) {
$annotationClass = Items::class; $annotationClass = Items::class;
} else {
continue;
} }
$parent->merge([new $annotationClass([ $model = null;
'ref' => $this->modelRegistry->register(new Model($this->createType($annotation->type))), foreach ($annotation->_unmerged as $unmerged) {
])]); if ($unmerged instanceof ModelAnnotation) {
$model = $unmerged;
// It is no longer an unmerged annotation
foreach ($parent->_unmerged as $key => $unmerged) {
if ($unmerged === $annotation) {
unset($parent->_unmerged[$key]);
break; break;
} }
} }
$analysis->annotations->detach($annotation);
if (null === $model || !$model instanceof ModelAnnotation) {
continue;
}
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))),
])]);
// It is no longer an unmerged annotation
foreach ($annotation->_unmerged as $key => $unmerged) {
if ($unmerged === $model) {
unset($annotation->_unmerged[$key]);
break;
}
}
$analysis->annotations->detach($model);
} }
} }

View File

@ -1,195 +0,0 @@
<?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\SwaggerPhp;
use EXSyst\Component\Swagger\Swagger;
use Nelmio\ApiDocBundle\Util\ControllerReflector;
use Swagger\Analysis;
use Swagger\Annotations as SWG;
use Swagger\Context;
use Symfony\Component\Routing\RouteCollection;
/**
* Automatically resolves the {@link SWG\Operation} linked to
* {@link SWG\Response}, {@link SWG\Parameter} and
* {@link SWG\ExternalDocumentation} annotations.
*
* @internal
*/
final class OperationResolver
{
private $routeCollection;
private $controllerReflector;
private $controllerMap;
public function __construct(RouteCollection $routeCollection, ControllerReflector $controllerReflector)
{
$this->routeCollection = $routeCollection;
$this->controllerReflector = $controllerReflector;
}
public function __invoke(Analysis $analysis)
{
$this->resolveOperationsPath($analysis);
$this->createImplicitOperations($analysis);
}
private function resolveOperationsPath(Analysis $analysis)
{
$operations = $analysis->getAnnotationsOfType(SWG\Operation::class);
foreach ($operations as $operation) {
if (null !== $operation->path || $operation->_context->not('method')) {
continue;
}
$paths = $this->getPaths($operation->_context, $operation->method);
if (0 === count($paths)) {
continue;
}
// Define the path of the first annotation
$operation->path = array_pop($paths);
// If there are other paths, clone the annotation
foreach ($paths as $path) {
$alias = clone $operation;
$alias->path = $path;
$analysis->addAnnotation($alias, $alias->_context);
}
}
}
private function createImplicitOperations(Analysis $analysis)
{
$annotations = array_merge($analysis->getAnnotationsOfType(SWG\Response::class), $analysis->getAnnotationsOfType(SWG\Parameter::class), $analysis->getAnnotationsOfType(SWG\ExternalDocumentation::class));
$map = [];
foreach ($annotations as $annotation) {
$context = $annotation->_context;
if ($context->not('method')) {
continue;
}
$class = $this->getClass($context);
$method = $context->method;
$id = $class.'|'.$method;
if (!isset($map[$id])) {
$map[$id] = [];
}
$map[$id][] = $annotation;
}
$operationAnnotations = [
'get' => SWG\Get::class,
'post' => SWG\Post::class,
'put' => SWG\Put::class,
'patch' => SWG\Patch::class,
'delete' => SWG\Delete::class,
'options' => SWG\Options::class,
'head' => SWG\Head::class,
];
foreach ($map as $id => $annotations) {
$context = $annotations[0]->_context;
$httpMethods = $this->getHttpMethods($context);
foreach ($httpMethods as $httpMethod => $paths) {
$annotationClass = $operationAnnotations[$httpMethod];
foreach ($paths as $path => $v) {
$operation = new $annotationClass(['path' => $path, 'value' => $annotations], $context);
$analysis->addAnnotation($operation, $context);
}
}
foreach ($annotations as $annotation) {
$analysis->annotations->detach($annotation);
}
}
}
private function getPaths(Context $context, string $httpMethod): array
{
$httpMethods = $this->getHttpMethods($context);
if (!isset($httpMethods[$httpMethod])) {
return [];
}
return array_keys($httpMethods[$httpMethod]);
}
private function getHttpMethods(Context $context)
{
if (null === $this->controllerMap) {
$this->buildMap();
}
$class = $this->getClass($context);
$method = $context->method;
// Checks if a route corresponds to this method
if (!isset($this->controllerMap[$class][$method])) {
return [];
}
return $this->controllerMap[$class][$method];
}
private function getClass(Context $context)
{
return ltrim($context->namespace.'\\'.$context->class, '\\');
}
private function buildMap()
{
$this->controllerMap = [];
foreach ($this->routeCollection->all() as $route) {
if (!$route->hasDefault('_controller')) {
continue;
}
$controller = $route->getDefault('_controller');
if ($callable = $this->controllerReflector->getReflectionClassAndMethod($controller)) {
list($class, $method) = $callable;
$class = $class->name;
$method = $method->name;
if (!isset($this->controllerMap[$class])) {
$this->controllerMap[$class] = [];
}
if (!isset($this->controllerMap[$class][$method])) {
$this->controllerMap[$class][$method] = [];
}
$httpMethods = $route->getMethods() ?: Swagger::$METHODS;
foreach ($httpMethods as $httpMethod) {
$httpMethod = strtolower($httpMethod);
if (!isset($this->controllerMap[$class][$method][$httpMethod])) {
$this->controllerMap[$class][$method][$httpMethod] = [];
}
$path = $this->normalizePath($route->getPath());
$this->controllerMap[$class][$method][$httpMethod][$path] = true;
}
}
}
}
private function normalizePath(string $path)
{
if (substr($path, -10) === '.{_format}') {
$path = substr($path, 0, -10);
}
return $path;
}
}

View File

@ -14,6 +14,7 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Controller;
use FOS\RestBundle\Controller\Annotations\QueryParam; use FOS\RestBundle\Controller\Annotations\QueryParam;
use FOS\RestBundle\Controller\Annotations\RequestParam; use FOS\RestBundle\Controller\Annotations\RequestParam;
use Nelmio\ApiDocBundle\Annotation\Model; use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Operation;
use Nelmio\ApiDocBundle\Tests\Functional\Entity\User; use Nelmio\ApiDocBundle\Tests\Functional\Entity\User;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
@ -26,7 +27,7 @@ class ApiController
/** /**
* @Route("/swagger", methods={"GET"}) * @Route("/swagger", methods={"GET"})
* @Route("/swagger2", methods={"GET"}) * @Route("/swagger2", methods={"GET"})
* @SWG\Get( * @Operation(
* @SWG\Response(response="201", description="An example resource") * @SWG\Response(response="201", description="An example resource")
* ) * )
*/ */
@ -92,4 +93,14 @@ class ApiController
public function adminAction() public function adminAction()
{ {
} }
/**
* @SWG\Get(
* path="/filtered",
* @SWG\Response(response="201", description="")
* )
*/
public function filteredAction()
{
}
} }

View File

@ -29,6 +29,13 @@ class FunctionalTest extends WebTestCase
$this->assertFalse($paths->has('/api/admin')); $this->assertFalse($paths->has('/api/admin'));
} }
public function testFilteredAction()
{
$paths = $this->getSwaggerDefinition()->getPaths();
$this->assertFalse($paths->has('/filtered'));
}
/** /**
* Tests that the paths are automatically resolved in Swagger annotations. * Tests that the paths are automatically resolved in Swagger annotations.
* *