From ec851ca65ce7ad212ef6d95a0c94b347d608722d Mon Sep 17 00:00:00 2001 From: Javier Spagnoletti Date: Tue, 13 Mar 2018 12:40:36 -0300 Subject: [PATCH] Allow to filter routes by host (#1121) --- CHANGELOG.md | 10 ++ DependencyInjection/Configuration.php | 10 +- DependencyInjection/NelmioApiDocExtension.php | 4 +- Resources/doc/areas.rst | 2 + Resources/doc/index.rst | 2 + Routing/FilteredRouteCollectionBuilder.php | 43 +++++- .../DependencyInjection/ConfigurationTest.php | 12 +- Tests/Functional/Controller/ApiController.php | 2 +- Tests/Functional/Controller/JMSController.php | 3 + .../Functional/Controller/TestController.php | 2 +- .../Controller/UndocumentedController.php | 3 + Tests/Functional/SwaggerUiTest.php | 2 +- Tests/Functional/TestKernel.php | 4 +- Tests/Functional/WebTestCase.php | 2 +- .../FilteredRouteCollectionBuilderTest.php | 127 +++++++++++++++++- composer.json | 1 + 16 files changed, 200 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8111006..4b9c319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ CHANGELOG * Add a documentation form extension. Use the ``documentation`` option to define how a form field is documented. * 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) ------------------ diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index b38ca5c..9588e65 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -48,10 +48,10 @@ final class Configuration implements ConfigurationInterface ->end() ->arrayNode('areas') ->info('Filter the routes that are documented') - ->defaultValue(['default' => ['path_patterns' => []]]) + ->defaultValue(['default' => ['path_patterns' => [], 'host_patterns' => []]]) ->beforeNormalization() ->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) { return ['default' => $v]; @@ -68,9 +68,15 @@ final class Configuration implements ConfigurationInterface ->addDefaultsIfNotSet() ->children() ->arrayNode('path_patterns') + ->defaultValue([]) ->example(['^/api', '^/api(?!/admin)']) ->prototype('scalar')->end() ->end() + ->arrayNode('host_patterns') + ->defaultValue([]) + ->example(['^api\.']) + ->prototype('scalar')->end() + ->end() ->end() ->end() ->end() diff --git a/DependencyInjection/NelmioApiDocExtension.php b/DependencyInjection/NelmioApiDocExtension.php index 22ad615..d17877a 100644 --- a/DependencyInjection/NelmioApiDocExtension.php +++ b/DependencyInjection/NelmioApiDocExtension.php @@ -66,7 +66,7 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI 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) ->setPublic(false); } else { @@ -74,7 +74,7 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI ->setPublic(false) ->setFactory([ (new Definition(FilteredRouteCollectionBuilder::class)) - ->addArgument($areaConfig['path_patterns']), + ->addArgument($areaConfig), 'filter', ]) ->addArgument($routesDefinition); diff --git a/Resources/doc/areas.rst b/Resources/doc/areas.rst index f996ca7..390dd11 100644 --- a/Resources/doc/areas.rst +++ b/Resources/doc/areas.rst @@ -8,6 +8,7 @@ We've already seen that you can configure which routes are documented using ``ne nelmio_api_doc: areas: 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. @@ -22,6 +23,7 @@ You can define areas which will each generates a different documentation: areas: default: path_patterns: [ ^/api ] + host_patterns: [ ^api\. ] internal: path_patterns: [ ^/internal ] commercial: diff --git a/Resources/doc/index.rst b/Resources/doc/index.rst index ea8144d..ed2d967 100644 --- a/Resources/doc/index.rst +++ b/Resources/doc/index.rst @@ -81,6 +81,8 @@ Open a command console, enter your project directory and execute the following c areas: path_patterns: # an array of regexps - ^/api(?!/doc$) + host_patterns: + - ^api\. How does this bundle work? -------------------------- diff --git a/Routing/FilteredRouteCollectionBuilder.php b/Routing/FilteredRouteCollectionBuilder.php index 19e4cef..0575c21 100644 --- a/Routing/FilteredRouteCollectionBuilder.php +++ b/Routing/FilteredRouteCollectionBuilder.php @@ -11,23 +11,41 @@ namespace Nelmio\ApiDocBundle\Routing; +use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; 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 { $filteredRoutes = new RouteCollection(); foreach ($routes->all() as $name => $route) { - if ($this->match($route)) { + if ($this->matchPath($route) && $this->matchHost($route)) { $filteredRoutes->add($name, $route); } } @@ -35,14 +53,25 @@ final class FilteredRouteCollectionBuilder 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())) { 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']); } } diff --git a/Tests/DependencyInjection/ConfigurationTest.php b/Tests/DependencyInjection/ConfigurationTest.php index c506d90..14203ac 100644 --- a/Tests/DependencyInjection/ConfigurationTest.php +++ b/Tests/DependencyInjection/ConfigurationTest.php @@ -22,19 +22,19 @@ class ConfigurationTest extends TestCase $processor = new Processor(); $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() { $processor = new Processor(); $config = $processor->processConfiguration(new Configuration(), [['areas' => $areas = [ - 'default' => ['path_patterns' => ['/foo']], - 'internal' => ['path_patterns' => ['/internal']], - 'commercial' => ['path_patterns' => ['/internal']], + 'default' => ['path_patterns' => ['/foo'], 'host_patterns' => []], + 'internal' => ['path_patterns' => ['/internal'], 'host_patterns' => ['^swagger\.']], + '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(); $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']); } } diff --git a/Tests/Functional/Controller/ApiController.php b/Tests/Functional/Controller/ApiController.php index db401ee..2b8a55f 100644 --- a/Tests/Functional/Controller/ApiController.php +++ b/Tests/Functional/Controller/ApiController.php @@ -25,7 +25,7 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Swagger\Annotations as SWG; /** - * @Route("/api") + * @Route("/api", host="api.example.com") */ class ApiController { diff --git a/Tests/Functional/Controller/JMSController.php b/Tests/Functional/Controller/JMSController.php index 468ba76..7294d32 100644 --- a/Tests/Functional/Controller/JMSController.php +++ b/Tests/Functional/Controller/JMSController.php @@ -18,6 +18,9 @@ use Nelmio\ApiDocBundle\Tests\Functional\Entity\VirtualProperty; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Swagger\Annotations as SWG; +/** + * @Route(host="api.example.com") + */ class JMSController { /** diff --git a/Tests/Functional/Controller/TestController.php b/Tests/Functional/Controller/TestController.php index ef3c80a..07ae103 100644 --- a/Tests/Functional/Controller/TestController.php +++ b/Tests/Functional/Controller/TestController.php @@ -15,7 +15,7 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Swagger\Annotations as SWG; /** - * @Route("/test") + * @Route("/test", host="api-test.example.com") */ class TestController { diff --git a/Tests/Functional/Controller/UndocumentedController.php b/Tests/Functional/Controller/UndocumentedController.php index 8f06974..6944b86 100644 --- a/Tests/Functional/Controller/UndocumentedController.php +++ b/Tests/Functional/Controller/UndocumentedController.php @@ -13,6 +13,9 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; +/** + * @Route(host="api.example.com") + */ class UndocumentedController { /** diff --git a/Tests/Functional/SwaggerUiTest.php b/Tests/Functional/SwaggerUiTest.php index 98d35f6..798980d 100644 --- a/Tests/Functional/SwaggerUiTest.php +++ b/Tests/Functional/SwaggerUiTest.php @@ -15,7 +15,7 @@ class SwaggerUiTest extends WebTestCase { 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']); } /** diff --git a/Tests/Functional/TestKernel.php b/Tests/Functional/TestKernel.php index 7421ebd..2a2f253 100644 --- a/Tests/Functional/TestKernel.php +++ b/Tests/Functional/TestKernel.php @@ -116,8 +116,8 @@ class TestKernel extends Kernel ], ], 'areas' => [ - 'default' => ['path_patterns' => ['^/api(?!/admin)']], - 'test' => ['path_patterns' => ['^/test']], + 'default' => ['path_patterns' => ['^/api(?!/admin)'], 'host_patterns' => ['^api\.']], + 'test' => ['path_patterns' => ['^/test'], 'host_patterns' => ['^api-test\.']], ], ]); } diff --git a/Tests/Functional/WebTestCase.php b/Tests/Functional/WebTestCase.php index ddadbf7..045a7b1 100644 --- a/Tests/Functional/WebTestCase.php +++ b/Tests/Functional/WebTestCase.php @@ -24,7 +24,7 @@ class WebTestCase extends BaseWebTestCase protected function getSwaggerDefinition() { - static::createClient(); + static::createClient([], ['HTTP_HOST' => 'api.example.com']); return static::$kernel->getContainer()->get('nelmio_api_doc.generator')->generate(); } diff --git a/Tests/Routing/FilteredRouteCollectionBuilderTest.php b/Tests/Routing/FilteredRouteCollectionBuilderTest.php index a15ff25..46eb883 100644 --- a/Tests/Routing/FilteredRouteCollectionBuilderTest.php +++ b/Tests/Routing/FilteredRouteCollectionBuilderTest.php @@ -22,6 +22,34 @@ use Symfony\Component\Routing\RouteCollection; class FilteredRouteCollectionBuilderTest extends TestCase { 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 = [ '^/api/foo', @@ -29,15 +57,102 @@ class FilteredRouteCollectionBuilderTest extends TestCase ]; $routes = new RouteCollection(); - $routes->add('r1', new Route('/api/bar/action1')); - $routes->add('r2', new Route('/api/foo/action1')); - $routes->add('r3', new Route('/api/foo/action2')); - $routes->add('r4', new Route('/api/demo')); - $routes->add('r5', new Route('/_profiler/test/test')); + foreach ($this->getRoutes() as $name => $route) { + $routes->add($name, $route); + } $routeBuilder = new FilteredRouteCollectionBuilder($pathPattern); $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']]], + ]; } } diff --git a/composer.json b/composer.json index 04fe0ab..9f063c3 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "require": { "php": "^7.0", "symfony/framework-bundle": "^3.4|^4.0", + "symfony/options-resolver": "^3.4.4|^4.0", "symfony/property-info": "^3.4|^4.0", "exsyst/swagger": "~0.3|~0.4", "zircote/swagger-php": "^2.0.9",