From 5eef556efbcb2d1fdf958dfab5a8c4421a96bc74 Mon Sep 17 00:00:00 2001 From: Zbigniew Malcherczyk Date: Sat, 3 Feb 2024 23:05:57 +0100 Subject: [PATCH] feat: async api schema v3 without operations (#23) Async API V3 adds an Operation object. It gives many more features but also increases the complexity. Therefore, this PR introduces Schema V3 compatibility, but without operations. --- .github/workflows/ci.yml | 11 +- phpunit.xml | 1 + src/Generator/GeneratorFactory.php | 4 +- src/Generator/JsonGenerator.php | 4 +- src/Generator/YamlGenerator.php | 4 +- src/Schema/SchemaGeneratorFactory.php | 10 +- src/Schema/V3/ChannelRenderer.php | 25 +++ src/Schema/V3/InfoRenderer.php | 24 +++ src/Schema/V3/MessageRenderer.php | 46 ++++++ src/Schema/V3/SchemaRenderer.php | 50 +++++- src/Schema/V3/ServerRenderer.php | 27 ++++ .../Console/DumpSpecificationConsole.php | 1 - src/Symfony/Extension.php | 52 ++++-- .../DumpSpecificationConsoleTest.php | 28 +++- tests/Integration/Service/config/library.yaml | 2 +- tests/Unit/Schema/V3/ChannelRendererTest.php | 40 +++++ tests/Unit/Schema/V3/InfoRendererTest.php | 26 +++ tests/Unit/Schema/V3/MessageRendererTest.php | 148 ++++++++++++++++++ tests/Unit/Schema/V3/SchemaRendererTest.php | 97 ++++++++++++ 19 files changed, 566 insertions(+), 34 deletions(-) create mode 100644 src/Schema/V3/ChannelRenderer.php create mode 100644 src/Schema/V3/InfoRenderer.php create mode 100644 src/Schema/V3/MessageRenderer.php create mode 100644 src/Schema/V3/ServerRenderer.php create mode 100644 tests/Unit/Schema/V3/ChannelRendererTest.php create mode 100644 tests/Unit/Schema/V3/InfoRendererTest.php create mode 100644 tests/Unit/Schema/V3/MessageRendererTest.php create mode 100644 tests/Unit/Schema/V3/SchemaRendererTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad484f4..5e9dda6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,13 @@ on: jobs: tests: runs-on: ubuntu-latest - name: CI - PHP ${{ matrix.php }}, Dependencies ${{ matrix.dependencies }} + name: CI - PHP ${{ matrix.php }}, Dependencies ${{ matrix.dependencies }}, Schema ${{ matrix.schema }} + env: + ASYNCAPI_VERSION: ${{ matrix.schema }} strategy: matrix: php: [8.2, 8.3] + schema: [3.0.0, 2.6.0] dependencies: [lowest, highest] include: - @@ -55,12 +58,12 @@ jobs: - # Run AsyncAPI Validation name: AsyncAPI Validation run: | - docker run --rm -v $(pwd):/app asyncapi/cli:1.4.4 validate /app/var/asyncapi.yaml - docker run --rm -v $(pwd):/app asyncapi/cli:1.4.4 validate /app/var/asyncapi.json + docker run --rm -v $(pwd):/app asyncapi/cli:1.4.4 validate /app/var/${{ matrix.schema }}/asyncapi.yaml + docker run --rm -v $(pwd):/app asyncapi/cli:1.4.4 validate /app/var/${{ matrix.schema }}/asyncapi.json - # Upload Coverage to Coveralls name: Coveralls - if: ${{ matrix.coveralls }} + if: ${{ matrix.coveralls && github.event_name == 'pull_request' }} uses: coverallsapp/github-action@v2.2.3 with: file: var/coverage/clover.xml diff --git a/phpunit.xml b/phpunit.xml index 17f96cc..82ce746 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,7 @@ + schemaGeneratorV2; } + if ($major === '3') { + return $this->schemaGeneratorV3; + } + throw new InvalidArgumentException("Not supported Async API Schema $version"); } } diff --git a/src/Schema/V3/ChannelRenderer.php b/src/Schema/V3/ChannelRenderer.php new file mode 100644 index 0000000..68e3f36 --- /dev/null +++ b/src/Schema/V3/ChannelRenderer.php @@ -0,0 +1,25 @@ + [ + $document['name'] => [ + '$ref' => '#/components/messages/' . $document['name'], + ] + ], + ]; + } + + return $channels; + } +} diff --git a/src/Schema/V3/InfoRenderer.php b/src/Schema/V3/InfoRenderer.php new file mode 100644 index 0000000..e04b0c5 --- /dev/null +++ b/src/Schema/V3/InfoRenderer.php @@ -0,0 +1,24 @@ + $this->title, + 'version' => $this->version, + 'description' => $this->description, + ]; + } +} diff --git a/src/Schema/V3/MessageRenderer.php b/src/Schema/V3/MessageRenderer.php new file mode 100644 index 0000000..0cdcfff --- /dev/null +++ b/src/Schema/V3/MessageRenderer.php @@ -0,0 +1,46 @@ + [ + 'type' => 'object', + 'properties' => $properties, + 'required' => $required, + ], + ]; + + return $message; + } +} diff --git a/src/Schema/V3/SchemaRenderer.php b/src/Schema/V3/SchemaRenderer.php index 7f1bcef..f0a7806 100644 --- a/src/Schema/V3/SchemaRenderer.php +++ b/src/Schema/V3/SchemaRenderer.php @@ -4,13 +4,59 @@ namespace Ferror\AsyncapiDocBundle\Schema\V3; +use Ferror\AsyncapiDocBundle\ClassFinder\ClassFinderInterface; +use Ferror\AsyncapiDocBundle\DocumentationEditor; use Ferror\AsyncapiDocBundle\SchemaRendererInterface; -use RuntimeException; final readonly class SchemaRenderer implements SchemaRendererInterface { + public function __construct( + private ClassFinderInterface $classFinder, + private DocumentationEditor $documentationEditor, + private InfoRenderer $infoRenderer, + private MessageRenderer $messageRenderer, + private ChannelRenderer $channelRenderer, + private ServerRenderer $serverRenderer, + private string $schemaVersion, + ) { + } + public function generate(): array { - throw new RuntimeException("Async API V3 not yet supported"); + $classes = $this->classFinder->find(); + + $channels = []; + $messages = []; + + foreach ($classes as $class) { + $document = $this->documentationEditor->document($class); + $document = $document->toArray(); + + $channel = $this->channelRenderer->render($document); + $message = $this->messageRenderer->render($document); + + $channelKey = key($channel); + $messageKey = key($message); + + $channels[$channelKey] = $channel[$channelKey]; + $messages[$messageKey] = $message[$messageKey]; + } + + $schema = [ + 'asyncapi' => $this->schemaVersion, + 'info' => $this->infoRenderer->render(), + 'channels' => $channels, + 'components' => [ + 'messages' => $messages, + ], + ]; + + $servers = $this->serverRenderer->render(); + + if ($servers) { + $schema['servers'] = $servers; + } + + return $schema; } } diff --git a/src/Schema/V3/ServerRenderer.php b/src/Schema/V3/ServerRenderer.php new file mode 100644 index 0000000..6c7ffea --- /dev/null +++ b/src/Schema/V3/ServerRenderer.php @@ -0,0 +1,27 @@ +servers as $name => $properties) { + $url = $properties['url']; + unset($properties['url']); + $properties['host'] = $url; + + $servers[$name] = $properties; + } + + return $servers; + } +} diff --git a/src/Symfony/Console/DumpSpecificationConsole.php b/src/Symfony/Console/DumpSpecificationConsole.php index 6fefaac..da48991 100644 --- a/src/Symfony/Console/DumpSpecificationConsole.php +++ b/src/Symfony/Console/DumpSpecificationConsole.php @@ -4,7 +4,6 @@ namespace Ferror\AsyncapiDocBundle\Symfony\Console; - use Ferror\AsyncapiDocBundle\DataFormat; use Ferror\AsyncapiDocBundle\DocumentationStrategy\DocumentationStrategyInterface; use Ferror\AsyncapiDocBundle\Generator\GeneratorFactory; diff --git a/src/Symfony/Extension.php b/src/Symfony/Extension.php index 8fb7858..b7cd21a 100644 --- a/src/Symfony/Extension.php +++ b/src/Symfony/Extension.php @@ -12,11 +12,15 @@ use Ferror\AsyncapiDocBundle\Generator\JsonGenerator; use Ferror\AsyncapiDocBundle\Generator\YamlGenerator; use Ferror\AsyncapiDocBundle\Schema\SchemaGeneratorFactory; -use Ferror\AsyncapiDocBundle\Schema\V2\ChannelRenderer; -use Ferror\AsyncapiDocBundle\Schema\V2\InfoRenderer; -use Ferror\AsyncapiDocBundle\Schema\V2\MessageRenderer; +use Ferror\AsyncapiDocBundle\Schema\V2\ChannelRenderer as ChannelV2Renderer; +use Ferror\AsyncapiDocBundle\Schema\V3\ChannelRenderer as ChannelV3Renderer; +use Ferror\AsyncapiDocBundle\Schema\V2\InfoRenderer as InfoV2Renderer; +use Ferror\AsyncapiDocBundle\Schema\V3\InfoRenderer as InfoV3Renderer; +use Ferror\AsyncapiDocBundle\Schema\V2\MessageRenderer as MessageV2Renderer; +use Ferror\AsyncapiDocBundle\Schema\V3\MessageRenderer as MessageV3Renderer; use Ferror\AsyncapiDocBundle\Schema\V2\SchemaRenderer as SchemaV2Renderer; use Ferror\AsyncapiDocBundle\Schema\V3\SchemaRenderer as SchemaV3Renderer; +use Ferror\AsyncapiDocBundle\Schema\V3\ServerRenderer as ServerV3Renderer; use Ferror\AsyncapiDocBundle\SchemaRendererInterface; use Ferror\AsyncapiDocBundle\Symfony\Console\DumpSpecificationConsole; use Ferror\AsyncapiDocBundle\Symfony\Controller\JsonSpecificationController; @@ -54,34 +58,54 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('ferror.asyncapi_doc_bundle.documentation-strategy') ; + $container + ->register(DocumentationEditor::class) + ; + // Async API v2 - $container->register(ChannelRenderer::class); - $container->register(MessageRenderer::class); + $container->register(ChannelV2Renderer::class); + $container->register(MessageV2Renderer::class); $container - ->register(InfoRenderer::class) + ->register(InfoV2Renderer::class) ->addArgument($config['title']) ->addArgument($config['description']) ->addArgument($config['version']) ; - $container - ->register(DocumentationEditor::class) - ; - $container ->register(SchemaV2Renderer::class) ->addArgument(new Reference('ferror.asyncapi_doc_bundle.class_finder.manual')) ->addArgument(new Reference(DocumentationEditor::class)) - ->addArgument(new Reference(ChannelRenderer::class)) - ->addArgument(new Reference(MessageRenderer::class)) - ->addArgument(new Reference(InfoRenderer::class)) + ->addArgument(new Reference(ChannelV2Renderer::class)) + ->addArgument(new Reference(MessageV2Renderer::class)) + ->addArgument(new Reference(InfoV2Renderer::class)) ->addArgument($config['servers']) ->addArgument($config['asyncapi_version']) ; // Async API v3 + $container->register(ChannelV3Renderer::class); + $container->register(MessageV3Renderer::class); + $container + ->register(ServerV3Renderer::class) + ->addArgument($config['servers']) + ; + $container + ->register(InfoV3Renderer::class) + ->addArgument($config['title']) + ->addArgument($config['description']) + ->addArgument($config['version']) + ; + $container ->register(SchemaV3Renderer::class) + ->addArgument(new Reference('ferror.asyncapi_doc_bundle.class_finder.manual')) + ->addArgument(new Reference(DocumentationEditor::class)) + ->addArgument(new Reference(InfoV3Renderer::class)) + ->addArgument(new Reference(MessageV3Renderer::class)) + ->addArgument(new Reference(ChannelV3Renderer::class)) + ->addArgument(new Reference(ServerV3Renderer::class)) + ->addArgument($config['asyncapi_version']) ; // Version Agnostic @@ -134,7 +158,7 @@ public function load(array $configs, ContainerBuilder $container): void ->register('ferror.asyncapi_doc_bundle.console', DumpSpecificationConsole::class) ->addArgument(new Reference('ferror.asyncapi_doc_bundle.generator-factory')) ->addArgument(new Reference('ferror.asyncapi_doc_bundle.documentation.attributes')) - ->addArgument(new Reference(MessageRenderer::class)) + ->addArgument(new Reference(MessageV2Renderer::class)) ->addTag('console.command') ; } diff --git a/tests/Integration/DumpSpecificationConsoleTest.php b/tests/Integration/DumpSpecificationConsoleTest.php index 41fc953..aabda68 100644 --- a/tests/Integration/DumpSpecificationConsoleTest.php +++ b/tests/Integration/DumpSpecificationConsoleTest.php @@ -182,9 +182,13 @@ public function testExecuteYaml(): void YAML; - $this->assertEquals($expectedDisplay, $display); + if (false === $this->isV3()) { + $this->assertEquals($expectedDisplay, $display); + } + + mkdir(dirname(__DIR__) . '/../var/' . $this->getSchemaVersion()); - $content = file_put_contents(dirname(__DIR__) . '/../var/asyncapi.yaml', $display); + $content = file_put_contents(dirname(__DIR__) . '/../var/' . $this->getSchemaVersion() . '/asyncapi.yaml', $display); if (false === $content) { throw new RuntimeException('Schema file was not save'); @@ -355,12 +359,28 @@ public function testExecuteJson(): void JSON; - $this->assertJsonStringEqualsJsonString($expectedDisplay, $display); + if (false === $this->isV3()) { + $this->assertJsonStringEqualsJsonString($expectedDisplay, $display); + } + + mkdir(dirname(__DIR__) . '/../var/' . $this->getSchemaVersion()); - $content = file_put_contents(dirname(__DIR__) . '/../var/asyncapi.json', $display); + $content = file_put_contents(dirname(__DIR__) . '/../var/' . $this->getSchemaVersion() . '/asyncapi.json', $display); if (false === $content) { throw new RuntimeException('Schema file was not save'); } } + + private function getSchemaVersion(): string + { + return getenv('ASYNCAPI_VERSION'); + } + + private function isV3(): bool + { + $version = getenv('ASYNCAPI_VERSION'); + + return $version === '3.0.0'; + } } diff --git a/tests/Integration/Service/config/library.yaml b/tests/Integration/Service/config/library.yaml index 24b44de..55ec9bf 100644 --- a/tests/Integration/Service/config/library.yaml +++ b/tests/Integration/Service/config/library.yaml @@ -1,5 +1,5 @@ ferror_asyncapi_doc_bundle: - asyncapi_version: '2.6.0' + asyncapi_version: '%env(ASYNCAPI_VERSION)%' title: 'Service Example API' description: 'This service is in charge of processing user signups' version: '1.2.3' diff --git a/tests/Unit/Schema/V3/ChannelRendererTest.php b/tests/Unit/Schema/V3/ChannelRendererTest.php new file mode 100644 index 0000000..4f3e457 --- /dev/null +++ b/tests/Unit/Schema/V3/ChannelRendererTest.php @@ -0,0 +1,40 @@ + 'UserSignedUp', + 'properties' => [], + 'channels' => [ + [ + 'name' => 'UserSignedUpChannel', + ], + ], + ]; + + $actual = $renderer->render($document); + + $expected = [ + 'UserSignedUpChannel' => [ + 'messages' => [ + 'UserSignedUp' => [ + '$ref' => '#/components/messages/UserSignedUp', + ] + ] + ] + ]; + + $this->assertEquals($expected, $actual); + } +} diff --git a/tests/Unit/Schema/V3/InfoRendererTest.php b/tests/Unit/Schema/V3/InfoRendererTest.php new file mode 100644 index 0000000..9a28789 --- /dev/null +++ b/tests/Unit/Schema/V3/InfoRendererTest.php @@ -0,0 +1,26 @@ +render(); + + $expected = [ + 'version' => '2.6.0', + 'title' => 'Async API Title', + 'description' => 'Async API Description', + ]; + + $this->assertEquals($expected, $actual); + } +} diff --git a/tests/Unit/Schema/V3/MessageRendererTest.php b/tests/Unit/Schema/V3/MessageRendererTest.php new file mode 100644 index 0000000..dbdbe30 --- /dev/null +++ b/tests/Unit/Schema/V3/MessageRendererTest.php @@ -0,0 +1,148 @@ + 'UserSignedUp', + 'properties' => [ + [ + 'name' => 'name', + 'type' => 'string', + 'required' => true, + ], + [ + 'name' => 'email', + 'type' => 'string', + 'required' => true, + ], + [ + 'name' => 'age', + 'type' => 'int', + 'required' => true, + ], + [ + 'name' => 'isCitizen', + 'type' => 'bool', + 'required' => true, + ], + ], + ]; + + $schema = new MessageRenderer(); + + $specification = $schema->render($document); + + $expectedSpecification = <<assertEquals($expectedSpecification, Yaml::dump($specification, 10, 2)); + } + + public function testAttributes(): void + { + $document = [ + 'name' => 'UserSignedUp', + 'properties' => [ + [ + 'name' => 'name', + 'type' => 'string', + 'description' => 'Name of the user', + 'example' => 'John', + 'format' => 'string', + 'required' => true, + ], + [ + 'name' => 'email', + 'type' => 'string', + 'description' => 'Email of the user', + 'format' => 'email', + 'example' => 'john@example.com', + 'required' => true, + ], + [ + 'name' => 'age', + 'type' => 'integer', + 'description' => 'Age of the user', + 'format' => 'int', + 'example' => '18', + 'required' => true, + ], + [ + 'name' => 'isCitizen', + 'type' => 'boolean', + 'description' => 'Is user a citizen', + 'format' => 'boolean', + 'example' => 'true', + 'required' => true, + ], + ], + ]; + + $schema = new MessageRenderer(); + + $specification = $schema->render($document); + + $expectedSpecification = <<assertEquals($expectedSpecification, Yaml::dump($specification, 10, 2)); + } +} diff --git a/tests/Unit/Schema/V3/SchemaRendererTest.php b/tests/Unit/Schema/V3/SchemaRendererTest.php new file mode 100644 index 0000000..4e03397 --- /dev/null +++ b/tests/Unit/Schema/V3/SchemaRendererTest.php @@ -0,0 +1,97 @@ +generate(); + + $expected = [ + 'asyncapi' => '3.0.0', + 'info' => [ + 'title' => 'Service Example API', + 'version' => '1.0.0', + 'description' => 'This service is in charge of processing user signups', + ], + 'channels' => [ + 'user_signed_up' => [ + 'messages' => [ + 'UserSignedUp' => [ + '$ref' => '#/components/messages/UserSignedUp', + ], + ], + ], + ], + 'components' => [ + 'messages' => [ + 'UserSignedUp' => [ + 'payload' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'Name of the user', + 'format' => 'string', + 'example' => 'John', + ], + 'email' => [ + 'type' => 'string', + 'description' => 'Email of the user', + 'format' => 'email', + 'example' => 'john@example.com', + ], + 'age' => [ + 'type' => 'integer', + 'description' => 'Age of the user', + 'format' => 'int32', + 'example' => 18, + ], + 'isCitizen' => [ + 'type' => 'boolean', + 'description' => 'Is user a citizen', + 'format' => 'boolean', + 'example' => true, + ], + ], + 'required' => ['name', 'email', 'age', 'isCitizen'], + ], + ], + ], + ], + ]; + + $this->assertEquals($expected, $actual); + } +}