From d6d9ec37b61476bd2dc80e95ebdf6e299755ebda Mon Sep 17 00:00:00 2001 From: Christoph Kappestein Date: Fri, 21 Feb 2025 23:48:27 +0100 Subject: [PATCH] add lock manager to detect api changes --- src/Console/LockCommand.php | 93 ++++++++++++++ src/Console/ParseCommand.php | 2 +- src/Exception/BreakingChangesException.php | 41 ++++++ src/Exception/LockException.php | 32 +++++ src/Inspector/ChangelogGenerator.php | 6 - src/LockManager.php | 90 +++++++++++++ tests/LockManagerTest.php | 81 ++++++++++++ tests/Parser/typeapi/simple_bc.json | 139 +++++++++++++++++++++ 8 files changed, 477 insertions(+), 7 deletions(-) create mode 100644 src/Console/LockCommand.php create mode 100644 src/Exception/BreakingChangesException.php create mode 100644 src/Exception/LockException.php create mode 100644 src/LockManager.php create mode 100644 tests/LockManagerTest.php create mode 100644 tests/Parser/typeapi/simple_bc.json diff --git a/src/Console/LockCommand.php b/src/Console/LockCommand.php new file mode 100644 index 0000000..4a56faa --- /dev/null +++ b/src/Console/LockCommand.php @@ -0,0 +1,93 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace PSX\Api\Console; + +use PSX\Api\Exception\BreakingChangesException; +use PSX\Api\Exception\LockException; +use PSX\Api\LockManager; +use PSX\Api\Scanner\FilterFactoryInterface; +use PSX\Api\ScannerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * LockCommand + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://phpsx.org + */ +class LockCommand extends Command +{ + public function __construct(private LockManager $lockManager, private ScannerInterface $scanner, private FilterFactoryInterface $filterFactory) + { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setName('api:lock') + ->setDescription('Generates or verifies an API lock file, this protects') + ->addArgument('goal', InputArgument::REQUIRED, 'Either "generate" or "verify"') + ->addOption('file', 'f', InputOption::VALUE_REQUIRED, 'Optional a specific target lock file'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $filterName = $input->getOption('filter'); + if (empty($filterName)) { + $filterName = $this->filterFactory->getDefault(); + } + + $file = $input->getOption('file'); + if (empty($file)) { + $file = getcwd() . '/api.lock'; + } + + $filter = $this->filterFactory->getFilter($filterName); + $spec = $this->scanner->generate($filter); + + try { + $goal = $input->getArgument('goal'); + if ($goal === 'generate') { + $this->lockManager->lock($spec, $file); + } elseif ($goal === 'verify') { + $this->lockManager->verify($spec, $file); + } else { + throw new LockException('Provided an invalid lock goal, must be either "generate" or "verify"'); + } + } catch (BreakingChangesException $e) { + $output->writeln('Error: ' . $e->getMessage()); + + return self::FAILURE; + } catch (LockException $e) { + $output->writeln('Error: ' . $e->getMessage()); + + return self::FAILURE; + } + + return self::SUCCESS; + } +} diff --git a/src/Console/ParseCommand.php b/src/Console/ParseCommand.php index d9ef949..bc03f9f 100644 --- a/src/Console/ParseCommand.php +++ b/src/Console/ParseCommand.php @@ -86,7 +86,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $content = $generator->generate($specification); if ($content instanceof Chunks) { - $content->writeTo($dir . '/sdk-' . $format . '.zip'); + $content->writeToZip($dir . '/sdk-' . $format . '.zip'); } else { file_put_contents($dir . '/output-' . $format . '.' . $extension, $content); } diff --git a/src/Exception/BreakingChangesException.php b/src/Exception/BreakingChangesException.php new file mode 100644 index 0000000..73812b5 --- /dev/null +++ b/src/Exception/BreakingChangesException.php @@ -0,0 +1,41 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace PSX\Api\Exception; + +/** + * BreakingChangesException + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://phpsx.org + */ +class BreakingChangesException extends LockException +{ + public function __construct(private array $changes) + { + parent::__construct(); + } + + public function getChanges(): array + { + return $this->changes; + } +} diff --git a/src/Exception/LockException.php b/src/Exception/LockException.php new file mode 100644 index 0000000..4526820 --- /dev/null +++ b/src/Exception/LockException.php @@ -0,0 +1,32 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace PSX\Api\Exception; + +/** + * LockException + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://phpsx.org + */ +class LockException extends ApiException +{ +} diff --git a/src/Inspector/ChangelogGenerator.php b/src/Inspector/ChangelogGenerator.php index 8a084ef..a439f51 100644 --- a/src/Inspector/ChangelogGenerator.php +++ b/src/Inspector/ChangelogGenerator.php @@ -47,9 +47,6 @@ public function __construct() $this->changelogGenerator = new SchemaChangelogGenerator(); } - /** - * @throws OperationNotFoundException - */ public function generate(SpecificationInterface $left, SpecificationInterface $right): \Generator { if ($left->getBaseUrl() !== $right->getBaseUrl()) { @@ -90,9 +87,6 @@ private function generateSecurity(SecurityInterface $left, SecurityInterface $ri } } - /** - * @throws OperationNotFoundException - */ private function generateCollection(OperationsInterface $left, OperationsInterface $right): \Generator { foreach ($left->getAll() as $name => $operation) { diff --git a/src/LockManager.php b/src/LockManager.php new file mode 100644 index 0000000..bf25e01 --- /dev/null +++ b/src/LockManager.php @@ -0,0 +1,90 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace PSX\Api; + +use PSX\Api\Exception\BreakingChangesException; +use PSX\Api\Exception\GeneratorException; +use PSX\Api\Exception\LockException; +use PSX\Api\Exception\ParserException; +use PSX\Api\Inspector\ChangelogGenerator; +use PSX\Schema\Inspector\SemVer; +use PSX\Schema\SchemaManagerInterface; + +/** + * The lock manager can help to ensure that your API does not introduce any breaking changes within a minor version. + * Therefor you need to generate a lock file at the start of a major version, then you can verify every change + * of the specification against this lock file and if a breaking change is introduced the verify method throws an exception + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://phpsx.org + */ +class LockManager +{ + private ChangelogGenerator $changelogGenerator; + + public function __construct(private readonly SchemaManagerInterface $schemaManager) + { + $this->changelogGenerator = new ChangelogGenerator(); + } + + /** + * @throws LockException + */ + public function lock(SpecificationInterface $specification, string $lockFile): void + { + try { + file_put_contents($lockFile, (string) (new Generator\Spec\TypeAPI())->generate($specification)); + } catch (GeneratorException $e) { + throw new LockException('Could not generate lock file: ' . $e->getMessage(), previous: $e); + } + } + + /** + * @throws LockException + * @throws BreakingChangesException + */ + public function verify(SpecificationInterface $specification, string $lockFile): void + { + if (!is_file($lockFile)) { + throw new LockException('Provided lock file does not exist: ' . $lockFile); + } + + try { + $lockSpecification = (new Parser\TypeAPI($this->schemaManager))->parse(file_get_contents($lockFile)); + } catch (ParserException $e) { + throw new LockException('Could not parse provided lock file ' . $lockFile . ' got: ' . $e->getMessage(), previous: $e); + } + + $changelogs = $this->changelogGenerator->generate($lockSpecification, $specification); + + $breakingChanges = []; + foreach ($changelogs as $level => $message) { + if ($level === SemVer::MAJOR) { + $breakingChanges[] = $message; + } + } + + if (count($breakingChanges) > 0) { + throw new BreakingChangesException($breakingChanges); + } + } +} diff --git a/tests/LockManagerTest.php b/tests/LockManagerTest.php new file mode 100644 index 0000000..2ccaf55 --- /dev/null +++ b/tests/LockManagerTest.php @@ -0,0 +1,81 @@ + + * + * Copyright (c) Christoph Kappestein + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace PSX\Api\Tests; + +use PHPUnit\Framework\TestCase; +use PSX\Api\Exception\BreakingChangesException; +use PSX\Api\LockManager; +use PSX\Api\Operation; +use PSX\Api\Operations; +use PSX\Api\SpecificationInterface; +use PSX\Schema\SchemaManager; +use PSX\Schema\TypeFactory; + +/** + * LockManagerTest + * + * @author Christoph Kappestein + * @license http://www.apache.org/licenses/LICENSE-2.0 + * @link https://phpsx.org + */ +class LockManagerTest extends ApiManagerTestCase +{ + public function testVerifyBreakingChanges(): void + { + $lockManager = new LockManager(new SchemaManager()); + + $specification = $this->apiManager->getApi(__DIR__ . '/Parser/typeapi/simple.json'); + + $lockFile = __DIR__ . '/api.lock'; + $lockManager->lock($specification, $lockFile); + + try { + $specification = $this->apiManager->getApi(__DIR__ . '/Parser/typeapi/simple_bc.json'); + + $lockManager->verify($specification, $lockFile); + + $this->fail('Must throw a breaking change exception'); + } catch (BreakingChangesException $e) { + $this->assertSame(3, count($e->getChanges())); + $this->assertSame([ + 'Operation "test.get.arguments.integer" was removed', + 'Property "Rating.text" was removed', + 'Property "Song.length" was removed', + ], $e->getChanges()); + } + } + + public function testVerifyNoBreakingChanges(): void + { + $lockManager = new LockManager(new SchemaManager()); + + $specification = $this->apiManager->getApi(__DIR__ . '/Parser/typeapi/simple.json'); + + $lockFile = __DIR__ . '/api.lock'; + $lockManager->lock($specification, $lockFile); + + $specification = $this->apiManager->getApi(__DIR__ . '/Parser/typeapi/simple.json'); + + $lockManager->verify($specification, $lockFile); + + $this->assertInstanceOf(SpecificationInterface::class, $specification); + } +} diff --git a/tests/Parser/typeapi/simple_bc.json b/tests/Parser/typeapi/simple_bc.json new file mode 100644 index 0000000..eabaab0 --- /dev/null +++ b/tests/Parser/typeapi/simple_bc.json @@ -0,0 +1,139 @@ +{ + "operations": { + "test.get": { + "description": "A long **Test** description", + "method": "GET", + "path": "/foo/:fooId", + "tags": ["foo"], + "return": { + "code": 200, + "schema": { + "$ref": "Song" + } + }, + "arguments": { + "fooId": { + "in": "path", + "schema": { + "type": "string" + } + }, + "foo": { + "in": "query", + "schema": { + "description": "Test", + "type": "string" + } + }, + "bar": { + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + "baz": { + "in": "query", + "schema": { + "type": "string", + "enum": [ + "foo", + "bar" + ] + } + }, + "boz": { + "in": "query", + "schema": { + "type": "string", + "pattern": "[A-z]+" + } + }, + "number": { + "in": "query", + "schema": { + "type": "number" + } + }, + "date": { + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + "boolean": { + "in": "query", + "schema": { + "type": "boolean" + } + }, + "string": { + "in": "query", + "schema": { + "type": "string" + } + }, + "payload": { + "in": "body", + "schema": { + "$ref": "Song" + } + } + }, + "throws": [{ + "code": 500, + "schema": { + "$ref": "Error" + } + }] + } + }, + "definitions": { + "Song": { + "description": "A canonical song", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "artist": { + "type": "string" + }, + "ratings": { + "type": "array", + "items": { + "$ref": "Rating" + } + } + }, + "required": [ + "title", + "artist" + ] + }, + "Rating": { + "title": "Rating", + "type": "object", + "properties": { + "author": { + "type": "string" + }, + "rating": { + "type": "integer" + } + } + }, + "Error": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + } + } +}