Merge pull request #938 from nelmio/SWAGGERPHP

[3.0] Allow implicit operations with SwaggerPhp annotations
This commit is contained in:
Guilhem N 2017-01-08 12:21:35 +01:00 committed by GitHub
commit 2b2ac363a9
14 changed files with 435 additions and 75 deletions

View File

@ -2,7 +2,7 @@
$finder = PhpCsFixer\Finder::create()
->in(__DIR__)
->exclude('tests/Fixtures/app/cache')
->exclude('Tests/Functional/cache')
;
return PhpCsFixer\Config::create()
@ -11,14 +11,14 @@ return PhpCsFixer\Config::create()
'ordered_imports' => true,
'phpdoc_order' => true,
'header_comment' => [
'header' => <<<COMMENT
'header' => <<<HEADER
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.
COMMENT
HEADER
],
])
->setFinder($finder)

View File

@ -22,6 +22,6 @@ class AddRouteDescribersPass implements CompilerPassInterface
{
$routeDescribers = $this->findAndSortTaggedServices('nelmio_api_doc.route_describer', $container);
$container->getDefinition('nelmio_api_doc.describers.route')->replaceArgument(3, $routeDescribers);
$container->getDefinition('nelmio_api_doc.describers.route')->replaceArgument(2, $routeDescribers);
}
}

View File

@ -1,8 +1,9 @@
<?php
/*
* This file is part of the Symfony package.
* This file is part of the NelmioApiDocBundle package.
*
* (c) Fabien Potencier <fabien@symfony.com>
* (c) Nelmio
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
@ -50,6 +51,7 @@ trait PriorityTaggedServiceTrait
krsort($services);
$services = call_user_func_array('array_merge', $services);
}
return $services;
}
}

View File

@ -11,32 +11,27 @@
namespace Nelmio\ApiDocBundle\Describer;
use Doctrine\Common\Util\ClassUtils;
use EXSyst\Component\Swagger\Swagger;
use Nelmio\ApiDocBundle\RouteDescriber\RouteDescriberInterface;
use Symfony\Bundle\FrameworkBundle\Controller\ControllerNameParser;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Nelmio\ApiDocBundle\Util\ControllerReflector;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
final class RouteDescriber implements DescriberInterface
{
private $container;
private $routeCollection;
private $controllerNameParser;
private $controllerReflector;
private $routeDescribers;
/**
* @param ContainerInterface $container
* @param RouteCollection $routeCollection
* @param ControllerNameParser $controllerNameParser
* @param ControllerReflector $controllerReflector
* @param RouteDescriberInterface[] $routeDescribers
*/
public function __construct(ContainerInterface $container, RouteCollection $routeCollection, ControllerNameParser $controllerNameParser, array $routeDescribers)
public function __construct(RouteCollection $routeCollection, ControllerReflector $controllerReflector, array $routeDescribers)
{
$this->container = $container;
$this->routeCollection = $routeCollection;
$this->controllerNameParser = $controllerNameParser;
$this->controllerReflector = $controllerReflector;
$this->routeDescribers = $routeDescribers;
}
@ -47,8 +42,13 @@ final class RouteDescriber implements DescriberInterface
}
foreach ($this->routeCollection->all() as $route) {
if (!$route->hasDefault('_controller')) {
continue;
}
// if able to resolve the controller
if ($method = $this->getReflectionMethod($route->getDefault('_controller') ?? '')) {
$controller = $route->getDefault('_controller');
if ($method = $this->controllerReflector->getReflectionMethod($controller)) {
// Extract as many informations as possible about this route
foreach ($this->routeDescribers as $describer) {
$describer->describe($api, $route, $method);
@ -56,50 +56,4 @@ final class RouteDescriber implements DescriberInterface
}
}
}
/**
* Returns the ReflectionMethod for the given controller string.
*
* @param string $controller
*
* @return \ReflectionMethod|null
*/
private function getReflectionMethod(string $controller)
{
if (false === strpos($controller, '::') && 2 === substr_count($controller, ':')) {
$controller = $this->controllerNameParser->parse($controller);
}
if (preg_match('#(.+)::([\w]+)#', $controller, $matches)) {
$class = $matches[1];
$method = $matches[2];
} elseif (class_exists($controller)) {
$class = $controller;
$method = '__invoke';
} else {
if (preg_match('#(.+):([\w]+)#', $controller, $matches)) {
$controller = $matches[1];
$method = $matches[2];
}
if ($this->container->has($controller)) {
if (class_exists(ClassUtils::class)) {
$class = ClassUtils::getRealClass(get_class($this->container->get($controller)));
}
if (!isset($method) && method_exists($class, '__invoke')) {
$method = '__invoke';
}
}
}
if (isset($class) && isset($method)) {
try {
return new \ReflectionMethod($class, $method);
} catch (\ReflectionException $e) {
// In case we can't reflect the controller, we just
// ignore the route
}
}
}
}

View File

@ -11,12 +11,17 @@
namespace Nelmio\ApiDocBundle\Describer;
use Nelmio\ApiDocBundle\SwaggerPhp\OperationResolver;
use Swagger\Analysis;
final class SwaggerPhpDescriber extends ExternalDocDescriber
{
private $operationResolver;
public function __construct(string $projectPath, bool $overwrite = false)
{
parent::__construct(function () use ($projectPath) {
// Catch notices as the documentation can be completed by other describers
// Ignore notices as the documentation can be completed by other describers
$prevHandler = set_error_handler(function ($type, $message, $file, $line, $context) use (&$prevHandler) {
if (E_USER_NOTICE === $type || E_USER_WARNING === $type) {
return;
@ -26,7 +31,12 @@ final class SwaggerPhpDescriber extends ExternalDocDescriber
});
try {
$annotation = \Swagger\scan($projectPath);
$options = [];
if (null !== $this->operationResolver) {
$options['processors'] = array_merge([$this->operationResolver], Analysis::processors());
}
$annotation = \Swagger\scan($projectPath, $options);
return json_decode(json_encode($annotation));
} finally {
@ -34,4 +44,13 @@ final class SwaggerPhpDescriber extends ExternalDocDescriber
}
}, $overwrite);
}
/**
* If set, the describer will try to complete paths and create
* implicit operations.
*/
public function setOperationResolver(OperationResolver $operationResolver)
{
$this->operationResolver = $operationResolver;
}
}

View File

@ -8,6 +8,11 @@
<argument type="collection" />
</service>
<service id="nelmio_api_doc.controller_reflector" class="Nelmio\ApiDocBundle\Util\ControllerReflector" public="false">
<argument type="service" id="service_container" />
<argument type="service" id="controller_name_converter" />
</service>
<!-- Extractors -->
<service id="nelmio_api_doc.describers.route.filtered_route_collection_builder" class="Nelmio\ApiDocBundle\Routing\FilteredRouteCollectionBuilder" public="false">
<argument type="collection" /> <!-- Path patterns -->
@ -15,7 +20,6 @@
<service id="nelmio_api_doc.describers.route" class="Nelmio\ApiDocBundle\Describer\RouteDescriber" public="false">
<argument type="service" id="service_container" />
<argument type="service">
<service class="Symfony\Component\Routing\RouteCollection">
<factory service="nelmio_api_doc.describers.route.filtered_route_collection_builder" method="filter" />
@ -26,7 +30,7 @@
</argument>
</service>
</argument>
<argument type="service" id="controller_name_converter" />
<argument type="service" id="nelmio_api_doc.controller_reflector" />
<argument type="collection" />
<tag name="nelmio_api_doc.describer" priority="-100" />

View File

@ -6,9 +6,21 @@
<services>
<service id="nelmio_api_doc.describers.swagger_php" class="Nelmio\ApiDocBundle\Describer\SwaggerPhpDescriber" public="false">
<argument>%kernel.root_dir%</argument>
<call method="setOperationResolver">
<argument type="service" id="nelmio_api_doc.describers.swagger_php.operation_resolver" />
</call>
<tag name="nelmio_api_doc.describer" priority="-300" />
</service>
<service id="nelmio_api_doc.describers.swagger_php.operation_resolver" class="Nelmio\ApiDocBundle\SwaggerPhp\OperationResolver" public="false">
<argument type="service">
<service class="Symfony\Component\Routing\RouteCollection">
<factory service="router" method="getRouteCollection" />
</service>
</argument>
<argument type="service" id="nelmio_api_doc.controller_reflector" />
</service>
</services>
</container>

View File

@ -0,0 +1,195 @@
<?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\Describer;
use EXSyst\Component\Swagger\Swagger;
use Nelmio\ApiDocBundle\Describer\RouteDescriber;
use Nelmio\ApiDocBundle\RouteDescriber\RouteDescriberInterface;
use Nelmio\ApiDocBundle\Util\ControllerReflector;
use Symfony\Bundle\FrameworkBundle\Controller\ControllerNameParser;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\Routing\Route;
@ -38,9 +39,11 @@ class RouteDescriberTest extends AbstractDescriberTest
$this->routeDescriber = $this->createMock(RouteDescriberInterface::class);
$this->routes = new RouteCollection();
$this->describer = new RouteDescriber(
new Container(),
$this->routes,
$this->createMock(ControllerNameParser::class),
new ControllerReflector(
new Container(),
$this->createMock(ControllerNameParser::class)
),
[$this->routeDescriber]
);
}

View File

@ -11,14 +11,14 @@
namespace Nelmio\ApiDocBundle\Tests\Functional\Fixtures\SwaggerPhp;
use Swagger\Annotations\Info as InfoAnnotation;
use Swagger\Annotations\Info;
/**
* @InfoAnnotation(
* @Info(
* title="My Awesome App",
* version="1.3"
* )
*/
class Info
class Api
{
}

View File

@ -15,12 +15,37 @@ use FOS\RestBundle\Controller\Annotations\QueryParam;
use FOS\RestBundle\Controller\Annotations\RequestParam;
use Nelmio\ApiDocBundle\Annotation\ApiDoc;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Swagger\Annotations as SWG;
/**
* @Route("/api")
*/
class ApiController
{
/**
* @Route("/swagger", methods={"GET"})
* @Route("/swagger2", methods={"GET"})
* @SWG\Get(
* @SWG\Response(response="201", description="An example resource")
* )
*/
public function swaggerAction()
{
}
/**
* @Route("/swagger/implicit", methods={"GET", "POST"})
* @SWG\Response(response="201", description="Operation automatically detected")
* @SWG\Parameter(
* name="foo",
* in="query",
* description="This is a parameter"
* )
*/
public function implicitSwaggerAction()
{
}
/**
* @Route("/test/{user}", methods={"GET"}, schemes={"https"}, requirements={"user"="/foo/"})
*/

View File

@ -22,6 +22,46 @@ class FunctionalTest extends WebTestCase
$this->assertFalse($paths->has('/api/admin'));
}
/**
* Tests that the paths are automatically resolved in Swagger annotations.
*
* @dataProvider swaggerActionPathsProvider
*/
public function testSwaggerAction($path)
{
$operation = $this->getOperation($path, 'get');
$responses = $operation->getResponses();
$this->assertTrue($responses->has('201'));
$this->assertEquals('An example resource', $responses->get('201')->getDescription());
}
public function swaggerActionPathsProvider()
{
return [['/api/swagger'], ['/api/swagger2']];
}
/**
* @dataProvider implicitSwaggerActionMethodsProvider
*/
public function testImplicitSwaggerAction($method)
{
$operation = $this->getOperation('/api/swagger/implicit', $method);
$responses = $operation->getResponses();
$this->assertTrue($responses->has('201'));
$this->assertEquals('Operation automatically detected', $responses->get('201')->getDescription());
$parameters = $operation->getParameters();
$this->assertTrue($parameters->has('foo', 'query'));
$this->assertEquals('This is a parameter', $parameters->get('foo', 'query')->getDescription());
}
public function implicitSwaggerActionMethodsProvider()
{
return [['get'], ['post']];
}
public function testUserAction()
{
$operation = $this->getOperation('/api/test/{user}', 'get');
@ -93,10 +133,10 @@ class FunctionalTest extends WebTestCase
$api = $this->getSwaggerDefinition();
$paths = $api->getPaths();
$this->assertTrue($paths->has($path));
$this->assertTrue($paths->has($path), sprintf('Path "%s" does not exist', $path));
$action = $paths->get($path);
$this->assertTrue($action->hasOperation($method));
$this->assertTrue($action->hasOperation($method), sprintf('Operation "%s" for path "%s" does not exist', $path, $method));
return $action->getOperation($method);
}

View File

@ -0,0 +1,106 @@
<?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;
/**
* @internal
*/
final class ControllerReflector
{
private $container;
private $controllerNameParser;
public function __construct(ContainerInterface $container, ControllerNameParser $controllerNameParser)
{
$this->container = $container;
$this->controllerNameParser = $controllerNameParser;
}
/**
* Returns the ReflectionMethod for the given controller string.
*
* @param string $controller
*
* @return \ReflectionMethod|null
*/
public function getReflectionMethod(string $controller)
{
$callable = $this->getClassAndMethod($controller);
if (null === $callable) {
return;
}
list($class, $method) = $callable;
try {
return new \ReflectionMethod($class, $method);
} catch (\ReflectionException $e) {
// In case we can't reflect the controller, we just
// ignore the route
}
}
public function getReflectionClassAndMethod(string $controller)
{
$callable = $this->getClassAndMethod($controller);
if (null === $callable) {
return;
}
list($class, $method) = $callable;
try {
return [new \ReflectionClass($class), new \ReflectionMethod($class, $method)];
} catch (\ReflectionException $e) {
// In case we can't reflect the controller, we just
// ignore the route
}
}
private function getClassAndMethod(string $controller)
{
if (false === strpos($controller, '::') && 2 === substr_count($controller, ':')) {
$controller = $this->controllerNameParser->parse($controller);
}
if (preg_match('#(.+)::([\w]+)#', $controller, $matches)) {
$class = $matches[1];
$method = $matches[2];
} elseif (class_exists($controller)) {
$class = $controller;
$method = '__invoke';
} else {
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)) {
return;
}
return [$class, $method];
}
}

View File

@ -17,7 +17,7 @@
"require": {
"php": "~7.0|~7.1",
"symfony/framework-bundle": "^2.8|^3.0",
"exsyst/swagger": "~0.2.2"
"exsyst/swagger": "~0.2.3"
},
"require-dev": {
"symfony/twig-bundle": "^2.8|^3.0",