add a way to filter areas on route by annotation

This commit is contained in:
babaorum 2019-01-05 16:37:43 +01:00
parent 0bca7f377d
commit dcfa5e23e5
8 changed files with 278 additions and 17 deletions

50
Annotation/Areas.php Normal file
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;
/**
* @Annotation
*/
final class Areas
{
/** @var string[] */
private $areas;
public function __construct(array $properties)
{
if (!array_key_exists('value', $properties) || !is_array($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);
}
}

View File

@ -48,7 +48,16 @@ final class Configuration implements ConfigurationInterface
->end() ->end()
->arrayNode('areas') ->arrayNode('areas')
->info('Filter the routes that are documented') ->info('Filter the routes that are documented')
->defaultValue(['default' => ['path_patterns' => [], 'host_patterns' => [], 'documentation' => []]]) ->defaultValue(
[
'default' => [
'path_patterns' => [],
'host_patterns' => [],
'with_annotation' => false,
'documentation' => [],
],
]
)
->beforeNormalization() ->beforeNormalization()
->ifTrue(function ($v) { ->ifTrue(function ($v) {
return 0 === count($v) || isset($v['path_patterns']) || isset($v['host_patterns']) || isset($v['documentation']); return 0 === count($v) || isset($v['path_patterns']) || isset($v['host_patterns']) || isset($v['documentation']);
@ -77,6 +86,10 @@ final class Configuration implements ConfigurationInterface
->example(['^api\.']) ->example(['^api\.'])
->prototype('scalar')->end() ->prototype('scalar')->end()
->end() ->end()
->booleanNode('with_annotation')
->defaultFalse()
->info('whether to filter by annotation')
->end()
->arrayNode('documentation') ->arrayNode('documentation')
->useAttributeAsKey('key') ->useAttributeAsKey('key')
->defaultValue([]) ->defaultValue([])

View File

@ -100,7 +100,10 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI
->addTag(sprintf('nelmio_api_doc.describer.%s', $area), ['priority' => 990]); ->addTag(sprintf('nelmio_api_doc.describer.%s', $area), ['priority' => 990]);
unset($areaConfig['documentation']); unset($areaConfig['documentation']);
if (0 === count($areaConfig['path_patterns']) && 0 === count($areaConfig['host_patterns'])) { if (0 === count($areaConfig['path_patterns'])
&& 0 === count($areaConfig['host_patterns'])
&& false === $areaConfig['with_annotation']
) {
$container->setDefinition(sprintf('nelmio_api_doc.routes.%s', $area), $routesDefinition) $container->setDefinition(sprintf('nelmio_api_doc.routes.%s', $area), $routesDefinition)
->setPublic(false); ->setPublic(false);
} else { } else {
@ -108,7 +111,14 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI
->setPublic(false) ->setPublic(false)
->setFactory([ ->setFactory([
(new Definition(FilteredRouteCollectionBuilder::class)) (new Definition(FilteredRouteCollectionBuilder::class))
->addArgument($areaConfig), ->setArguments(
[
new Reference('annotation_reader'),
new Reference('nelmio_api_doc.controller_reflector'),
$area,
$areaConfig,
]
),
'filter', 'filter',
]) ])
->addArgument($routesDefinition); ->addArgument($routesDefinition);

View File

@ -11,24 +11,43 @@
namespace Nelmio\ApiDocBundle\Routing; namespace Nelmio\ApiDocBundle\Routing;
use Doctrine\Common\Annotations\Reader;
use Nelmio\ApiDocBundle\Annotation\Areas;
use Nelmio\ApiDocBundle\Util\ControllerReflector;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Route; use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouteCollection;
final class FilteredRouteCollectionBuilder final class FilteredRouteCollectionBuilder
{ {
/** @var Reader */
private $annotationReader;
/** @var ControllerReflector */
private $controllerReflector;
/** @var string */
private $area;
/** @var array */
private $options; private $options;
public function __construct(array $options = []) public function __construct(
{ Reader $annotationReader,
ControllerReflector $controllerReflector,
string $area,
array $options = []
) {
$resolver = new OptionsResolver(); $resolver = new OptionsResolver();
$resolver $resolver
->setDefaults([ ->setDefaults([
'path_patterns' => [], 'path_patterns' => [],
'host_patterns' => [], 'host_patterns' => [],
'with_annotation' => false,
]) ])
->setAllowedTypes('path_patterns', 'string[]') ->setAllowedTypes('path_patterns', 'string[]')
->setAllowedTypes('host_patterns', 'string[]') ->setAllowedTypes('host_patterns', 'string[]')
->setAllowedTypes('with_annotation', 'boolean')
; ;
if (array_key_exists(0, $options)) { if (array_key_exists(0, $options)) {
@ -38,6 +57,9 @@ final class FilteredRouteCollectionBuilder
$options = $normalizedOptions; $options = $normalizedOptions;
} }
$this->annotationReader = $annotationReader;
$this->controllerReflector = $controllerReflector;
$this->area = $area;
$this->options = $resolver->resolve($options); $this->options = $resolver->resolve($options);
} }
@ -45,7 +67,7 @@ final class FilteredRouteCollectionBuilder
{ {
$filteredRoutes = new RouteCollection(); $filteredRoutes = new RouteCollection();
foreach ($routes->all() as $name => $route) { foreach ($routes->all() as $name => $route) {
if ($this->matchPath($route) && $this->matchHost($route)) { if ($this->matchPath($route) && $this->matchHost($route) && $this->matchAnnotation($route)) {
$filteredRoutes->add($name, $route); $filteredRoutes->add($name, $route);
} }
} }
@ -74,4 +96,26 @@ final class FilteredRouteCollectionBuilder
return 0 === count($this->options['host_patterns']); return 0 === count($this->options['host_patterns']);
} }
private function matchAnnotation(Route $route): bool
{
if (false === $this->options['with_annotation']) {
return true;
}
$method = $this->controllerReflector->getReflectionMethod(
$route->getDefault('_controller') ?? ''
);
if (null === $method) {
return false;
}
/** @var null|Areas $areas */
$areas = $this->annotationReader->getMethodAnnotation(
$method,
Areas::class
);
return (null !== $areas) ? $areas->has($this->area) : false;
}
} }

View File

@ -22,16 +22,41 @@ class ConfigurationTest extends TestCase
$processor = new Processor(); $processor = new Processor();
$config = $processor->processConfiguration(new Configuration(), [['areas' => ['path_patterns' => ['/foo']]]]); $config = $processor->processConfiguration(new Configuration(), [['areas' => ['path_patterns' => ['/foo']]]]);
$this->assertSame(['default' => ['path_patterns' => ['/foo'], 'host_patterns' => [], 'documentation' => []]], $config['areas']); $this->assertSame(
[
'default' => [
'path_patterns' => ['/foo'],
'host_patterns' => [],
'with_annotation' => false,
'documentation' => [],
],
],
$config['areas']
);
} }
public function testAreas() public function testAreas()
{ {
$processor = new Processor(); $processor = new Processor();
$config = $processor->processConfiguration(new Configuration(), [['areas' => $areas = [ $config = $processor->processConfiguration(new Configuration(), [['areas' => $areas = [
'default' => ['path_patterns' => ['/foo'], 'host_patterns' => [], 'documentation' => []], 'default' => [
'internal' => ['path_patterns' => ['/internal'], 'host_patterns' => ['^swagger\.'], 'documentation' => []], 'path_patterns' => ['/foo'],
'commercial' => ['path_patterns' => ['/internal'], 'host_patterns' => [], 'documentation' => []], 'host_patterns' => [],
'with_annotation' => false,
'documentation' => [],
],
'internal' => [
'path_patterns' => ['/internal'],
'host_patterns' => ['^swagger\.'],
'with_annotation' => false,
'documentation' => [],
],
'commercial' => [
'path_patterns' => ['/internal'],
'host_patterns' => [],
'with_annotation' => false,
'documentation' => [],
],
]]]); ]]]);
$this->assertSame($areas, $config['areas']); $this->assertSame($areas, $config['areas']);
@ -136,6 +161,16 @@ class ConfigurationTest extends TestCase
$processor = new Processor(); $processor = new Processor();
$config = $processor->processConfiguration(new Configuration(), [['routes' => ['path_patterns' => ['/foo']]]]); $config = $processor->processConfiguration(new Configuration(), [['routes' => ['path_patterns' => ['/foo']]]]);
$this->assertSame(['default' => ['path_patterns' => ['/foo'], 'host_patterns' => [], 'documentation' => []]], $config['areas']); $this->assertSame(
[
'default' => [
'path_patterns' => ['/foo'],
'host_patterns' => [],
'with_annotation' => false,
'documentation' => [],
],
],
$config['areas']
);
} }
} }

View File

@ -13,6 +13,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\Areas;
use Nelmio\ApiDocBundle\Annotation\Model; use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Operation; use Nelmio\ApiDocBundle\Annotation\Operation;
use Nelmio\ApiDocBundle\Annotation\Security; use Nelmio\ApiDocBundle\Annotation\Security;
@ -211,4 +212,13 @@ class ApiController
public function operationsWithOtherAnnotations() public function operationsWithOtherAnnotations()
{ {
} }
/**
* @Route("/areas/new", methods={"GET", "POST"})
*
* @Areas({"area", "area2"})
*/
public function newAreaAction()
{
}
} }

View File

@ -11,8 +11,14 @@
namespace Nelmio\ApiDocBundle\Tests\Routing; namespace Nelmio\ApiDocBundle\Tests\Routing;
use Doctrine\Common\Annotations\AnnotationReader;
use Doctrine\Common\Annotations\Reader;
use Nelmio\ApiDocBundle\Annotation\Areas;
use Nelmio\ApiDocBundle\Routing\FilteredRouteCollectionBuilder; use Nelmio\ApiDocBundle\Routing\FilteredRouteCollectionBuilder;
use Nelmio\ApiDocBundle\Util\ControllerReflector;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Controller\ControllerNameParser;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\Routing\Route; use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouteCollection;
@ -39,7 +45,15 @@ class FilteredRouteCollectionBuilderTest extends TestCase
$routes->add($name, $route); $routes->add($name, $route);
} }
$routeBuilder = new FilteredRouteCollectionBuilder($options); $routeBuilder = new FilteredRouteCollectionBuilder(
new AnnotationReader(),
new ControllerReflector(
new Container(),
$this->createMock(ControllerNameParser::class)
),
'areaName',
$options
);
$filteredRoutes = $routeBuilder->filter($routes); $filteredRoutes = $routeBuilder->filter($routes);
$this->assertCount(4, $filteredRoutes); $this->assertCount(4, $filteredRoutes);
@ -61,7 +75,15 @@ class FilteredRouteCollectionBuilderTest extends TestCase
$routes->add($name, $route); $routes->add($name, $route);
} }
$routeBuilder = new FilteredRouteCollectionBuilder($pathPattern); $routeBuilder = new FilteredRouteCollectionBuilder(
new AnnotationReader(),
new ControllerReflector(
new Container(),
$this->createMock(ControllerNameParser::class)
),
'areaName',
$pathPattern
);
$filteredRoutes = $routeBuilder->filter($routes); $filteredRoutes = $routeBuilder->filter($routes);
$this->assertCount(5, $filteredRoutes); $this->assertCount(5, $filteredRoutes);
@ -74,7 +96,15 @@ class FilteredRouteCollectionBuilderTest extends TestCase
*/ */
public function testFilterWithInvalidOption(array $options) public function testFilterWithInvalidOption(array $options)
{ {
new FilteredRouteCollectionBuilder($options); new FilteredRouteCollectionBuilder(
new AnnotationReader(),
new ControllerReflector(
new Container(),
$this->createMock(ControllerNameParser::class)
),
'areaName',
$options
);
} }
public function getInvalidOptions(): array public function getInvalidOptions(): array
@ -87,6 +117,9 @@ class FilteredRouteCollectionBuilderTest extends TestCase
[['path_patterns' => [null]]], [['path_patterns' => [null]]],
[['path_patterns' => [new \stdClass()]]], [['path_patterns' => [new \stdClass()]]],
[['path_patterns' => ['^/foo$', 1]]], [['path_patterns' => ['^/foo$', 1]]],
[['with_annotation' => ['an array']]],
[['path_patterns' => 'a string']],
[['path_patterns' => 11]],
]; ];
} }
@ -102,6 +135,7 @@ class FilteredRouteCollectionBuilderTest extends TestCase
'r7' => new Route('/api/bar/action1', [], [], [], 'www.example.com'), 'r7' => new Route('/api/bar/action1', [], [], [], 'www.example.com'),
'r8' => new Route('/admin/bar/action1', [], [], [], 'api.example.com'), 'r8' => new Route('/admin/bar/action1', [], [], [], 'api.example.com'),
'r9' => new Route('/api/bar/action1', [], [], [], 'api.example.com'), 'r9' => new Route('/api/bar/action1', [], [], [], 'api.example.com'),
'r10' => new Route('/api/areas/new'),
]; ];
} }
@ -113,7 +147,15 @@ class FilteredRouteCollectionBuilderTest extends TestCase
$routes = new RouteCollection(); $routes = new RouteCollection();
$routes->add($name, $route); $routes->add($name, $route);
$routeBuilder = new FilteredRouteCollectionBuilder($options); $routeBuilder = new FilteredRouteCollectionBuilder(
new AnnotationReader(),
new ControllerReflector(
new Container(),
$this->createMock(ControllerNameParser::class)
),
'area',
$options
);
$filteredRoutes = $routeBuilder->filter($routes); $filteredRoutes = $routeBuilder->filter($routes);
$this->assertCount(1, $filteredRoutes); $this->assertCount(1, $filteredRoutes);
@ -127,6 +169,55 @@ class FilteredRouteCollectionBuilderTest extends TestCase
['r3', new Route('/api/foo/action2'), ['path_patterns' => ['^/api/foo/action2$']]], ['r3', new Route('/api/foo/action2'), ['path_patterns' => ['^/api/foo/action2$']]],
['r4', new Route('/api/demo'), ['path_patterns' => ['/api/demo']]], ['r4', new Route('/api/demo'), ['path_patterns' => ['/api/demo']]],
['r9', new Route('/api/bar/action1', [], [], [], 'api.example.com'), ['path_patterns' => ['^/api/'], 'host_patterns' => ['^api\.ex']]], ['r9', new Route('/api/bar/action1', [], [], [], 'api.example.com'), ['path_patterns' => ['^/api/'], 'host_patterns' => ['^api\.ex']]],
['r10', new Route('/api/areas/new'), ['path_patterns' => ['^/api']]],
];
}
/**
* @group test
* @dataProvider getMatchingRoutesWithAnnotation
*/
public function testMatchingRoutesWithAnnotation(string $name, Route $route, array $options = [])
{
$routes = new RouteCollection();
$routes->add($name, $route);
$area = 'area';
$reflectionMethodStub = $this->createMock(\ReflectionMethod::class);
$controllerReflectorStub = $this->createMock(ControllerReflector::class);
$controllerReflectorStub->method('getReflectionMethod')->willReturn($reflectionMethodStub);
$annotationReader = $this->createMock(Reader::class);
$annotationReader
->method('getMethodAnnotation')
->with($reflectionMethodStub, Areas::class)
->willReturn(new Areas(['value' => [$area]]))
;
$routeBuilder = new FilteredRouteCollectionBuilder(
$annotationReader,
$controllerReflectorStub,
$area,
$options
);
$filteredRoutes = $routeBuilder->filter($routes);
$this->assertCount(1, $filteredRoutes);
}
public function getMatchingRoutesWithAnnotation(): array
{
return [
'with annotation only' => [
'r10',
new Route('/api/areas/new', ['_controller' => 'ApiController::newAreaAction']),
['with_annotation' => true],
],
'with annotation and path patterns' => [
'r10',
new Route('/api/areas/new', ['_controller' => 'ApiController::newAreaAction']),
['path_patterns' => ['^/api'], 'with_annotation' => true],
],
]; ];
} }
@ -138,7 +229,15 @@ class FilteredRouteCollectionBuilderTest extends TestCase
$routes = new RouteCollection(); $routes = new RouteCollection();
$routes->add($name, $route); $routes->add($name, $route);
$routeBuilder = new FilteredRouteCollectionBuilder($options); $routeBuilder = new FilteredRouteCollectionBuilder(
new AnnotationReader(),
new ControllerReflector(
new Container(),
$this->createMock(ControllerNameParser::class)
),
'areaName',
$options
);
$filteredRoutes = $routeBuilder->filter($routes); $filteredRoutes = $routeBuilder->filter($routes);
$this->assertCount(0, $filteredRoutes); $this->assertCount(0, $filteredRoutes);

View File

@ -17,7 +17,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
/** /**
* @internal * @internal
*/ */
final class ControllerReflector class ControllerReflector
{ {
private $container; private $container;