Merge pull request #1257 from phansys/ticket_1121

Allow to filter routes by host (#1121)
This commit is contained in:
David Buchmann 2018-03-17 15:07:31 +01:00 committed by GitHub
commit 3698daa547
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 200 additions and 29 deletions

View File

@ -7,6 +7,16 @@ CHANGELOG
* Add a documentation form extension. Use the ``documentation`` option to define how a form field is documented. * Add a documentation form extension. Use the ``documentation`` option to define how a form field is documented.
* Allow references to config definitions in controllers. * Allow references to config definitions in controllers.
Config
* `nelmio_api_doc.areas` added support to filter by host patterns.
```yml
nelmio_api_doc:
routes: [ host_patterns: [ ^api\. ] ]
```
* Added dependency for "symfony/options-resolver:^3.4.4|^4.0"
3.1.0 (2017-01-28) 3.1.0 (2017-01-28)
------------------ ------------------

View File

@ -48,10 +48,10 @@ 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' => []]]) ->defaultValue(['default' => ['path_patterns' => [], 'host_patterns' => []]])
->beforeNormalization() ->beforeNormalization()
->ifTrue(function ($v) { ->ifTrue(function ($v) {
return empty($v) or isset($v['path_patterns']); return 0 === count($v) || isset($v['path_patterns']) || isset($v['host_patterns']);
}) })
->then(function ($v) { ->then(function ($v) {
return ['default' => $v]; return ['default' => $v];
@ -68,9 +68,15 @@ final class Configuration implements ConfigurationInterface
->addDefaultsIfNotSet() ->addDefaultsIfNotSet()
->children() ->children()
->arrayNode('path_patterns') ->arrayNode('path_patterns')
->defaultValue([])
->example(['^/api', '^/api(?!/admin)']) ->example(['^/api', '^/api(?!/admin)'])
->prototype('scalar')->end() ->prototype('scalar')->end()
->end() ->end()
->arrayNode('host_patterns')
->defaultValue([])
->example(['^api\.'])
->prototype('scalar')->end()
->end()
->end() ->end()
->end() ->end()
->end() ->end()

View File

@ -66,7 +66,7 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI
new TaggedIteratorArgument('nelmio_api_doc.model_describer'), new TaggedIteratorArgument('nelmio_api_doc.model_describer'),
]); ]);
if (0 === count($areaConfig['path_patterns'])) { if (0 === count($areaConfig['path_patterns']) && 0 === count($areaConfig['host_patterns'])) {
$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 {
@ -74,7 +74,7 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI
->setPublic(false) ->setPublic(false)
->setFactory([ ->setFactory([
(new Definition(FilteredRouteCollectionBuilder::class)) (new Definition(FilteredRouteCollectionBuilder::class))
->addArgument($areaConfig['path_patterns']), ->addArgument($areaConfig),
'filter', 'filter',
]) ])
->addArgument($routesDefinition); ->addArgument($routesDefinition);

View File

@ -8,6 +8,7 @@ We've already seen that you can configure which routes are documented using ``ne
nelmio_api_doc: nelmio_api_doc:
areas: areas:
path_patterns: [ ^/api ] path_patterns: [ ^/api ]
host_patterns: [ ^api\. ]
But in fact, this config option is way more powerful and allows you to split your documentation in several parts. But in fact, this config option is way more powerful and allows you to split your documentation in several parts.
@ -22,6 +23,7 @@ You can define areas which will each generates a different documentation:
areas: areas:
default: default:
path_patterns: [ ^/api ] path_patterns: [ ^/api ]
host_patterns: [ ^api\. ]
internal: internal:
path_patterns: [ ^/internal ] path_patterns: [ ^/internal ]
commercial: commercial:

View File

@ -81,6 +81,8 @@ Open a command console, enter your project directory and execute the following c
areas: areas:
path_patterns: # an array of regexps path_patterns: # an array of regexps
- ^/api(?!/doc$) - ^/api(?!/doc$)
host_patterns:
- ^api\.
How does this bundle work? How does this bundle work?
-------------------------- --------------------------

View File

@ -11,23 +11,41 @@
namespace Nelmio\ApiDocBundle\Routing; namespace Nelmio\ApiDocBundle\Routing;
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
{ {
private $pathPatterns; private $options;
public function __construct(array $pathPatterns = []) public function __construct(array $options = [])
{ {
$this->pathPatterns = $pathPatterns; $resolver = new OptionsResolver();
$resolver
->setDefaults([
'path_patterns' => [],
'host_patterns' => [],
])
->setAllowedTypes('path_patterns', 'string[]')
->setAllowedTypes('host_patterns', 'string[]')
;
if (array_key_exists(0, $options)) {
@trigger_error(sprintf('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__), E_USER_DEPRECATED);
$normalizedOptions = ['path_patterns' => $options];
$options = $normalizedOptions;
}
$this->options = $resolver->resolve($options);
} }
public function filter(RouteCollection $routes): RouteCollection public function filter(RouteCollection $routes): RouteCollection
{ {
$filteredRoutes = new RouteCollection(); $filteredRoutes = new RouteCollection();
foreach ($routes->all() as $name => $route) { foreach ($routes->all() as $name => $route) {
if ($this->match($route)) { if ($this->matchPath($route) && $this->matchHost($route)) {
$filteredRoutes->add($name, $route); $filteredRoutes->add($name, $route);
} }
} }
@ -35,14 +53,25 @@ final class FilteredRouteCollectionBuilder
return $filteredRoutes; return $filteredRoutes;
} }
private function match(Route $route): bool private function matchPath(Route $route): bool
{ {
foreach ($this->pathPatterns as $pathPattern) { foreach ($this->options['path_patterns'] as $pathPattern) {
if (preg_match('{'.$pathPattern.'}', $route->getPath())) { if (preg_match('{'.$pathPattern.'}', $route->getPath())) {
return true; return true;
} }
} }
return false; 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']);
} }
} }

View File

@ -22,19 +22,19 @@ 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->assertEquals(['default' => ['path_patterns' => ['/foo']]], $config['areas']); $this->assertSame(['default' => ['path_patterns' => ['/foo'], 'host_patterns' => []]], $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']], 'default' => ['path_patterns' => ['/foo'], 'host_patterns' => []],
'internal' => ['path_patterns' => ['/internal']], 'internal' => ['path_patterns' => ['/internal'], 'host_patterns' => ['^swagger\.']],
'commercial' => ['path_patterns' => ['/internal']], 'commercial' => ['path_patterns' => ['/internal'], 'host_patterns' => []],
]]]); ]]]);
$this->assertEquals($areas, $config['areas']); $this->assertSame($areas, $config['areas']);
} }
/** /**
@ -57,6 +57,6 @@ 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->assertEquals(['default' => ['path_patterns' => ['/foo']]], $config['areas']); $this->assertSame(['default' => ['path_patterns' => ['/foo'], 'host_patterns' => []]], $config['areas']);
} }
} }

View File

@ -25,7 +25,7 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
/** /**
* @Route("/api") * @Route("/api", host="api.example.com")
*/ */
class ApiController class ApiController
{ {

View File

@ -18,6 +18,9 @@ use Nelmio\ApiDocBundle\Tests\Functional\Entity\VirtualProperty;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
/**
* @Route(host="api.example.com")
*/
class JMSController class JMSController
{ {
/** /**

View File

@ -15,7 +15,7 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Swagger\Annotations as SWG; use Swagger\Annotations as SWG;
/** /**
* @Route("/test") * @Route("/test", host="api-test.example.com")
*/ */
class TestController class TestController
{ {

View File

@ -13,6 +13,9 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
/**
* @Route(host="api.example.com")
*/
class UndocumentedController class UndocumentedController
{ {
/** /**

View File

@ -15,7 +15,7 @@ class SwaggerUiTest extends WebTestCase
{ {
protected static function createClient(array $options = [], array $server = []) protected static function createClient(array $options = [], array $server = [])
{ {
return parent::createClient([], ['PHP_SELF' => '/app_dev.php/docs', 'SCRIPT_FILENAME' => '/var/www/app/web/app_dev.php']); return parent::createClient([], $server + ['HTTP_HOST' => 'api.example.com', 'PHP_SELF' => '/app_dev.php/docs', 'SCRIPT_FILENAME' => '/var/www/app/web/app_dev.php']);
} }
/** /**

View File

@ -116,8 +116,8 @@ class TestKernel extends Kernel
], ],
], ],
'areas' => [ 'areas' => [
'default' => ['path_patterns' => ['^/api(?!/admin)']], 'default' => ['path_patterns' => ['^/api(?!/admin)'], 'host_patterns' => ['^api\.']],
'test' => ['path_patterns' => ['^/test']], 'test' => ['path_patterns' => ['^/test'], 'host_patterns' => ['^api-test\.']],
], ],
]); ]);
} }

View File

@ -24,7 +24,7 @@ class WebTestCase extends BaseWebTestCase
protected function getSwaggerDefinition() protected function getSwaggerDefinition()
{ {
static::createClient(); static::createClient([], ['HTTP_HOST' => 'api.example.com']);
return static::$kernel->getContainer()->get('nelmio_api_doc.generator')->generate(); return static::$kernel->getContainer()->get('nelmio_api_doc.generator')->generate();
} }

View File

@ -22,6 +22,34 @@ use Symfony\Component\Routing\RouteCollection;
class FilteredRouteCollectionBuilderTest extends TestCase class FilteredRouteCollectionBuilderTest extends TestCase
{ {
public function testFilter() public function testFilter()
{
$options = [
'path_patterns' => [
'^/api/foo',
'^/api/bar',
],
'host_patterns' => [
'^$',
'^api\.',
],
];
$routes = new RouteCollection();
foreach ($this->getRoutes() as $name => $route) {
$routes->add($name, $route);
}
$routeBuilder = new FilteredRouteCollectionBuilder($options);
$filteredRoutes = $routeBuilder->filter($routes);
$this->assertCount(4, $filteredRoutes);
}
/**
* @group legacy
* @expectedDeprecation Passing an indexed array with a collection of path patterns as argument 1 for `Nelmio\ApiDocBundle\Routing\FilteredRouteCollectionBuilder::__construct()` is deprecated since 3.2.0, expected structure is an array containing parameterized options.
*/
public function testFilterWithDeprecatedArgument()
{ {
$pathPattern = [ $pathPattern = [
'^/api/foo', '^/api/foo',
@ -29,15 +57,102 @@ class FilteredRouteCollectionBuilderTest extends TestCase
]; ];
$routes = new RouteCollection(); $routes = new RouteCollection();
$routes->add('r1', new Route('/api/bar/action1')); foreach ($this->getRoutes() as $name => $route) {
$routes->add('r2', new Route('/api/foo/action1')); $routes->add($name, $route);
$routes->add('r3', new Route('/api/foo/action2')); }
$routes->add('r4', new Route('/api/demo'));
$routes->add('r5', new Route('/_profiler/test/test'));
$routeBuilder = new FilteredRouteCollectionBuilder($pathPattern); $routeBuilder = new FilteredRouteCollectionBuilder($pathPattern);
$filteredRoutes = $routeBuilder->filter($routes); $filteredRoutes = $routeBuilder->filter($routes);
$this->assertCount(3, $filteredRoutes); $this->assertCount(5, $filteredRoutes);
}
/**
* @expectedException \Symfony\Component\OptionsResolver\Exception\InvalidArgumentException
*
* @dataProvider getInvalidOptions
*/
public function testFilterWithInvalidOption(array $options)
{
new FilteredRouteCollectionBuilder($options);
}
public function getInvalidOptions(): array
{
return [
[['invalid_option' => null]],
[['invalid_option' => 42]],
[['invalid_option' => []]],
[['path_patterns' => [22]]],
[['path_patterns' => [null]]],
[['path_patterns' => [new \stdClass()]]],
[['path_patterns' => ['^/foo$', 1]]],
];
}
private function getRoutes(): array
{
return [
'r1' => new Route('/api/bar/action1'),
'r2' => new Route('/api/foo/action1'),
'r3' => new Route('/api/foo/action2'),
'r4' => new Route('/api/demo'),
'r5' => new Route('/_profiler/test/test'),
'r6' => new Route('/admin/bar/action1', [], [], [], 'www.example.com'),
'r7' => new Route('/api/bar/action1', [], [], [], 'www.example.com'),
'r8' => new Route('/admin/bar/action1', [], [], [], 'api.example.com'),
'r9' => new Route('/api/bar/action1', [], [], [], 'api.example.com'),
];
}
/**
* @dataProvider getMatchingRoutes
*/
public function testMatchingRoutes(string $name, Route $route, array $options = [])
{
$routes = new RouteCollection();
$routes->add($name, $route);
$routeBuilder = new FilteredRouteCollectionBuilder($options);
$filteredRoutes = $routeBuilder->filter($routes);
$this->assertCount(1, $filteredRoutes);
}
public function getMatchingRoutes(): array
{
return [
['r1', new Route('/api/bar/action1')],
['r2', new Route('/api/foo/action1'), ['path_patterns' => ['^/api', 'i/fo', 'n1$']]],
['r3', new Route('/api/foo/action2'), ['path_patterns' => ['^/api/foo/action2$']]],
['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']]],
];
}
/**
* @dataProvider getNonMatchingRoutes
*/
public function testNonMatchingRoutes(string $name, Route $route, array $options = [])
{
$routes = new RouteCollection();
$routes->add($name, $route);
$routeBuilder = new FilteredRouteCollectionBuilder($options);
$filteredRoutes = $routeBuilder->filter($routes);
$this->assertCount(0, $filteredRoutes);
}
public function getNonMatchingRoutes(): array
{
return [
['r1', new Route('/api/bar/action1'), ['path_patterns' => ['^/apis']]],
['r2', new Route('/api/foo/action1'), ['path_patterns' => ['^/apis', 'i/foo/b', 'n1/$']]],
['r3_matching_path_and_non_matching_host', new Route('/api/foo/action2'), ['path_patterns' => ['^/api/foo/action2$'], 'host_patterns' => ['^api\.']]],
['r4_matching_path_and_non_matching_host', new Route('/api/bar/action1', [], [], [], 'www.example.com'), ['path_patterns' => ['^/api/'], 'host_patterns' => ['^api\.']]],
['r5_non_matching_path_and_matching_host', new Route('/admin/bar/action1', [], [], [], 'api.example.com'), ['path_patterns' => ['^/api/'], 'host_patterns' => ['^api\.']]],
['r6_non_matching_path_and_non_matching_host', new Route('/admin/bar/action1', [], [], [], 'www.example.com'), ['path_patterns' => ['^/api/'], 'host_patterns' => ['^api\.ex']]],
];
} }
} }

View File

@ -17,6 +17,7 @@
"require": { "require": {
"php": "^7.0", "php": "^7.0",
"symfony/framework-bundle": "^3.4|^4.0", "symfony/framework-bundle": "^3.4|^4.0",
"symfony/options-resolver": "^3.4.4|^4.0",
"symfony/property-info": "^3.4|^4.0", "symfony/property-info": "^3.4|^4.0",
"exsyst/swagger": "~0.3|~0.4", "exsyst/swagger": "~0.3|~0.4",
"zircote/swagger-php": "^2.0.9", "zircote/swagger-php": "^2.0.9",