diff --git a/Command/DumpCommand.php b/Command/DumpCommand.php index f1b6271..c2d929f 100644 --- a/Command/DumpCommand.php +++ b/Command/DumpCommand.php @@ -11,9 +11,9 @@ namespace Nelmio\ApiDocBundle\Command; -use Psr\Container\ContainerInterface; +use Nelmio\ApiDocBundle\Render\Html\AssetsMode; +use Nelmio\ApiDocBundle\Render\RenderOpenApi; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -21,16 +21,21 @@ use Symfony\Component\Console\Output\OutputInterface; class DumpCommand extends Command { /** - * @var ContainerInterface + * @var RenderOpenApi */ - private $generatorLocator; + private $renderOpenApi; /** - * DumpCommand constructor. + * @var mixed[] */ - public function __construct(ContainerInterface $generatorLocator) + private $defaultHtmlConfig = [ + 'assets_mode' => AssetsMode::CDN, + 'swagger_ui_config' => [], + ]; + + public function __construct(RenderOpenApi $renderOpenApi) { - $this->generatorLocator = $generatorLocator; + $this->renderOpenApi = $renderOpenApi; parent::__construct(); } @@ -40,34 +45,48 @@ class DumpCommand extends Command */ protected function configure() { + $availableFormats = $this->renderOpenApi->getAvailableFormats(); $this - ->setDescription('Dumps documentation in OpenAPI JSON format') + ->setDescription('Dumps documentation in OpenAPI format to: '.implode(', ', $availableFormats)) ->addOption('area', '', InputOption::VALUE_OPTIONAL, '', 'default') - ->addOption('no-pretty', '', InputOption::VALUE_NONE, 'Do not pretty format output') + ->addOption( + 'format', + '', + InputOption::VALUE_REQUIRED, + 'Output format like: '.implode(', ', $availableFormats), + RenderOpenApi::JSON + ) + ->addOption('server-url', '', InputOption::VALUE_REQUIRED, 'URL where live api doc is served') + ->addOption('html-config', '', InputOption::VALUE_REQUIRED, '', json_encode($this->defaultHtmlConfig)) + ->addOption('no-pretty', '', InputOption::VALUE_NONE, 'Do not pretty format JSON output') ; } /** - * @throws InvalidArgumentException If the area to dump is not valid - * * @return int|void */ protected function execute(InputInterface $input, OutputInterface $output) { $area = $input->getOption('area'); + $format = $input->getOption('format'); - if (!$this->generatorLocator->has($area)) { - throw new InvalidArgumentException(sprintf('Area "%s" is not supported.', $area)); + $options = []; + if (RenderOpenApi::HTML === $format) { + $rawHtmlConfig = json_decode($input->getOption('html-config'), true); + $options = is_array($rawHtmlConfig) ? $rawHtmlConfig : $this->defaultHtmlConfig; + } elseif (RenderOpenApi::JSON === $format) { + $options = [ + 'no-pretty' => $input->hasParameterOption(['--no-pretty']), + ]; } - $spec = $this->generatorLocator->get($area)->generate(); - - if ($input->hasParameterOption(['--no-pretty'])) { - $output->writeln(json_encode($spec)); - } else { - $output->writeln(json_encode($spec, JSON_PRETTY_PRINT)); + if ($input->getOption('server-url')) { + $options['server_url'] = $input->getOption('server-url'); } + $docs = $this->renderOpenApi->render($format, $area, $options); + $output->writeln($docs, OutputInterface::OUTPUT_RAW); + return 0; } } diff --git a/Controller/DocumentationController.php b/Controller/DocumentationController.php index 0450d3a..020451a 100644 --- a/Controller/DocumentationController.php +++ b/Controller/DocumentationController.php @@ -11,35 +11,31 @@ namespace Nelmio\ApiDocBundle\Controller; -use OpenApi\Annotations\OpenApi; -use OpenApi\Annotations\Server; -use Psr\Container\ContainerInterface; +use Nelmio\ApiDocBundle\Render\RenderOpenApi; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; final class DocumentationController { - private $generatorLocator; + /** + * @var RenderOpenApi + */ + private $renderOpenApi; - public function __construct(ContainerInterface $generatorLocator) + public function __construct(RenderOpenApi $renderOpenApi) { - $this->generatorLocator = $generatorLocator; + $this->renderOpenApi = $renderOpenApi; } public function __invoke(Request $request, $area = 'default') { - if (!$this->generatorLocator->has($area)) { + try { + return JsonResponse::fromJsonString( + $this->renderOpenApi->renderFromRequest($request, RenderOpenApi::JSON, $area) + ); + } catch (InvalidArgumentException $e) { throw new BadRequestHttpException(sprintf('Area "%s" is not supported as it isn\'t defined in config.', $area)); } - - /** @var OpenApi $spec */ - $spec = $this->generatorLocator->get($area)->generate(); - - if ('' !== $request->getBaseUrl()) { - $spec->servers = [new Server(['url' => $request->getSchemeAndHttpHost().$request->getBaseUrl()])]; - } - - return new JsonResponse($spec); } } diff --git a/Controller/SwaggerUiController.php b/Controller/SwaggerUiController.php index 2b3d962..c5a3201 100644 --- a/Controller/SwaggerUiController.php +++ b/Controller/SwaggerUiController.php @@ -11,33 +11,38 @@ namespace Nelmio\ApiDocBundle\Controller; -use OpenApi\Annotations\OpenApi; -use OpenApi\Annotations\Server; -use Psr\Container\ContainerInterface; +use InvalidArgumentException; +use Nelmio\ApiDocBundle\Render\Html\AssetsMode; +use Nelmio\ApiDocBundle\Render\RenderOpenApi; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; -use Twig\Environment; final class SwaggerUiController { - private $generatorLocator; + /** + * @var RenderOpenApi + */ + private $renderOpenApi; - private $twig; - - public function __construct(ContainerInterface $generatorLocator, $twig) + public function __construct(RenderOpenApi $renderOpenApi) { - if (!$twig instanceof \Twig_Environment && !$twig instanceof Environment) { - throw new \InvalidArgumentException(sprintf('Providing an instance of "%s" as twig is not supported.', get_class($twig))); - } - - $this->generatorLocator = $generatorLocator; - $this->twig = $twig; + $this->renderOpenApi = $renderOpenApi; } public function __invoke(Request $request, $area = 'default') { - if (!$this->generatorLocator->has($area)) { + try { + $response = new Response( + $this->renderOpenApi->renderFromRequest($request, RenderOpenApi::HTML, $area, [ + 'assets_mode' => AssetsMode::BUNDLE, + ]), + Response::HTTP_OK, + ['Content-Type' => 'text/html'] + ); + + return $response->setCharset('UTF-8'); + } catch (InvalidArgumentException $e) { $advice = ''; if (false !== strpos($area, '.json')) { $advice = ' Since the area provided contains `.json`, the issue is likely caused by route priorities. Try switching the Swagger UI / the json documentation routes order.'; @@ -45,23 +50,5 @@ final class SwaggerUiController throw new BadRequestHttpException(sprintf('Area "%s" is not supported as it isn\'t defined in config.%s', $area, $advice)); } - - /** @var OpenApi $spec */ - $spec = $this->generatorLocator->get($area)->generate(); - - if ('' !== $request->getBaseUrl()) { - $spec->servers = [new Server(['url' => $request->getSchemeAndHttpHost().$request->getBaseUrl()])]; - } - - return new Response( - $this->twig->render( - '@NelmioApiDoc/SwaggerUi/index.html.twig', - ['swagger_data' => ['spec' => json_decode($spec->toJson(), true)]] - ), - Response::HTTP_OK, - ['Content-Type' => 'text/html'] - ); - - return $response->setCharset('UTF-8'); } } diff --git a/Controller/YamlDocumentationController.php b/Controller/YamlDocumentationController.php index 7557fc8..a935a2d 100644 --- a/Controller/YamlDocumentationController.php +++ b/Controller/YamlDocumentationController.php @@ -11,37 +11,36 @@ namespace Nelmio\ApiDocBundle\Controller; -use OpenApi\Annotations\OpenApi; -use OpenApi\Annotations\Server; -use Psr\Container\ContainerInterface; +use InvalidArgumentException; +use Nelmio\ApiDocBundle\Render\RenderOpenApi; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; final class YamlDocumentationController { - private $generatorLocator; + /** + * @var RenderOpenApi + */ + private $renderOpenApi; - public function __construct(ContainerInterface $generatorLocator) + public function __construct(RenderOpenApi $renderOpenApi) { - $this->generatorLocator = $generatorLocator; + $this->renderOpenApi = $renderOpenApi; } public function __invoke(Request $request, $area = 'default') { - if (!$this->generatorLocator->has($area)) { + try { + $response = new Response( + $this->renderOpenApi->renderFromRequest($request, RenderOpenApi::YAML, $area), + Response::HTTP_OK, + ['Content-Type' => 'text/x-yaml'] + ); + + return $response->setCharset('UTF-8'); + } catch (InvalidArgumentException $e) { throw new BadRequestHttpException(sprintf('Area "%s" is not supported as it isn\'t defined in config.', $area)); } - - /** @var OpenApi $spec */ - $spec = $this->generatorLocator->get($area)->generate(); - - if ('' !== $request->getBaseUrl()) { - $spec->servers = [new Server(['url' => $request->getSchemeAndHttpHost().$request->getBaseUrl()])]; - } - - return new Response($spec->toYaml(), 200, [ - 'Content-Type' => 'text/x-yaml', - ]); } } diff --git a/Render/Html/AssetsMode.php b/Render/Html/AssetsMode.php new file mode 100644 index 0000000..2bbdf5a --- /dev/null +++ b/Render/Html/AssetsMode.php @@ -0,0 +1,19 @@ +assetExtension = $assetExtension; + $this->cdnUrl = 'https://cdn.jsdelivr.net/gh/nelmio/NelmioApiDocBundle/Resources/public'; + $this->resourcesDir = __DIR__.'/../../Resources/public'; + } + + public function toTwigFunction($assetsMode): TwigFunction + { + $this->assetsMode = $assetsMode; + + return new TwigFunction('nelmioAsset', $this, ['is_safe' => ['html']]); + } + + public function __invoke($asset) + { + [$extension, $mode] = $this->getExtension($asset); + [$resource, $isInline] = $this->getResource($asset, $mode); + if ('js' == $extension) { + return $this->renderJavascript($resource, $isInline); + } elseif ('css' == $extension) { + return $this->renderCss($resource, $isInline); + } else { + return $resource; + } + } + + private function getExtension($asset) + { + $extension = mb_substr($asset, -3, 3, 'utf-8'); + if ('.js' === $extension) { + return ['js', $this->assetsMode]; + } elseif ('png' === $extension) { + return ['png', AssetsMode::OFFLINE == $this->assetsMode ? AssetsMode::CDN : $this->assetsMode]; + } else { + return ['css', $this->assetsMode]; + } + } + + private function getResource($asset, $mode) + { + if (filter_var($asset, FILTER_VALIDATE_URL)) { + return [$asset, false]; + } elseif (AssetsMode::OFFLINE === $mode) { + return [file_get_contents($this->resourcesDir.'/'.$asset), true]; + } elseif (AssetsMode::CDN === $mode) { + return [$this->cdnUrl.'/'.$asset, false]; + } else { + return [$this->assetExtension->getAssetUrl(sprintf('bundles/nelmioapidoc/%s', $asset)), false]; + } + } + + private function renderJavascript(string $script, bool $isInline) + { + if ($isInline) { + return sprintf('', $script); + } else { + return sprintf('', $script); + } + } + + private function renderCss(string $stylesheet, bool $isInline) + { + if ($isInline) { + return sprintf('', $stylesheet); + } else { + return sprintf('', $stylesheet); + } + } +} diff --git a/Render/Html/HtmlOpenApiRenderer.php b/Render/Html/HtmlOpenApiRenderer.php new file mode 100644 index 0000000..78577f1 --- /dev/null +++ b/Render/Html/HtmlOpenApiRenderer.php @@ -0,0 +1,63 @@ +twig = $twig; + $this->getNelmioAsset = $getNelmioAsset; + } + + public function getFormat(): string + { + return RenderOpenApi::HTML; + } + + public function render(OpenApi $spec, array $options = []): string + { + $options += [ + 'assets_mode' => AssetsMode::CDN, + 'swagger_ui_config' => [], + ]; + + $this->twig->addFunction($this->getNelmioAsset->toTwigFunction($options['assets_mode'])); + + return $this->twig->render( + '@NelmioApiDoc/SwaggerUi/index.html.twig', + [ + 'swagger_data' => ['spec' => json_decode($spec->toJson(), true)], + 'assets_mode' => $options['assets_mode'], + 'swagger_ui_config' => $options['swagger_ui_config'], + ] + ); + } +} diff --git a/Render/Json/JsonOpenApiRenderer.php b/Render/Json/JsonOpenApiRenderer.php new file mode 100644 index 0000000..62b0a7f --- /dev/null +++ b/Render/Json/JsonOpenApiRenderer.php @@ -0,0 +1,37 @@ + false, + ]; + $flags = $options['no-pretty'] ? 0 : JSON_PRETTY_PRINT; + + return json_encode($spec, $flags); + } +} diff --git a/Render/OpenApiRenderer.php b/Render/OpenApiRenderer.php new file mode 100644 index 0000000..73edb66 --- /dev/null +++ b/Render/OpenApiRenderer.php @@ -0,0 +1,24 @@ + */ + private $openApiRenderers = []; + + public function __construct(ContainerInterface $generatorLocator, OpenApiRenderer ...$openApiRenderers) + { + $this->generatorLocator = $generatorLocator; + foreach ($openApiRenderers as $openApiRenderer) { + $this->openApiRenderers[$openApiRenderer->getFormat()] = $openApiRenderer; + } + } + + public function getAvailableFormats(): array + { + return array_keys($this->openApiRenderers); + } + + public function renderFromRequest(Request $request, string $format, $area, array $extraOptions = []) + { + $options = []; + if ('' !== $request->getBaseUrl()) { + $options += [ + 'server_url' => $request->getSchemeAndHttpHost().$request->getBaseUrl(), + ]; + } + $options += $extraOptions; + + return $this->render($format, $area, $options); + } + + /** + * @throws InvalidArgumentException If the area to dump is not valid + */ + public function render(string $format, string $area, array $options = []): string + { + if (!$this->generatorLocator->has($area)) { + throw new InvalidArgumentException(sprintf('Area "%s" is not supported.', $area)); + } elseif (!array_key_exists($format, $this->openApiRenderers)) { + throw new InvalidArgumentException(sprintf('Format "%s" is not supported.', $format)); + } + + /** @var OpenApi $spec */ + $spec = $this->generatorLocator->get($area)->generate(); + + if (array_key_exists('server_url', $options) && $options['server_url']) { + $spec->servers = [new Server(['url' => $options['server_url']])]; + } + + return $this->openApiRenderers[$format]->render($spec, $options); + } +} diff --git a/Render/Yaml/YamlOpenApiRenderer.php b/Render/Yaml/YamlOpenApiRenderer.php new file mode 100644 index 0000000..b96a284 --- /dev/null +++ b/Render/Yaml/YamlOpenApiRenderer.php @@ -0,0 +1,32 @@ +toYaml(); + } +} diff --git a/Resources/config/services.xml b/Resources/config/services.xml index 8c888a6..3caaba4 100644 --- a/Resources/config/services.xml +++ b/Resources/config/services.xml @@ -6,24 +6,42 @@ - + - - + - + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/doc/commands.rst b/Resources/doc/commands.rst new file mode 100644 index 0000000..839a122 --- /dev/null +++ b/Resources/doc/commands.rst @@ -0,0 +1,45 @@ +Commands +======== + +A command is provided in order to dump the documentation in ``json``, ``yaml`` or ``html``. + +.. code-block:: bash + + $ php app/console api:doc:dump [--format="..."] + +The ``--format`` option allows to choose the format (default is: ``json``). + +By default, the generated JSON will be pretty-formatted. If you want to generate a json +without whitespace, use the ``--no-pretty`` option. + +.. code-block:: bash + + $ php app/console api:doc:dump --format=json > json-pretty-formatted.json + $ php app/console api:doc:dump --format=json --no-pretty > json-no-pretty.json + +Every format can override API url. Useful if static documentation is not hosted on API url: + +.. code-block:: bash + + $ php app/console api:doc:dump --format=yaml --server-url "http://example.com/api" > api.yaml + +For example to generate a static version of your documentation you can use: + +.. code-block:: bash + + $ php app/console api:doc:dump --format=html > api.html + +By default, the generated HTML will add the sandbox feature. +If you want to generate a static version of your documentation without sandbox, +or configure UI configuration, use the ``--html-config`` option. + +- ``assets_mode`` - `cdn` loads assets from CDN, `offline` inlines assets +- ``server_url`` - API url, useful if static documentation is not hosted on API url +- ``swagger_ui_config`` - `configure Swagger UI`_ + - ``"supportedSubmitMethods":[]`` disables the sandbox + +.. code-block:: bash + + $ php app/console api:doc:dump --format=html --html-config '{"assets_mode":"offline","server_url":"https://example.com","swagger_ui_config":{"supportedSubmitMethods":[]}}' > api.html + +.. _`configure Swagger UI`: https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/ diff --git a/Resources/doc/index.rst b/Resources/doc/index.rst index fdc7c0e..9daa3ea 100644 --- a/Resources/doc/index.rst +++ b/Resources/doc/index.rst @@ -339,6 +339,7 @@ If you need more complex features, take a look at: areas alternative_names customization + commands faq .. _`Symfony PropertyInfo component`: https://symfony.com/doc/current/components/property_info.html diff --git a/Resources/views/SwaggerUi/index.html.twig b/Resources/views/SwaggerUi/index.html.twig index 82e1332..a1b3129 100644 --- a/Resources/views/SwaggerUi/index.html.twig +++ b/Resources/views/SwaggerUi/index.html.twig @@ -14,8 +14,8 @@ file that was distributed with this source code. #} {% block title %}{{ swagger_data.spec.info.title }}{% endblock title %} {% block stylesheets %} - - + {{ nelmioAsset('swagger-ui/swagger-ui.css') }} + {{ nelmioAsset('style.css') }} {% endblock stylesheets %} {% block swagger_data %} @@ -53,7 +53,9 @@ file that was distributed with this source code. #} {% endblock svg_icons %}
{% block header %} - + {% endblock header %}
@@ -62,14 +64,18 @@ file that was distributed with this source code. #} {% endblock %} {% block javascripts %} - - + {{ nelmioAsset('swagger-ui/swagger-ui-bundle.js') }} + {{ nelmioAsset('swagger-ui/swagger-ui-standalone-preset.js') }} {% endblock javascripts %} - + {{ nelmioAsset('init-swagger-ui.js') }} + {% block swagger_initialization %} {% endblock swagger_initialization %} diff --git a/Tests/Command/DumpCommandTest.php b/Tests/Command/DumpCommandTest.php index 2ccb15e..9d82f38 100644 --- a/Tests/Command/DumpCommandTest.php +++ b/Tests/Command/DumpCommandTest.php @@ -11,26 +11,104 @@ namespace Nelmio\ApiDocBundle\Tests\Command; +use Nelmio\ApiDocBundle\Render\Html\AssetsMode; use Nelmio\ApiDocBundle\Tests\Functional\WebTestCase; // for the creation of the kernel use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Tester\CommandTester; class DumpCommandTest extends WebTestCase { - public function testExecute() + /** @dataProvider provideJsonMode */ + public function testJson(array $jsonOptions, int $expectedJsonFlags) + { + $output = $this->executeDumpCommand($jsonOptions + [ + '--area' => 'test', + ]); + $this->assertEquals( + json_encode($this->getOpenApiDefinition('test'), $expectedJsonFlags)."\n", + $output + ); + } + + public function provideJsonMode() + { + return [ + 'pretty print' => [[], JSON_PRETTY_PRINT], + 'one line' => [['--no-pretty'], 0], + ]; + } + + public function testYaml() + { + $output = $this->executeDumpCommand([ + '--format' => 'yaml', + '--server-url' => 'http://example.com/api', + ]); + $expectedYaml = <<executeDumpCommand([ + '--area' => 'test', + '--format' => 'html', + '--html-config' => json_encode($htmlConfig), + ]); + self::assertStringContainsString('', $output); + self::assertStringContainsString($expectedHtml, $output); + } + + public function provideAssetsMode() + { + return [ + 'default mode is cdn' => [ + null, + 'https://cdn.jsdelivr.net', + ], + 'invalid mode fallbacks to cdn' => [ + 'invalid', + 'https://cdn.jsdelivr.net', + ], + 'select cdn mode' => [ + ['assets_mode' => AssetsMode::CDN], + 'https://cdn.jsdelivr.net', + ], + 'select offline mode' => [ + ['assets_mode' => AssetsMode::OFFLINE], + '', + ], + 'external css' => [ + AssetsMode::BUNDLE, + 'https://cdn.com/my.css', + '', + ], + ]; + } + + private function provideJs($cdnDir, $resourceDir) + { + return [ + 'bundled js' => [ + AssetsMode::BUNDLE, + 'init-swagger-ui.js', + '', + ], + 'cdn js' => [ + AssetsMode::CDN, + 'init-swagger-ui.js', + '', + ], + 'offline js' => [ + AssetsMode::OFFLINE, + 'init-swagger-ui.js', + '', + ], + 'external js' => [ + AssetsMode::BUNDLE, + 'https://cdn.com/my.js', + '', + ], + ]; + } + + private function provideImage($cdnDir) + { + return [ + 'bundled image' => [ + AssetsMode::BUNDLE, + 'logo.png', + '/bundles/nelmioapidoc/logo.png', + ], + 'cdn image' => [ + AssetsMode::CDN, + 'logo.png', + $cdnDir.'/logo.png', + ], + 'offline image fallbacks to cdn' => [ + AssetsMode::OFFLINE, + 'logo.png', + $cdnDir.'/logo.png', + ], + ]; + } +} diff --git a/Tests/Render/RenderOpenApiTest.php b/Tests/Render/RenderOpenApiTest.php new file mode 100644 index 0000000..d9b656b --- /dev/null +++ b/Tests/Render/RenderOpenApiTest.php @@ -0,0 +1,75 @@ +createMock(OpenApiRenderer::class); + $openApiRenderer->method('getFormat')->willReturn($this->format); + $openApiRenderer->expects($this->once())->method('render'); + $this->renderOpenApi($openApiRenderer); + } + + public function testUnknownFormat() + { + $availableOpenApiRenderers = []; + $this->expectException(InvalidArgumentException::class); + $this->expectErrorMessage(sprintf('Format "%s" is not supported.', $this->format)); + $this->renderOpenApi(...$availableOpenApiRenderers); + } + + public function testUnknownArea() + { + $this->hasArea = false; + $this->expectException(InvalidArgumentException::class); + $this->expectErrorMessage(sprintf('Area "%s" is not supported.', $this->area)); + $this->renderOpenApi(); + } + + private function renderOpenApi(...$openApiRenderer): void + { + $spec = $this->createMock(OpenApi::class); + $generator = new class($spec) { + private $spec; + + public function __construct($spec) + { + $this->spec = $spec; + } + + public function generate() + { + return $this->spec; + } + }; + + $generatorLocator = $this->createMock(ContainerInterface::class); + $generatorLocator->method('has')->willReturn($this->hasArea); + $generatorLocator->method('get')->willReturn($generator); + + $renderOpenApi = new RenderOpenApi($generatorLocator, ...$openApiRenderer); + $renderOpenApi->render($this->format, $this->area, []); + } +}