From 3be3225da28c8eff999ba80294cb898ae5811918 Mon Sep 17 00:00:00 2001 From: Anatoly Nekhay Date: Mon, 27 Jul 2020 05:43:20 +0300 Subject: [PATCH 1/3] support apcu cache for openapi annotations --- composer.json | 1 + src/AbstractAnnotation.php | 6 +- src/AbstractAnnotationReference.php | 30 ++-- .../RequestBodyValidationMiddleware.php | 75 +++----- .../RequestQueryValidationMiddleware.php | 20 ++- src/Utility/JsonSchemaBuilder.php | 48 ++++- .../RequestQueryValidationMiddlewareTest.php | 165 ++++++++++++++++++ tests/Utility/JsonSchemaBuilderTest.php | 40 +++++ 8 files changed, 318 insertions(+), 67 deletions(-) create mode 100644 tests/Middleware/RequestQueryValidationMiddlewareTest.php diff --git a/composer.json b/composer.json index 707d754..218f03a 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "require": { "php": "^7.1", "doctrine/annotations": "^1.6", + "doctrine/cache": "^1.6", "sunrise/http-router": "^2.4" }, "require-dev": { diff --git a/src/AbstractAnnotation.php b/src/AbstractAnnotation.php index 3126252..d9430cd 100644 --- a/src/AbstractAnnotation.php +++ b/src/AbstractAnnotation.php @@ -14,7 +14,7 @@ /** * Import classes */ -use Doctrine\Common\Annotations\SimpleAnnotationReader; +use Doctrine\Common\Annotations\Reader as AnnotationReader; /** * Import functions @@ -31,11 +31,11 @@ abstract class AbstractAnnotation extends AbstractObject /** * Recursively collects all annotations referenced by this object or its children * - * @param SimpleAnnotationReader $annotationReader + * @param AnnotationReader $annotationReader * * @return ComponentObjectInterface[] */ - public function getReferencedObjects(SimpleAnnotationReader $annotationReader) : array + public function getReferencedObjects(AnnotationReader $annotationReader) : array { $fields = $this->getFields(); $objects = []; diff --git a/src/AbstractAnnotationReference.php b/src/AbstractAnnotationReference.php index f1148d3..69abc9f 100644 --- a/src/AbstractAnnotationReference.php +++ b/src/AbstractAnnotationReference.php @@ -14,7 +14,7 @@ /** * Import classes */ -use Doctrine\Common\Annotations\SimpleAnnotationReader; +use Doctrine\Common\Annotations\Reader as AnnotationReader; use ReflectionClass; use ReflectionMethod; use ReflectionProperty; @@ -94,13 +94,13 @@ abstract public function getAnnotationName() : string; /** * Tries to find a referenced object that implements the `ComponentObjectInterface` interface * - * @param SimpleAnnotationReader $annotationReader + * @param AnnotationReader $annotationReader * * @return ComponentObjectInterface * * @throws InvalidReferenceException */ - public function getAnnotation(SimpleAnnotationReader $annotationReader) : ComponentObjectInterface + public function getAnnotation(AnnotationReader $annotationReader) : ComponentObjectInterface { $key = hash( 'md5', @@ -128,17 +128,17 @@ public function getAnnotation(SimpleAnnotationReader $annotationReader) : Compon } /** - * Proxy to `SimpleAnnotationReader::getMethodAnnotation()` with validation + * Proxy to `AnnotationReader::getMethodAnnotation()` with validation * - * @param SimpleAnnotationReader $annotationReader + * @param AnnotationReader $annotationReader * * @return ComponentObjectInterface * * @throws InvalidReferenceException * - * @see SimpleAnnotationReader::getMethodAnnotation() + * @see AnnotationReader::getMethodAnnotation() */ - private function getMethodAnnotation(SimpleAnnotationReader $annotationReader) : ComponentObjectInterface + private function getMethodAnnotation(AnnotationReader $annotationReader) : ComponentObjectInterface { if (!method_exists($this->class, $this->method)) { $message = 'Annotation %s refers to non-existent method %s::%s()'; @@ -163,17 +163,17 @@ private function getMethodAnnotation(SimpleAnnotationReader $annotationReader) : } /** - * Proxy to `SimpleAnnotationReader::getPropertyAnnotation()` with validation + * Proxy to `AnnotationReader::getPropertyAnnotation()` with validation * - * @param SimpleAnnotationReader $annotationReader + * @param AnnotationReader $annotationReader * * @return ComponentObjectInterface * * @throws InvalidReferenceException * - * @see SimpleAnnotationReader::getPropertyAnnotation() + * @see AnnotationReader::getPropertyAnnotation() */ - private function getPropertyAnnotation(SimpleAnnotationReader $annotationReader) : ComponentObjectInterface + private function getPropertyAnnotation(AnnotationReader $annotationReader) : ComponentObjectInterface { if (!property_exists($this->class, $this->property)) { $message = 'Annotation %s refers to non-existent property %s::$%s'; @@ -198,17 +198,17 @@ private function getPropertyAnnotation(SimpleAnnotationReader $annotationReader) } /** - * Proxy to `SimpleAnnotationReader::getClassAnnotation()` with validation + * Proxy to `AnnotationReader::getClassAnnotation()` with validation * - * @param SimpleAnnotationReader $annotationReader + * @param AnnotationReader $annotationReader * * @return ComponentObjectInterface * * @throws InvalidReferenceException * - * @see SimpleAnnotationReader::getClassAnnotation() + * @see AnnotationReader::getClassAnnotation() */ - private function getClassAnnotation(SimpleAnnotationReader $annotationReader) : ComponentObjectInterface + private function getClassAnnotation(AnnotationReader $annotationReader) : ComponentObjectInterface { if (!class_exists($this->class)) { $message = 'Annotation %s refers to non-existent class %s'; diff --git a/src/Middleware/RequestBodyValidationMiddleware.php b/src/Middleware/RequestBodyValidationMiddleware.php index 97af227..3e3437c 100644 --- a/src/Middleware/RequestBodyValidationMiddleware.php +++ b/src/Middleware/RequestBodyValidationMiddleware.php @@ -45,6 +45,11 @@ class RequestBodyValidationMiddleware implements MiddlewareInterface { + /** + * @var bool + */ + private $useCache = false; + /** * Constructor of the class * @@ -59,6 +64,14 @@ public function __construct() } } + /** + * @return void + */ + public function useCache() : void + { + $this->useCache = true; + } + /** * {@inheritDoc} * @@ -75,27 +88,7 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface } /** - * Tries to determine the reflection of an object that contains the `@OpenApi\Operation()` annotation - * - * @param ServerRequestInterface $request - * - * @return null|ReflectionClass - */ - protected function fetchOperationSource(ServerRequestInterface $request) : ?ReflectionClass - { - $route = $request->getAttribute(Route::ATTR_NAME_FOR_ROUTE); - - if ($route instanceof RouteInterface) { - return new ReflectionClass( - $route->getRequestHandler() - ); - } - - return null; - } - - /** - * Tries to determine a MIME type for the request body + * Tries to determine a media type for the request body * * @param ServerRequestInterface $request * @@ -103,7 +96,7 @@ protected function fetchOperationSource(ServerRequestInterface $request) : ?Refl * * @link https://tools.ietf.org/html/rfc7231#section-3.1.1.1 */ - protected function fetchMimeType(ServerRequestInterface $request) : string + protected function fetchMediaType(ServerRequestInterface $request) : string { $result = $request->getHeaderLine('Content-Type'); $semicolon = strpos($result, ';'); @@ -115,29 +108,6 @@ protected function fetchMimeType(ServerRequestInterface $request) : string return $result; } - /** - * Tries to determine a JSON schema for the request body - * - * @param ServerRequestInterface $request - * - * @return mixed - */ - protected function fetchJsonSchema(ServerRequestInterface $request) - { - $operationSource = $this->fetchOperationSource($request); - - // it is not recommended to use this middleware globally... - if (null === $operationSource) { - return null; - } - - $builder = new JsonSchemaBuilder($operationSource); - - $mimeType = $this->fetchMimeType($request); - - return $builder->forRequestBody($mimeType); - } - /** * Validates the given request * @@ -150,8 +120,21 @@ protected function fetchJsonSchema(ServerRequestInterface $request) */ protected function validate(ServerRequestInterface $request) : void { + $route = $request->getAttribute(Route::ATTR_NAME_FOR_ROUTE); + if (!($route instanceof RouteInterface)) { + return; + } + + $operationSource = new ReflectionClass($route->getRequestHandler()); + $jsonSchemaBuilder = new JsonSchemaBuilder($operationSource); + + if ($this->useCache) { + $jsonSchemaBuilder->useCache(); + } + try { - $jsonSchema = $this->fetchJsonSchema($request); + $mediaType = $this->fetchMediaType($request); + $jsonSchema = $jsonSchemaBuilder->forRequestBody($mediaType); } catch (LocalUnsupportedMediaTypeException $e) { throw new UnsupportedMediaTypeException($e->getMessage(), [ 'type' => $e->getType(), diff --git a/src/Middleware/RequestQueryValidationMiddleware.php b/src/Middleware/RequestQueryValidationMiddleware.php index e1b9c48..6bbe7c4 100644 --- a/src/Middleware/RequestQueryValidationMiddleware.php +++ b/src/Middleware/RequestQueryValidationMiddleware.php @@ -41,6 +41,11 @@ class RequestQueryValidationMiddleware implements MiddlewareInterface { + /** + * @var bool + */ + private $useCache = false; + /** * Constructor of the class * @@ -55,6 +60,14 @@ public function __construct() } } + /** + * @return void + */ + public function useCache() : void + { + $this->useCache = true; + } + /** * {@inheritDoc} * @@ -82,15 +95,18 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface protected function validate(ServerRequestInterface $request) : void { $route = $request->getAttribute(Route::ATTR_NAME_FOR_ROUTE); - if (!($route instanceof RouteInterface)) { return; } $operationSource = new ReflectionClass($route->getRequestHandler()); $jsonSchemaBuilder = new JsonSchemaBuilder($operationSource); - $jsonSchema = $jsonSchemaBuilder->forRequestQueryParams(); + if ($this->useCache) { + $jsonSchemaBuilder->useCache(); + } + + $jsonSchema = $jsonSchemaBuilder->forRequestQueryParams(); if (null === $jsonSchema) { return; } diff --git a/src/Utility/JsonSchemaBuilder.php b/src/Utility/JsonSchemaBuilder.php index 6432aa1..54894ca 100644 --- a/src/Utility/JsonSchemaBuilder.php +++ b/src/Utility/JsonSchemaBuilder.php @@ -14,7 +14,10 @@ /** * Import classes */ +use Doctrine\Common\Annotations\CachedReader; +use Doctrine\Common\Annotations\Reader as AnnotationReader; use Doctrine\Common\Annotations\SimpleAnnotationReader; +use Doctrine\Common\Cache\ApcuCache; use Sunrise\Http\Router\OpenApi\Annotation\OpenApi\Operation; use Sunrise\Http\Router\OpenApi\Annotation\OpenApi\ParameterReference; use Sunrise\Http\Router\OpenApi\Annotation\OpenApi\RequestBodyReference; @@ -22,6 +25,7 @@ use Sunrise\Http\Router\OpenApi\Exception\UnsupportedMediaTypeException; use Sunrise\Http\Router\OpenApi\OpenApi; use ReflectionClass; +use RuntimeException; /** * Import functions @@ -29,6 +33,7 @@ use function array_keys; use function array_walk; use function array_walk_recursive; +use function extension_loaded; use function str_replace; /** @@ -50,10 +55,15 @@ class JsonSchemaBuilder private $operationSource; /** - * @var SimpleAnnotationReader + * @var AnnotationReader */ private $annotationReader; + /** + * @var bool + */ + private $useCache = false; + /** * Constructor of the class * @@ -67,6 +77,42 @@ public function __construct(ReflectionClass $operationSource) $this->annotationReader->addNamespace(OpenApi::ANNOTATIONS_NAMESPACE); } + /** + * @return ReflectionClass + */ + public function getOperationSource() : ReflectionClass + { + return $this->operationSource; + } + + /** + * @return AnnotationReader + */ + public function getAnnotationReader() : AnnotationReader + { + return $this->annotationReader; + } + + /** + * @return void + * + * @throws RuntimeException + */ + public function useCache() : void + { + if ($this->useCache) { + throw new RuntimeException('Cache already used.'); + } + + if (!extension_loaded('apcu')) { + throw new RuntimeException('APCu extension required.'); + } + + $this->useCache = true; + + $this->annotationReader = new CachedReader($this->annotationReader, new ApcuCache(__CLASS__), false); + } + /** * Builds a JSON schema for a request query parameters * diff --git a/tests/Middleware/RequestQueryValidationMiddlewareTest.php b/tests/Middleware/RequestQueryValidationMiddlewareTest.php new file mode 100644 index 0000000..a2ec0c1 --- /dev/null +++ b/tests/Middleware/RequestQueryValidationMiddlewareTest.php @@ -0,0 +1,165 @@ +assertInstanceOf(MiddlewareInterface::class, $middleware); + } + + /** + * @return void + */ + public function testProcess() : void + { + $route = $this->createRoute(); + + $request = $this->createServerRequest([ + Route::ATTR_NAME_FOR_ROUTE => $route, + ], [ + 'foo' => '1', + 'bar' => 'a', + ]); + + $middleware = new RequestQueryValidationMiddleware(); + $response = $middleware->process($request, $route->getRequestHandler()); + + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * @return void + */ + public function testProcessWithEmptyRequest() : void + { + $route = $this->createRoute(); + $request = $this->createServerRequest(); + + $middleware = new RequestQueryValidationMiddleware(); + $response = $middleware->process($request, $route->getRequestHandler()); + + $this->assertSame(200, $response->getStatusCode()); + } + + /** + * @return void + */ + public function testProcessWithInvalidPayload() : void + { + $route = $this->createRoute(); + + $request = $this->createServerRequest([ + Route::ATTR_NAME_FOR_ROUTE => $route, + ], [ + 'foo' => 'a', + 'bar' => '1', + ]); + + $middleware = new RequestQueryValidationMiddleware(); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('The request query parameters is not valid for this resource.'); + + $middleware->process($request, $route->getRequestHandler()); + } + + /** + * @return RouteInterface + */ + private function createRoute() : RouteInterface + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + + /** + * @OpenApi\Operation( + * parameters={ + * @OpenApi\Parameter( + * in="query", + * name="foo", + * required=true, + * schema=@OpenApi\Schema( + * type="string", + * pattern="^\d+$", + * ), + * ), + * @OpenApi\Parameter( + * in="query", + * name="bar", + * required=true, + * schema=@OpenApi\Schema( + * type="string", + * pattern="^\w+$", + * ), + * ), + * }, + * responses={ + * 200=@OpenApi\Response( + * description="OK", + * ), + * }, + * ) + */ + $requestHandler = new class ($response) implements RequestHandlerInterface + { + private $response; + + public function __construct($response) + { + $this->response = $response; + } + + public function handle(ServerRequestInterface $request) : ResponseInterface + { + return $this->response; + } + }; + + return $this->createConfiguredMock(RouteInterface::class, [ + 'getRequestHandler' => $requestHandler, + ]); + } + + /** + * @param array $attributes + * @param array $queryParams + * + * @return ServerRequestInterface + */ + private function createServerRequest($attributes = [], $queryParams = []) : ServerRequestInterface + { + $mock = $this->createMock(ServerRequestInterface::class); + + $mock->method('getAttribute')->will($this->returnCallback(function ($key) use ($attributes) { + return $attributes[$key] ?? null; + })); + + $mock->method('getQueryParams')->willReturn($queryParams); + + return $mock; + } +} diff --git a/tests/Utility/JsonSchemaBuilderTest.php b/tests/Utility/JsonSchemaBuilderTest.php index 338c92d..4410bae 100644 --- a/tests/Utility/JsonSchemaBuilderTest.php +++ b/tests/Utility/JsonSchemaBuilderTest.php @@ -5,10 +5,13 @@ /** * Import classes */ +use Doctrine\Common\Annotations\CachedReader; +use Doctrine\Common\Annotations\SimpleAnnotationReader; use PHPUnit\Framework\TestCase; use Sunrise\Http\Router\OpenApi\Exception\UnsupportedMediaTypeException; use Sunrise\Http\Router\OpenApi\Utility\JsonSchemaBuilder; use ReflectionClass; +use RuntimeException; /** * JsonSchemaBuilderTest @@ -85,6 +88,43 @@ class JsonSchemaBuilderTest extends TestCase */ private $baz; + /** + * @return void + */ + public function testConstructor() : void + { + $class = new class + { + }; + + $classReflection = new ReflectionClass($class); + $jsonSchemaBuilder = new JsonSchemaBuilder($classReflection); + + $this->assertSame($classReflection, $jsonSchemaBuilder->getOperationSource()); + $this->assertInstanceOf(SimpleAnnotationReader::class, $jsonSchemaBuilder->getAnnotationReader()); + } + + /** + * @return void + */ + public function testUseCache() : void + { + $class = new class + { + }; + + $classReflection = new ReflectionClass($class); + $jsonSchemaBuilder = new JsonSchemaBuilder($classReflection); + $jsonSchemaBuilder->useCache(); + + $this->assertInstanceOf(CachedReader::class, $jsonSchemaBuilder->getAnnotationReader()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cache already used.'); + + $jsonSchemaBuilder->useCache(); + } + /** * @return void */ From f2085ac7fe37e8a7adc01a7a718c714d541e4497 Mon Sep 17 00:00:00 2001 From: Anatoly Nekhay Date: Mon, 27 Jul 2020 05:54:28 +0300 Subject: [PATCH 2/3] apcu is required for tests --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index dde67ac..e71d557 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ matrix: fast_finish: true before_install: + - travis_retry pecl install apcu - travis_retry composer self-update install: From 131b6e04371a11da3897db2bc91792661345d9ec Mon Sep 17 00:00:00 2001 From: Anatoly Nekhay Date: Mon, 27 Jul 2020 06:00:11 +0300 Subject: [PATCH 3/3] apcu is required for tests --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index e71d557..260a5f4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,7 @@ matrix: fast_finish: true before_install: - - travis_retry pecl install apcu + - travis_retry yes '' | pecl install -f apcu - travis_retry composer self-update install: