diff --git a/.gitattributes b/.gitattributes index 921af42..c211397 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,6 @@ * text=auto eol=lf /.github export-ignore -/src export-ignore /tests export-ignore /.editorconfig export-ignore /.gitattributes export-ignore diff --git a/README.md b/README.md index a06bf4e..f56836b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ See [this post](https://tech.osteel.me/posts/openapi-backed-api-testing-in-php-p ## Why? -[OpenAPI](https://swagger.io/specification/) is a specification intended to describe RESTful APIs in a way that is understood by humans and machines alike. +[OpenAPI](https://swagger.io/specification/) is a specification intended to describe RESTful APIs in a way that can be understood by both humans and machines. By validating an API's requests and responses against the OpenAPI definition that describes it, we guarantee that the API is used correctly and behaves in accordance with the documentation we provide, thus making the OpenAPI definition the single source of truth. @@ -22,21 +22,21 @@ The [HttpFoundation component](https://symfony.com/doc/current/components/http_f ## How does it work? -This package is built upon the [OpenAPI PSR-7 Message Validator](https://github.com/thephpleague/openapi-psr7-validator) one, which validates [PSR-7 messages](https://www.php-fig.org/psr/psr-7/) against OpenAPI definitions. +This package is built on top of [OpenAPI PSR-7 Message Validator](https://github.com/thephpleague/openapi-psr7-validator), which validates [PSR-7 messages](https://www.php-fig.org/psr/psr-7/) against OpenAPI definitions. It converts HttpFoundation request and response objects to PSR-7 messages using Symfony's [PSR-7 Bridge](https://symfony.com/doc/current/components/psr7.html) and [Tobias Nyholm](https://github.com/Nyholm)'s [PSR-7 implementation](https://github.com/Nyholm/psr7), before passing them on to OpenAPI PSR-7 Message Validator. ## Installation +> **Note** +> This package is mostly intended to be used as part of an API test suite. + Via Composer: ```bash $ composer require --dev osteel/openapi-httpfoundation-testing ``` -> **Note** -> This package is mostly intended to be used as part of an API test suite. - ## Usage Import the builder class: @@ -45,7 +45,7 @@ Import the builder class: use Osteel\OpenApi\Testing\ValidatorBuilder; ``` -Use the builder to create a `\Osteel\OpenApi\Testing\Validator` object, using one of the available factory methods for YAML or JSON: +Use the builder to create a [`\Osteel\OpenApi\Testing\Validator`](/src/Validator.php) object, using one of the available factory methods for YAML or JSON: ```php // From a file: @@ -64,6 +64,9 @@ $validator = ValidatorBuilder::fromYaml($yamlFileOrString)->getValidator(); $validator = ValidatorBuilder::fromJson($jsonFileOrString)->getValidator(); ``` +> **Note** +> You can also use a dependency injection container to bind the `ValidatorBuilder` class to the [`ValidatorBuilderInterface`](/src/ValidatorBuilderInterface.php) interface it implements and inject the interface instead, which would also be useful for testing and mocking. + You can now validate `\Symfony\Component\HttpFoundation\Request` and `\Symfony\Component\HttpFoundation\Response` objects for a given [path](https://swagger.io/specification/#paths-object) and method: ```php @@ -89,7 +92,53 @@ $validator->post($request, '/users'); In the example above, we check that the request matches the OpenAPI definition for a `POST` request on the `/users` path. -The `validate` method returns `true` in case of success, and throws `\Osteel\OpenApi\Testing\Exceptions\ValidationException` exceptions in case of error. +The `validate` method returns `true` in case of success, and throw a [`\Osteel\OpenApi\Testing\Exceptions\ValidationException`](/src/Exceptions/ValidationException.php) exception in case of error. + +## Caching + +This package supports caching to speed up the parsing of OpenAPI definitions. Simply pass your [PSR-6](https://www.php-fig.org/psr/psr-6/) or [PSR-16](https://www.php-fig.org/psr/psr-16/) cache object to the `setCache` method of the [`ValidatorBuilder`](/src/ValidatorBuilder.php) class. + +Here is an example using Symfony's [Array Cache Adapter](https://symfony.com/doc/current/components/cache/adapters/array_cache_adapter.html "Array Cache Adapter"): + +```php +use Osteel\OpenApi\Testing\ValidatorBuilder; +use Symfony\Component\Cache\Adapter\ArrayAdapter; + +$cache = new ArrayAdapter(); +$validator = ValidatorBuilder::fromYamlFile($yamlFile)->setCache($cache)->getValidator(); +``` + +## Extending the package + +There are two main extension points – message adapters and cache adapters. + +### Message adapters + +The [`ValidatorBuilder`](/src/ValidatorBuilder.php) class uses the [`HttpFoundationAdapter`](/src/Adapters/HttpFoundationAdapter.php) class as its default HTTP message adapter. This class converts HttpFoundation request and response objects to their PSR-7 counterparts. + +If you need to change the adapter's logic, or if you need a new adapter altogether, create a class implementing the [`MessageAdapterInterface`](/src/Adapters/MessageAdapterInterface.php) interface and pass it to the `setMessageAdapter` method of the [`ValidatorBuilder`](/src/ValidatorBuilder.php) class: + +```php +$validator = ValidatorBuilder::fromYamlFile($yamlFile) + ->setMessageAdapter($yourAdapter) + ->getValidator(); +``` + +### Cache adapters + +The [`ValidatorBuilder`](/src/ValidatorBuilder.php) class uses the [`Psr16Adapter`](/src/Cache/Psr16Adapter.php) class as its default cache adapter. This class converts PSR-16 cache objects to their PSR-6 counterparts. + +If you need to change the adapter's logic, or if you need a new adapter altogether, create a class implementing the [`CacheAdapterInterface`](/src/Cache/CacheAdapterInterface.php) interface and pass it to the `setCacheAdapter` method of the [`ValidatorBuilder`](/src/ValidatorBuilder.php) class: + +```php +$validator = ValidatorBuilder::fromYamlFile($yamlFile) + ->setCacheAdapter($yourAdapter) + ->getValidator(); +``` + +### Other interfaces + +The [`ValidatorBuilder`](/src/ValidatorBuilder.php) and [`Validator`](/src/Validator.php) classes are `final` but they implement the [`ValidatorBuilderInterface`](/src/ValidatorBuilderInterface.php) and [`ValidatorInterface`](/src/ValidatorInterface.php) interfaces respectively for which you can provide your own implementations if you need to. ## Change log diff --git a/composer.json b/composer.json index c835b14..04b3480 100644 --- a/composer.json +++ b/composer.json @@ -27,13 +27,16 @@ "ext-json": "*", "league/openapi-psr7-validator": "^0.21", "nyholm/psr7": "^1.0", + "psr/cache": "^3.0", "psr/http-message": "^1.0", + "psr/simple-cache": "^3.0", + "symfony/cache": "^6.0", "symfony/http-foundation": "^4.0 || ^5.0 || ^6.0", "symfony/psr-http-message-bridge": "^2.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.17", - "phpunit/phpunit": "^9.0", + "phpunit/phpunit": "^9.6", "rector/rector": "^0.17.1" }, "autoload": { @@ -47,8 +50,12 @@ } }, "scripts": { - "test": "phpunit", "fix": "php-cs-fixer fix -v", + "test": "phpunit", + "all": [ + "@fix", + "@test" + ], "refactor": "rector process" }, "extra": { diff --git a/src/Adapters/HttpFoundationAdapter.php b/src/Adapters/HttpFoundationAdapter.php index 1d750de..c5e3c43 100644 --- a/src/Adapters/HttpFoundationAdapter.php +++ b/src/Adapters/HttpFoundationAdapter.php @@ -13,10 +13,10 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -final class HttpFoundationAdapter implements AdapterInterface +final class HttpFoundationAdapter implements MessageAdapterInterface { /** - * @inheritDoc + * Convert a HttpFoundation object to a PSR-7 HTTP message. * * @param object $message the HTTP message to convert */ diff --git a/src/Adapters/AdapterInterface.php b/src/Adapters/MessageAdapterInterface.php similarity index 90% rename from src/Adapters/AdapterInterface.php rename to src/Adapters/MessageAdapterInterface.php index a9eae07..4de8937 100644 --- a/src/Adapters/AdapterInterface.php +++ b/src/Adapters/MessageAdapterInterface.php @@ -6,7 +6,7 @@ use Psr\Http\Message\MessageInterface; -interface AdapterInterface +interface MessageAdapterInterface { /** * Convert a HTTP message to a PSR-7 HTTP message. diff --git a/src/Cache/CacheAdapterInterface.php b/src/Cache/CacheAdapterInterface.php new file mode 100644 index 0000000..cc93da0 --- /dev/null +++ b/src/Cache/CacheAdapterInterface.php @@ -0,0 +1,17 @@ +cacheAdapter(); + + $this->validatorBuilder->setCache($adapter->convert($cache)); + + return $this; + } + /** @inheritDoc */ public function getValidator(): ValidatorInterface { @@ -108,25 +122,42 @@ public function getValidator(): ValidatorInterface } /** - * Change the adapter to use. The provided class must implement - * \Osteel\OpenApi\Testing\Adapters\AdapterInterface. + * Change the adapter to use. The provided class must implement \Osteel\OpenApi\Testing\Adapters\AdapterInterface. * * @param string $class the adapter's class * * @throws InvalidArgumentException */ - public function setAdapter(string $class): ValidatorBuilder + public function setMessageAdapter(string $class): ValidatorBuilder { - if (! is_subclass_of($class, AdapterInterface::class)) { - throw new InvalidArgumentException(sprintf( - 'Class %s does not implement the %s interface', - $class, - AdapterInterface::class - )); + if (is_subclass_of($class, MessageAdapterInterface::class)) { + $this->adapter = $class; + + return $this; } - $this->adapter = $class; + throw new InvalidArgumentException( + sprintf('Class %s does not implement the %s interface', $class, MessageAdapterInterface::class), + ); + } + + /** + * Change the cache adapter to use. The provided class must implement \Osteel\OpenApi\Testing\Cache\AdapterInterface. + * + * @param string $class the cache adapter's class + * + * @throws InvalidArgumentException + */ + public function setCacheAdapter(string $class): ValidatorBuilder + { + if (is_subclass_of($class, CacheAdapterInterface::class)) { + $this->cacheAdapter = $class; - return $this; + return $this; + } + + throw new InvalidArgumentException( + sprintf('Class %s does not implement the %s interface', $class, CacheAdapterInterface::class), + ); } } diff --git a/src/ValidatorBuilderInterface.php b/src/ValidatorBuilderInterface.php index 04a2036..667bee0 100644 --- a/src/ValidatorBuilderInterface.php +++ b/src/ValidatorBuilderInterface.php @@ -48,6 +48,9 @@ public static function fromYamlString(string $definition): ValidatorBuilderInter */ public static function fromJsonString(string $definition): ValidatorBuilderInterface; + /** Set a cache library. */ + public function setCache(object $cache): ValidatorBuilderInterface; + /** Return the validator. */ public function getValidator(): ValidatorInterface; } diff --git a/tests/Adapters/HttpFoundationAdapterTest.php b/tests/Adapters/HttpFoundationAdapterTest.php index 41c0f48..a073cfd 100644 --- a/tests/Adapters/HttpFoundationAdapterTest.php +++ b/tests/Adapters/HttpFoundationAdapterTest.php @@ -9,6 +9,7 @@ use Psr\Http\Message\ServerRequestInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use stdClass; class HttpFoundationAdapterTest extends TestCase { @@ -24,9 +25,9 @@ protected function setUp(): void public function test_it_does_not_convert_the_message_because_the_type_is_not_supported() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Unsupported InvalidArgumentException object received'); + $this->expectExceptionMessage('Unsupported stdClass object received'); - $this->sut->convert(new InvalidArgumentException()); + $this->sut->convert(new stdClass()); } public function test_it_converts_the_http_foundation_request() diff --git a/tests/Cache/Psr16AdapterTest.php b/tests/Cache/Psr16AdapterTest.php new file mode 100644 index 0000000..9d18d6f --- /dev/null +++ b/tests/Cache/Psr16AdapterTest.php @@ -0,0 +1,45 @@ +sut = new Psr16Adapter(); + } + + public function test_it_does_not_convert_the_caching_library_because_the_type_is_not_supported() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported stdClass object received'); + + $this->sut->convert(new stdClass()); + } + + public function test_it_converts_the_psr16_caching_library() + { + $result = $this->sut->convert($this->createMock(CacheInterface::class)); + + $this->assertInstanceOf(CacheItemPoolInterface::class, $result); + } + + public function test_it_leaves_the_psr6_caching_library_untouched() + { + $cache = $this->createMock(CacheItemPoolInterface::class); + $result = $this->sut->convert($cache); + + $this->assertEquals($cache, $result); + } +} diff --git a/tests/ValidatorBuilderTest.php b/tests/ValidatorBuilderTest.php index 2737acf..f10f023 100644 --- a/tests/ValidatorBuilderTest.php +++ b/tests/ValidatorBuilderTest.php @@ -5,9 +5,11 @@ namespace Osteel\OpenApi\Testing\Tests; use InvalidArgumentException; -use Osteel\OpenApi\Testing\Adapters\AdapterInterface; +use Osteel\OpenApi\Testing\Adapters\MessageAdapterInterface; +use Osteel\OpenApi\Testing\Cache\CacheAdapterInterface; use Osteel\OpenApi\Testing\Validator; use Osteel\OpenApi\Testing\ValidatorBuilder; +use stdClass; class ValidatorBuilderTest extends TestCase { @@ -45,19 +47,38 @@ public function test_it_does_not_set_the_adapter_because_its_type_is_invalid() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage(sprintf( 'Class %s does not implement the %s interface', - InvalidArgumentException::class, - AdapterInterface::class + stdClass::class, + MessageAdapterInterface::class )); - ValidatorBuilder::fromYaml(self::$yamlDefinition)->setAdapter(InvalidArgumentException::class); + ValidatorBuilder::fromYaml(self::$yamlDefinition)->setMessageAdapter(stdClass::class); } public function test_it_sets_the_adapter() { ValidatorBuilder::fromYaml(self::$yamlDefinition) - ->setAdapter($this->createMock(AdapterInterface::class)::class); + ->setMessageAdapter($this->createMock(MessageAdapterInterface::class)::class); - // No exception means the test was successful. - $this->assertTrue(true); + $this->addToAssertionCount(1); + } + + public function test_it_does_not_set_the_cache_adapter_because_its_type_is_invalid() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'Class %s does not implement the %s interface', + stdClass::class, + CacheAdapterInterface::class + )); + + ValidatorBuilder::fromYaml(self::$yamlDefinition)->setCacheAdapter(stdClass::class); + } + + public function test_it_sets_the_cache_adapter() + { + ValidatorBuilder::fromYaml(self::$yamlDefinition) + ->setCacheAdapter($this->createMock(CacheAdapterInterface::class)::class); + + $this->addToAssertionCount(1); } }