diff --git a/README.md b/README.md index 45deee4..3684ec4 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,14 @@ --- +## Important to understanding + +* [OpenAPI Specification](https://github.com/OAI/OpenAPI-Specification/blob/6ba1577240b79c9f613c2ea8d745c6ef6c832e50/versions/3.0.2.md) + ## Installation ```bash -composer require 'sunrise/http-router-openapi:^2.0' +composer require 'sunrise/http-router-openapi:^2.1' ``` ## QuickStart @@ -21,7 +25,7 @@ composer require 'sunrise/http-router-openapi:^2.0' use Psr\SimpleCache\CacheInterface; use Sunrise\Http\Router\OpenApi\Object\Info; use Sunrise\Http\Router\OpenApi\OpenApi; -use Sunrise\Http\Router\Router; +use Sunrise\Http\Router\OpenApi\RouteInterface; $openapi = new OpenApi(new Info('Acme', '1.0.0')); @@ -30,7 +34,11 @@ $openapi = new OpenApi(new Info('Acme', '1.0.0')); $openapi->setCache($cache); // Passing all routes to the openapi object: -/** @var Router $router */ +/** @var RouteInterface[] $routes */ +$openapi->addRoute(...$routes); + +// When using Sunrise Router: +/** @var \Sunrise\Http\Router\Router $router */ $openapi->addRoute(...$router->getRoutes()); ``` @@ -41,7 +49,7 @@ $openapi->addRoute(...$router->getRoutes()); $openapi->toJson(); // Converting the openapi object to YAML document: $openapi->toYaml(); -// Converting the openapi object to an array +// Converting the openapi object to an array: $openapi->toArray(); ``` @@ -66,7 +74,6 @@ $openapi->getResponseBodyJsonSchema(); ```php use Sunrise\Http\Router\OpenApi\Middleware\RequestValidationMiddleware; use Sunrise\Http\Router\OpenApi\OpenApi; -use Sunrise\Http\Router\Route; /** @var OpenApi $openapi */ $middleware = new RequestValidationMiddleware($openapi); diff --git a/composer.json b/composer.json index 5a26af8..742a1b3 100644 --- a/composer.json +++ b/composer.json @@ -23,13 +23,13 @@ ], "require": { "php": "^7.1|^8.0", - "sunrise/http-router": "^2.10", "doctrine/annotations": "^1.6" }, "require-dev": { "phpunit/phpunit": "7.5.20|9.5.0", "sunrise/coding-standard": "1.0.0", "sunrise/http-factory": "1.1.0", + "sunrise/http-router": "^2.11", "symfony/console": "^4.4", "justinrainbow/json-schema": "5.2.10" }, @@ -47,6 +47,10 @@ "test": [ "phpcs", "XDEBUG_MODE=coverage phpunit --coverage-text" + ], + "build": [ + "phpdoc -d src/ -t phpdoc/", + "XDEBUG_MODE=coverage phpunit --coverage-html coverage/" ] } } diff --git a/src/AbstractAnnotation.php b/src/AbstractAnnotation.php index 7b755ff..4f1a1ed 100644 --- a/src/AbstractAnnotation.php +++ b/src/AbstractAnnotation.php @@ -106,4 +106,36 @@ public function collectReferencedObjects(AnnotationReader $annotationReader) : a return $this->referencedObjects; } + + /** + * Serializes the object + * + * @return array + */ + public function __serialize() : array + { + // reflector can't be serialized... + $this->holder = null; + + $data = []; + foreach ($this as $key => $value) { + $data[$key] = $value; + } + + return $data; + } + + /** + * Unserializes the object + * + * @param array $data + * + * @return void + */ + public function __unserialize(array $data) : void + { + foreach ($data as $key => $value) { + $this->$key = $value; + } + } } diff --git a/src/Annotation/OpenApi/Parameter.php b/src/Annotation/OpenApi/Parameter.php index 005ae9c..25f437f 100644 --- a/src/Annotation/OpenApi/Parameter.php +++ b/src/Annotation/OpenApi/Parameter.php @@ -29,7 +29,7 @@ * * @link https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#parameter-object * - * @final Don't use the object outside of the package. + * @final */ class Parameter extends AbstractAnnotation implements ParameterInterface, ComponentInterface { @@ -147,7 +147,7 @@ class Parameter extends AbstractAnnotation implements ParameterInterface, Compon /** * {@inheritdoc} */ - public function getComponentName() : string + final public function getComponentName() : string { return 'parameters'; } @@ -155,7 +155,7 @@ public function getComponentName() : string /** * {@inheritdoc} */ - public function getReferenceName() : string + final public function getReferenceName() : string { return $this->refName ?? spl_object_hash($this); } diff --git a/src/Bridge/Sunrise/SunriseRouteProxy.php b/src/Bridge/Sunrise/SunriseRouteProxy.php new file mode 100644 index 0000000..33e10c7 --- /dev/null +++ b/src/Bridge/Sunrise/SunriseRouteProxy.php @@ -0,0 +1,127 @@ + + * @copyright Copyright (c) 2019, Anatoly Fenric + * @license https://github.com/sunrise-php/http-router-openapi/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router-openapi + */ + +namespace Sunrise\Http\Router\OpenApi\Bridge\Sunrise; + +/** + * Import classes + */ +use Sunrise\Http\Router\OpenApi\RouteInterface as OpenapiRouteInterface; +use Sunrise\Http\Router\RequestHandler\CallableRequestHandler as SunriseCallableRequestHandler; +use Sunrise\Http\Router\RouteInterface as SunriseRouteInterface; +use ReflectionClass; +use ReflectionMethod; +use Reflector; + +/** + * Import functions + */ +use function is_array; +use function Sunrise\Http\Router\path_parse; +use function Sunrise\Http\Router\path_plain; + +/** + * Sunrise Route Proxy + */ +final class SunriseRouteProxy implements OpenapiRouteInterface +{ + + /** + * Proxied Sunrise Route + * + * @var SunriseRouteInterface + */ + private $route; + + /** + * Constructor of the class + * + * @param SunriseRouteInterface $route + */ + public function __construct(SunriseRouteInterface $route) + { + $this->route = $route; + } + + /** + * {@inheritdoc} + */ + public function getName() : string + { + return $this->route->getName(); + } + + /** + * {@inheritdoc} + */ + public function getMethods() : array + { + return $this->route->getMethods(); + } + + /** + * {@inheritdoc} + */ + public function getPlainPath() : string + { + return path_plain($this->route->getPath()); + } + + /** + * {@inheritdoc} + */ + public function getPathAttributes() : array + { + return path_parse($this->route->getPath()); + } + + /** + * {@inheritdoc} + */ + public function getSummary() : string + { + return $this->route->getSummary(); + } + + /** + * {@inheritdoc} + */ + public function getDescription() : string + { + return $this->route->getDescription(); + } + + /** + * {@inheritdoc} + */ + public function getTags() : array + { + return $this->route->getTags(); + } + + /** + * {@inheritdoc} + */ + public function getHolder() : ?Reflector + { + $handler = $this->route->getRequestHandler(); + if (!($handler instanceof SunriseCallableRequestHandler)) { + return new ReflectionClass($handler); + } + + $callback = $handler->getCallback(); + if (is_array($callback)) { + return new ReflectionMethod(...$callback); + } + + return null; + } +} diff --git a/src/Command/GenerateJsonSchemaCommand.php b/src/Command/GenerateJsonSchemaCommand.php index 689e22d..c392605 100644 --- a/src/Command/GenerateJsonSchemaCommand.php +++ b/src/Command/GenerateJsonSchemaCommand.php @@ -14,6 +14,7 @@ /** * Import classes */ +use RuntimeException; use Sunrise\Http\Router\OpenApi\OpenApi; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -25,6 +26,7 @@ * Import functions */ use function json_encode; +use function sprintf; /** * Import constants @@ -34,29 +36,47 @@ use const JSON_UNESCAPED_UNICODE; /** - * GenerateJsonSchemaCommand + * This command generates JSON schema + * + * If you cannot pass the openapi to the constructor, + * or your architecture has problems with autowiring, + * then inherit this class and override the getOpenapi method. + * + * @since 2.0.0 */ -final class GenerateJsonSchemaCommand extends Command +class GenerateJsonSchemaCommand extends Command { /** - * Openapi instance + * {@inheritdoc} + */ + protected static $defaultName = 'router:generate-json-schema'; + + /** + * {@inheritdoc} + */ + protected static $defaultDescription = 'Generates JSON schema'; + + /** + * The openapi instance * - * @var OpenApi + * @var OpenApi|null */ private $openapi; /** - * {@inheritdoc} + * Constructor of the class * - * @param OpenApi $openapi - * @param string|null $name + * @param OpenApi|null $openapi */ - public function __construct(OpenApi $openapi, ?string $name = null) + public function __construct(?OpenApi $openapi = null) { $this->openapi = $openapi; - parent::__construct($name ?? 'router:generate-json-schema'); + parent::__construct(); + + $this->setName(static::$defaultName); + $this->setDescription(static::$defaultDescription); $this->addArgument( 'operation-id', @@ -80,47 +100,60 @@ public function __construct(OpenApi $openapi, ?string $name = null) } /** - * {@inheritdoc} + * Gets the openapi instance * - * @param InputInterface $input - * @param OutputInterface $output + * @return OpenApi * - * @return int Exit code + * @throws RuntimeException + * If the class doesn't contain the openapi instance. */ - public function execute(InputInterface $input, OutputInterface $output) : int + protected function getOpenapi() : OpenApi { + if (null === $this->openapi) { + throw new RuntimeException(sprintf( + 'The %2$s() method MUST return the %1$s class instance. ' . + 'Pass the %1$s class instance to the constructor, or override the %2$s() method.', + OpenApi::class, + __METHOD__ + )); + } + + return $this->openapi; + } + + /** + * {@inheritdoc} + */ + final protected function execute(InputInterface $input, OutputInterface $output) : int + { + $openapi = $this->getOpenapi(); $operationId = $input->getArgument('operation-id'); $operationSection = $input->getArgument('operation-section'); - $contentType = $input->getOption('content-type'); switch ($operationSection) { case 'cookie': - $output->writeln($this->jsonify($this->openapi->getRequestCookieJsonSchema($operationId))); - return 0; + $jsonSchema = $openapi->getRequestCookieJsonSchema($operationId); + break; case 'header': - $output->writeln($this->jsonify($this->openapi->getRequestHeaderJsonSchema($operationId))); - return 0; + $jsonSchema = $openapi->getRequestHeaderJsonSchema($operationId); + break; case 'query': - $output->writeln($this->jsonify($this->openapi->getRequestQueryJsonSchema($operationId))); - return 0; + $jsonSchema = $openapi->getRequestQueryJsonSchema($operationId); + break; case 'body': - $output->writeln($this->jsonify($this->openapi->getRequestBodyJsonSchema($operationId, $contentType))); - return 0; + $jsonSchema = $openapi->getRequestBodyJsonSchema($operationId, $input->getOption('content-type')); + break; default: $output->writeln('Unknown operation section ("cookie", "header", "query", "body")'); return 1; } - } - /** - * Jsonifies the given data and returns the result - * - * @param array|null $data - * - * @return string - */ - private function jsonify(?array $data) : string - { - return json_encode($data, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE); + if (!isset($jsonSchema)) { + $output->writeln('Not enough data to build JSON schema'); + return 1; + } + + $output->writeln(json_encode($jsonSchema, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE)); + return 0; } } diff --git a/src/Command/GenerateOpenapiDocumentCommand.php b/src/Command/GenerateOpenapiDocumentCommand.php index 36f7396..7e68d6b 100644 --- a/src/Command/GenerateOpenapiDocumentCommand.php +++ b/src/Command/GenerateOpenapiDocumentCommand.php @@ -14,6 +14,7 @@ /** * Import classes */ +use RuntimeException; use Sunrise\Http\Router\OpenApi\OpenApi; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputOption; @@ -21,29 +22,52 @@ use Symfony\Component\Console\Output\OutputInterface; /** - * GenerateOpenapiDocumentCommand + * Import functions */ -final class GenerateOpenapiDocumentCommand extends Command +use function sprintf; + +/** + * This command generates OpenAPI document + * + * If you cannot pass the openapi to the constructor, + * or your architecture has problems with autowiring, + * then inherit this class and override the getOpenapi method. + * + * @since 2.0.0 + */ +class GenerateOpenapiDocumentCommand extends Command { /** - * Openapi instance + * {@inheritdoc} + */ + protected static $defaultName = 'router:generate-openapi-document'; + + /** + * {@inheritdoc} + */ + protected static $defaultDescription = 'Generates OpenAPI document'; + + /** + * The openapi instance * - * @var OpenApi + * @var OpenApi|null */ private $openapi; /** - * {@inheritdoc} + * Constructor of the class * - * @param OpenApi $openapi - * @param string|null $name + * @param OpenApi|null $openapi */ - public function __construct(OpenApi $openapi, ?string $name = null) + public function __construct(?OpenApi $openapi = null) { $this->openapi = $openapi; - parent::__construct($name ?? 'router:generate-openapi-document'); + parent::__construct(); + + $this->setName(static::$defaultName); + $this->setDescription(static::$defaultDescription); $this->addOption( 'output-format', @@ -55,21 +79,40 @@ public function __construct(OpenApi $openapi, ?string $name = null) } /** - * {@inheritdoc} + * Gets the openapi instance * - * @param InputInterface $input - * @param OutputInterface $output + * @return OpenApi * - * @return int Exit code + * @throws RuntimeException + * If the class doesn't contain the openapi instance. + */ + protected function getOpenapi() : OpenApi + { + if (null === $this->openapi) { + throw new RuntimeException(sprintf( + 'The %2$s() method MUST return the %1$s class instance. ' . + 'Pass the %1$s class instance to the constructor, or override the %2$s() method.', + OpenApi::class, + __METHOD__ + )); + } + + return $this->openapi; + } + + /** + * {@inheritdoc} */ - public function execute(InputInterface $input, OutputInterface $output) : int + final protected function execute(InputInterface $input, OutputInterface $output) : int { + $openapi = $this->getOpenapi(); + switch ($input->getOption('output-format')) { case 'json': - $output->writeln($this->openapi->toJson()); + $output->writeln($openapi->toJson()); return 0; case 'yaml': - $output->writeln($this->openapi->toYaml()); + $output->writeln($openapi->toYaml()); return 0; default: $output->writeln('Unknown output format ("json", "yaml").'); diff --git a/src/Middleware/RequestValidationMiddleware.php b/src/Middleware/RequestValidationMiddleware.php index a6d382e..b65ae88 100644 --- a/src/Middleware/RequestValidationMiddleware.php +++ b/src/Middleware/RequestValidationMiddleware.php @@ -24,55 +24,91 @@ use Sunrise\Http\Router\Exception\UnsupportedMediaTypeException; use Sunrise\Http\Router\OpenApi\OpenApi; use Sunrise\Http\Router\RouteInterface; -use Sunrise\Http\Router\Route; use RuntimeException; /** * Import functions */ use function class_exists; +use function sprintf; use function strpos; use function substr; /** - * RequestValidationMiddleware + * Validates the given request using all possible JSON schemes + * + * If you cannot pass the openapi to the constructor, + * or your architecture has problems with autowiring, + * then inherit this class and override the getOpenapi method. + * + * @since 2.0.0 */ -final class RequestValidationMiddleware implements MiddlewareInterface +class RequestValidationMiddleware implements MiddlewareInterface { /** - * Openapi instance + * Default validation options + * + * @var int * - * @var OpenApi + * @link https://github.com/justinrainbow/json-schema/tree/4c74da50b0ca56469f5c7b1903ab5f2c7bf68f4d#configuration-options */ - private $openapi; + public const DEFAULT_VALIDATION_OPTIONS = Constraint::CHECK_MODE_TYPE_CAST|Constraint::CHECK_MODE_COERCE_TYPES; /** - * Validator instance + * The openapi instance * - * @var Validator + * @var OpenApi|null */ - private $validator; + private $openapi; /** - * Validation options - * - * @var int + * Cookie validation options * - * @link https://github.com/justinrainbow/json-schema/tree/4c74da50b0ca56469f5c7b1903ab5f2c7bf68f4d#configuration-options + * @var int|null */ private $cookieValidationOptions; + + /** + * Header validation options + * + * @var int|null + */ private $headerValidationOptions; + + /** + * Query validation options + * + * @var int|null + */ private $queryValidationOptions; + + /** + * Body validation options + * + * @var int|null + */ private $bodyValidationOptions; /** * Constructor of the class * - * @param OpenApi $openapi + * @param OpenApi|null $openapi + * @param int|null $cookieValidationOptions + * @param int|null $headerValidationOptions + * @param int|null $queryValidationOptions + * @param int|null $bodyValidationOptions + * + * @throws RuntimeException + * If the "justinrainbow/json-schema" isn't installed. */ - public function __construct(OpenApi $openapi) - { + public function __construct( + ?OpenApi $openapi = null, + ?int $cookieValidationOptions = self::DEFAULT_VALIDATION_OPTIONS, + ?int $headerValidationOptions = self::DEFAULT_VALIDATION_OPTIONS, + ?int $queryValidationOptions = self::DEFAULT_VALIDATION_OPTIONS, + ?int $bodyValidationOptions = self::DEFAULT_VALIDATION_OPTIONS + ) { if (!class_exists(Validator::class)) { // @codeCoverageIgnoreStart throw new RuntimeException('To use request validation, install the "justinrainbow/json-schema".'); @@ -80,12 +116,33 @@ public function __construct(OpenApi $openapi) } $this->openapi = $openapi; - $this->validator = new Validator(); - $this->cookieValidationOptions = Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES; - $this->headerValidationOptions = Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES; - $this->queryValidationOptions = Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES; - $this->bodyValidationOptions = Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES; + $this->cookieValidationOptions = $cookieValidationOptions; + $this->headerValidationOptions = $headerValidationOptions; + $this->queryValidationOptions = $queryValidationOptions; + $this->bodyValidationOptions = $bodyValidationOptions; + } + + /** + * Gets the openapi instance + * + * @return OpenApi + * + * @throws RuntimeException + * If the class doesn't contain the openapi instance. + */ + protected function getOpenapi() : OpenApi + { + if (null === $this->openapi) { + throw new RuntimeException(sprintf( + 'The %2$s() method MUST return the %1$s class instance. ' . + 'Pass the %1$s class instance to the constructor, or override the %2$s() method.', + OpenApi::class, + __METHOD__ + )); + } + + return $this->openapi; } /** @@ -102,13 +159,15 @@ public function __construct(OpenApi $openapi) * @throws UnsupportedMediaTypeException * If the request body contains an unsupported type. */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface + final public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface { - $route = $request->getAttribute(Route::ATTR_NAME_FOR_ROUTE); + $route = $request->getAttribute(RouteInterface::ATTR_ROUTE); if (!($route instanceof RouteInterface)) { return $handler->handle($request); } + $openapi = $this->getOpenapi(); + $validator = new Validator(); $operationId = $route->getName(); // https://tools.ietf.org/html/rfc7231#section-3.1.1.1 @@ -117,24 +176,52 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface $contentType = substr($contentType, 0, $semicolon); } - $cookieJsonSchema = $this->openapi->getRequestCookieJsonSchema($operationId); - if (isset($cookieJsonSchema)) { - $this->validateRequestCookie($request, $cookieJsonSchema); + if (isset($this->cookieValidationOptions)) { + $jsonSchema = $openapi->getRequestCookieJsonSchema($operationId); + if (isset($jsonSchema)) { + $request = $this->validateRequestCookie( + $request, + $jsonSchema, + $validator, + $this->cookieValidationOptions + ); + } } - $headerJsonSchema = $this->openapi->getRequestHeaderJsonSchema($operationId); - if (isset($headerJsonSchema)) { - $this->validateRequestHeader($request, $headerJsonSchema); + if (isset($this->headerValidationOptions)) { + $jsonSchema = $openapi->getRequestHeaderJsonSchema($operationId); + if (isset($jsonSchema)) { + $request = $this->validateRequestHeader( + $request, + $jsonSchema, + $validator, + $this->headerValidationOptions + ); + } } - $queryJsonSchema = $this->openapi->getRequestQueryJsonSchema($operationId); - if (isset($queryJsonSchema)) { - $this->validateRequestQuery($request, $queryJsonSchema); + if (isset($this->queryValidationOptions)) { + $jsonSchema = $openapi->getRequestQueryJsonSchema($operationId); + if (isset($jsonSchema)) { + $request = $this->validateRequestQuery( + $request, + $jsonSchema, + $validator, + $this->queryValidationOptions + ); + } } - $bodyJsonSchema = $this->openapi->getRequestBodyJsonSchema($operationId, $contentType); - if (isset($bodyJsonSchema)) { - $this->validateRequestBody($request, $bodyJsonSchema); + if (isset($this->bodyValidationOptions)) { + $jsonSchema = $openapi->getRequestBodyJsonSchema($operationId, $contentType); + if (isset($jsonSchema)) { + $request = $this->validateRequestBody( + $request, + $jsonSchema, + $validator, + $this->bodyValidationOptions + ); + } } return $handler->handle($request); @@ -145,23 +232,30 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface * * @param ServerRequestInterface $request * @param array $jsonSchema + * @param Validator $validator + * @param int $validationOptions * - * @return void + * @return ServerRequestInterface + * New request with changed data. * * @throws BadRequestException - * If the request cookie isn't valid. + * If the validation data isn't valid. */ - private function validateRequestCookie(ServerRequestInterface $request, array $jsonSchema) : void - { + private function validateRequestCookie( + ServerRequestInterface $request, + array $jsonSchema, + Validator $validator, + int $validationOptions + ) : ServerRequestInterface { $cookies = $request->getCookieParams(); - - $this->validator->validate($cookies, $jsonSchema, $this->cookieValidationOptions); - if (!$this->validator->isValid()) { + $validator->validate($cookies, $jsonSchema, $validationOptions); + if (!$validator->isValid()) { throw new BadRequestException('The request cookie is not valid for this resource.', [ - 'jsonSchema' => $jsonSchema, - 'errors' => $this->validator->getErrors(), + 'errors' => $validator->getErrors(), ]); } + + return $request; } /** @@ -169,26 +263,34 @@ private function validateRequestCookie(ServerRequestInterface $request, array $j * * @param ServerRequestInterface $request * @param array $jsonSchema + * @param Validator $validator + * @param int $validationOptions * - * @return void + * @return ServerRequestInterface + * New request with changed data. * * @throws BadRequestException - * If the request header isn't valid. + * If the validation data isn't valid. */ - private function validateRequestHeader(ServerRequestInterface $request, array $jsonSchema) : void - { + private function validateRequestHeader( + ServerRequestInterface $request, + array $jsonSchema, + Validator $validator, + int $validationOptions + ) : ServerRequestInterface { $headers = []; foreach ($request->getHeaders() as $header => $_) { $headers[$header] = $request->getHeaderLine($header); } - $this->validator->validate($headers, $jsonSchema, $this->headerValidationOptions); - if (!$this->validator->isValid()) { + $validator->validate($headers, $jsonSchema, $validationOptions); + if (!$validator->isValid()) { throw new BadRequestException('The request header is not valid for this resource.', [ - 'jsonSchema' => $jsonSchema, - 'errors' => $this->validator->getErrors(), + 'errors' => $validator->getErrors(), ]); } + + return $request; } /** @@ -196,23 +298,30 @@ private function validateRequestHeader(ServerRequestInterface $request, array $j * * @param ServerRequestInterface $request * @param array $jsonSchema + * @param Validator $validator + * @param int $validationOptions * - * @return void + * @return ServerRequestInterface + * New request with changed data. * * @throws BadRequestException - * If the request query isn't valid. + * If the validation data isn't valid. */ - private function validateRequestQuery(ServerRequestInterface $request, array $jsonSchema) : void - { + private function validateRequestQuery( + ServerRequestInterface $request, + array $jsonSchema, + Validator $validator, + int $validationOptions + ) : ServerRequestInterface { $query = $request->getQueryParams(); - - $this->validator->validate($query, $jsonSchema, $this->queryValidationOptions); - if (!$this->validator->isValid()) { + $validator->validate($query, $jsonSchema, $validationOptions); + if (!$validator->isValid()) { throw new BadRequestException('The request query is not valid for this resource.', [ - 'jsonSchema' => $jsonSchema, - 'errors' => $this->validator->getErrors(), + 'errors' => $validator->getErrors(), ]); } + + return $request->withQueryParams($query); } /** @@ -220,22 +329,29 @@ private function validateRequestQuery(ServerRequestInterface $request, array $js * * @param ServerRequestInterface $request * @param array $jsonSchema + * @param Validator $validator + * @param int $validationOptions * - * @return void + * @return ServerRequestInterface + * New request with changed data. * * @throws BadRequestException - * If the request body isn't valid. + * If the validation data isn't valid. */ - private function validateRequestBody(ServerRequestInterface $request, array $jsonSchema) : void - { + private function validateRequestBody( + ServerRequestInterface $request, + array $jsonSchema, + Validator $validator, + int $validationOptions + ) : ServerRequestInterface { $body = $request->getParsedBody(); - - $this->validator->validate($body, $jsonSchema, $this->bodyValidationOptions); - if (!$this->validator->isValid()) { + $validator->validate($body, $jsonSchema, $validationOptions); + if (!$validator->isValid()) { throw new BadRequestException('The request body is not valid for this resource.', [ - 'jsonSchema' => $jsonSchema, - 'errors' => $this->validator->getErrors(), + 'errors' => $validator->getErrors(), ]); } + + return $request->withParsedBody($body); } } diff --git a/src/Object/SecurityRequirement.php b/src/Object/SecurityRequirement.php index 7962f3a..5e6c771 100644 --- a/src/Object/SecurityRequirement.php +++ b/src/Object/SecurityRequirement.php @@ -21,19 +21,6 @@ * * Lists the required security schemes to execute this operation. * - * The name used for each property MUST correspond to a security scheme declared in the Security Schemes under the - * Components Object. Security Requirement Objects that contain multiple schemes require that all schemes MUST be - * satisfied for a request to be authorized. This enables support for scenarios where multiple query parameters or HTTP - * headers are required to convey security information. When a list of Security Requirement Objects is defined on the - * OpenAPI Object or Operation Object, only one of the Security Requirement Objects in the list needs to be satisfied to - * authorize the request. - * - * Each name MUST correspond to a security scheme which is declared in the Security Schemes under the Components Object. - * If the security scheme is of type "oauth2" or "openIdConnect", then the value is a list of scope names required for - * the execution, and the list MAY be empty if authorization does not require a specified scope. For other security - * scheme types, the array MAY contain a list of role names which are required for the execution, but are not otherwise - * defined or exchanged in-band. - * * @link https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#security-requirement-object */ final class SecurityRequirement implements ObjectInterface diff --git a/src/Object/SecurityScheme.php b/src/Object/SecurityScheme.php index acb8a73..356e79a 100644 --- a/src/Object/SecurityScheme.php +++ b/src/Object/SecurityScheme.php @@ -22,12 +22,6 @@ * * Defines a security scheme that can be used by the operations. * - * Supported schemes are HTTP authentication, an API key (either as a header, a cookie parameter or as a query - * parameter), mutual TLS (use of a client certificate), OAuth2's common flows (implicit, password, client credentials - * and authorization code) as defined in RFC6749, and OpenID Connect Discovery. Please note that as of 2020, the - * implicit flow is about to be deprecated by OAuth 2.0 Security Best Current Practice. Recommended for most use case is - * Authorization Code Grant flow with PKCE. - * * @link https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#security-scheme-object * @link https://datatracker.ietf.org/doc/html/rfc6749 * @link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-discovery-06 diff --git a/src/OpenApi.php b/src/OpenApi.php index 345a8fe..0a1a247 100644 --- a/src/OpenApi.php +++ b/src/OpenApi.php @@ -20,29 +20,26 @@ use Sunrise\Http\Router\OpenApi\Annotation\OpenApi\Operation; use Sunrise\Http\Router\OpenApi\Annotation\OpenApi\Parameter; use Sunrise\Http\Router\OpenApi\Annotation\OpenApi\Schema; +use Sunrise\Http\Router\OpenApi\Bridge\Sunrise\SunriseRouteProxy; use Sunrise\Http\Router\OpenApi\Object\ExternalDocumentation; use Sunrise\Http\Router\OpenApi\Object\Info; use Sunrise\Http\Router\OpenApi\Object\SecurityRequirement; use Sunrise\Http\Router\OpenApi\Object\Server; use Sunrise\Http\Router\OpenApi\Object\Tag; use Sunrise\Http\Router\OpenApi\Utility\OperationConverter; -use Sunrise\Http\Router\RequestHandler\CallableRequestHandler; -use Sunrise\Http\Router\RouteInterface; +use Sunrise\Http\Router\RouteInterface as SunriseRouteInterface; use ReflectionClass; -use ReflectionMethod; -use Reflector; use RuntimeException; +use TypeError; /** * Import functions */ -use function Sunrise\Http\Router\path_parse; -use function Sunrise\Http\Router\path_plain; use function extension_loaded; use function hash; -use function is_array; use function json_encode; use function strtolower; +use function sprintf; use function yaml_emit; /** @@ -52,7 +49,7 @@ use const JSON_UNESCAPED_SLASHES; use const JSON_UNESCAPED_UNICODE; use const YAML_ANY_BREAK; -use const YAML_ANY_ENCODING; +use const YAML_UTF8_ENCODING; /** * OAS OpenAPI Object @@ -347,13 +344,28 @@ public function setCache(?CacheInterface $cache) : void } /** - * @param RouteInterface ...$routes + * @param RouteInterface|SunriseRouteInterface ...$routes * * @return void + * + * @throws TypeError */ - public function addRoute(RouteInterface ...$routes) : void + public function addRoute(...$routes) : void { foreach ($routes as $route) { + // BC for Sunrise Router... + if ($route instanceof SunriseRouteInterface) { + $route = new SunriseRouteProxy($route); + } + + if (!($route instanceof RouteInterface)) { + throw new TypeError(sprintf( + 'The %s method expects an object that implements %s.', + __METHOD__, + RouteInterface::class + )); + } + $this->routes[$route->getName()] = $route; } } @@ -384,7 +396,7 @@ public function toYaml() : string // @codeCoverageIgnoreEnd } - return yaml_emit($this->toArray(), YAML_ANY_ENCODING, YAML_ANY_BREAK); + return yaml_emit($this->toArray(), YAML_UTF8_ENCODING, YAML_ANY_BREAK); } /** @@ -441,8 +453,10 @@ private function build() : array if (isset($this->routes[$operation->operationId])) { $this->addComponent(...$operation->getReferencedObjects()); - $path = path_plain($this->routes[$operation->operationId]->getPath()); - foreach ($this->routes[$operation->operationId]->getMethods() as $method) { + $path = $this->routes[$operation->operationId]->getPlainPath(); + $methods = $this->routes[$operation->operationId]->getMethods(); + + foreach ($methods as $method) { // https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#fixed-fields-7 $lcmethod = strtolower($method); @@ -488,7 +502,7 @@ private function getOperations() : array $operations = []; foreach ($this->routes as $route) { - $operation = $this->getRouteOperation($route, $annotationReader); + $operation = $this->fetchOperation($route, $annotationReader); if (isset($operation)) { $operations[$operation->operationId] = $operation; } @@ -498,19 +512,16 @@ private function getOperations() : array } /** - * Gets the given route operation - * - * TODO: Can be moved to a new abstract layer, - * which would make support for any router... + * Fetches an operation object from the given route * * @param RouteInterface $route * @param AnnotationReader $annotationReader * * @return Operation|null */ - private function getRouteOperation(RouteInterface $route, AnnotationReader $annotationReader) : ?Operation + private function fetchOperation(RouteInterface $route, AnnotationReader $annotationReader) : ?Operation { - $holder = $this->getRouteHolder($route); + $holder = $route->getHolder(); if (null === $holder) { return null; } @@ -523,35 +534,39 @@ private function getRouteOperation(RouteInterface $route, AnnotationReader $anno return null; } - // override the operation ID... $operation->operationId = $route->getName(); - if (empty($operation->summary) && !empty($summary = $route->getSummary())) { + if (null === $operation->summary && '' !== ($summary = $route->getSummary())) { $operation->summary = $summary; } - if (empty($operation->description) && !empty($description = $route->getDescription())) { + if (null === $operation->description && '' !== ($description = $route->getDescription())) { $operation->description = $description; } - if (empty($operation->tags) && !empty($tags = $route->getTags())) { + if (null === $operation->tags && [] !== ($tags = $route->getTags())) { $operation->tags = $tags; } - $attributes = path_parse($route->getPath()); + $attributes = $route->getPathAttributes(); foreach ($attributes as $attribute) { - $parameter = new Parameter(); - $parameter->in = 'path'; - $parameter->name = $attribute['name']; - $parameter->required = !$attribute['isOptional']; - - if (isset($attribute['pattern'])) { - $parameter->schema = new Schema(); - $parameter->schema->type = 'string'; - $parameter->schema->pattern = $attribute['pattern']; - } + if (isset($attribute['name'])) { + $parameter = new Parameter(); + $parameter->in = 'path'; + $parameter->name = $attribute['name']; + + if (isset($attribute['pattern'])) { + $parameter->schema = new Schema(); + $parameter->schema->type = 'string'; + $parameter->schema->pattern = $attribute['pattern']; + } + + if (isset($attribute['isOptional'])) { + $parameter->required = !$attribute['isOptional']; + } - $operation->parameters[] = $parameter; + $operation->parameters[] = $parameter; + } } $operation->setHolder($holder); @@ -559,31 +574,4 @@ private function getRouteOperation(RouteInterface $route, AnnotationReader $anno return $operation; } - - /** - * Gets the given route holder - * - * @param RouteInterface $route - * - * @return ReflectionClass|ReflectionMethod|null - */ - private function getRouteHolder(RouteInterface $route) : ?Reflector - { - $holder = $route->getHolder(); - if (isset($holder)) { - return $holder; - } - - $handler = $route->getRequestHandler(); - if (!($handler instanceof CallableRequestHandler)) { - return new ReflectionClass($handler); - } - - $callback = $handler->getCallback(); - if (is_array($callback)) { - return new ReflectionMethod(...$callback); - } - - return null; - } } diff --git a/src/RouteInterface.php b/src/RouteInterface.php new file mode 100644 index 0000000..4ce972b --- /dev/null +++ b/src/RouteInterface.php @@ -0,0 +1,90 @@ + + * @copyright Copyright (c) 2019, Anatoly Fenric + * @license https://github.com/sunrise-php/http-router-openapi/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router-openapi + */ + +namespace Sunrise\Http\Router\OpenApi; + +/** + * Import classes + */ +use ReflectionClass; +use ReflectionMethod; +use Reflector; + +/** + * RouteInterface + */ +interface RouteInterface +{ + + /** + * Gets the route name (aka ID) + * + * @return string + */ + public function getName() : string; + + /** + * Gets the route methods + * + * @return string[] + */ + public function getMethods() : array; + + /** + * Gets the route plain path + * + * @return string + */ + public function getPlainPath() : string; + + /** + * Gets the route path attributes + * + * ```php + * [ + * 'name' => 'foo', + * 'pattern' => '\w+', + * 'isOptional' => false, + * ] + * ``` + * + * @return array[] + */ + public function getPathAttributes() : array; + + /** + * Gets the route summary + * + * @return string + */ + public function getSummary() : string; + + /** + * Gets the route description + * + * @return string + */ + public function getDescription() : string; + + /** + * Gets the route tags + * + * @return string[] + */ + public function getTags() : array; + + /** + * Gets the route holder + * + * @return ReflectionClass|ReflectionMethod|null + */ + public function getHolder() : ?Reflector; +} diff --git a/src/Test/OpenapiTestKit.php b/src/Test/OpenapiTestKit.php index b3ea866..3f3a3ca 100644 --- a/src/Test/OpenapiTestKit.php +++ b/src/Test/OpenapiTestKit.php @@ -43,7 +43,7 @@ trait OpenapiTestKit { /** - * Gets Openapi instance + * Gets the openapi instance * * @return OpenApi */ @@ -62,7 +62,7 @@ protected function assertResponseBodyMatchesDescription(string $operationId, Res { if (!class_exists(Validator::class)) { // @codeCoverageIgnoreStart - $this->markTestSkipped('To use Openapi Test Kit, install the "justinrainbow/json-schema".'); + $this->markTestSkipped('To use OpenAPI Test Kit, install the "justinrainbow/json-schema".'); // @codeCoverageIgnoreEnd } diff --git a/src/Utility/OperationConverter.php b/src/Utility/OperationConverter.php index 24c4ce9..0dae17e 100644 --- a/src/Utility/OperationConverter.php +++ b/src/Utility/OperationConverter.php @@ -37,7 +37,7 @@ final class OperationConverter { /** - * Blank JSON Schema + * Blank JSON schema * * @var array * diff --git a/tests/Command/GenerateJsonSchemaCommandTest.php b/tests/Command/GenerateJsonSchemaCommandTest.php index ddeb8cc..7ea13c6 100644 --- a/tests/Command/GenerateJsonSchemaCommandTest.php +++ b/tests/Command/GenerateJsonSchemaCommandTest.php @@ -6,9 +6,11 @@ * Import classes */ use PHPUnit\Framework\TestCase; +use Sunrise\Http\Router\OpenApi\OpenApi; use Sunrise\Http\Router\OpenApi\Command\GenerateJsonSchemaCommand; use Sunrise\Http\Router\OpenApi\Tests\Fixtures\OpenapiAwareTrait; use Symfony\Component\Console\Tester\CommandTester; +use RuntimeException; /** * GenerateJsonSchemaCommandTest @@ -50,5 +52,83 @@ public function testRun() : void 'operation-id' => 'users.list', 'operation-section' => 'unknown', ])); + + $this->assertSame(1, $commandTester->execute([ + 'operation-id' => 'unknown', + 'operation-section' => 'body', + ])); + } + + /** + * @return void + */ + public function testRunInheritedCommand() : void + { + // @codingStandardsIgnoreStart + $command = new class($this->getOpenapi()) extends GenerateJsonSchemaCommand { + private $_openapi; + + public function __construct(OpenApi $openapi) { + $this->_openapi = $openapi; + parent::__construct(null); + } + + protected function getOpenapi() : OpenApi { + return $this->_openapi; + } + }; + // @codingStandardsIgnoreEnd + + $this->assertSame('router:generate-json-schema', $command->getName()); + + $commandTester = new CommandTester($command); + + $this->assertSame(0, $commandTester->execute([ + 'operation-id' => 'users.create', + 'operation-section' => 'body', + ])); + } + + /** + * @return void + */ + public function testRunRenamedCommand() : void + { + // @codingStandardsIgnoreStart + $command = new class ($this->getOpenapi()) extends GenerateJsonSchemaCommand { + protected static $defaultName = 'foo'; + protected static $defaultDescription = 'bar'; + + public function __construct(OpenApi $openapi) { + parent::__construct($openapi); + } + }; + // @codingStandardsIgnoreEnd + + $this->assertSame('foo', $command->getName()); + $this->assertSame('bar', $command->getDescription()); + + $commandTester = new CommandTester($command); + + $this->assertSame(0, $commandTester->execute([ + 'operation-id' => 'users.create', + 'operation-section' => 'body', + ])); + } + + /** + * @return void + */ + public function testRunWithoutOpenapi() : void + { + $command = new GenerateJsonSchemaCommand(); + $commandTester = new CommandTester($command); + + $this->expectException(RuntimeException::class); + + $commandTester->execute([ + 'operation-id' => 'users.create', + 'operation-section' => 'body', + ]); } } diff --git a/tests/Command/GenerateOpenapiDocumentCommandTest.php b/tests/Command/GenerateOpenapiDocumentCommandTest.php index 27e23ee..98f6aef 100644 --- a/tests/Command/GenerateOpenapiDocumentCommandTest.php +++ b/tests/Command/GenerateOpenapiDocumentCommandTest.php @@ -6,9 +6,11 @@ * Import classes */ use PHPUnit\Framework\TestCase; +use Sunrise\Http\Router\OpenApi\OpenApi; use Sunrise\Http\Router\OpenApi\Command\GenerateOpenapiDocumentCommand; use Sunrise\Http\Router\OpenApi\Tests\Fixtures\OpenapiAwareTrait; use Symfony\Component\Console\Tester\CommandTester; +use RuntimeException; /** * GenerateOpenapiDocumentCommandTest @@ -37,4 +39,68 @@ public function testRun() : void '--output-format' => 'unknown', ])); } + + /** + * @return void + */ + public function testRunInheritedCommand() : void + { + // @codingStandardsIgnoreStart + $command = new class($this->getOpenapi()) extends GenerateOpenapiDocumentCommand { + private $_openapi; + + public function __construct(OpenApi $openapi) { + $this->_openapi = $openapi; + parent::__construct(null); + } + + protected function getOpenapi() : OpenApi { + return $this->_openapi; + } + }; + // @codingStandardsIgnoreEnd + + $this->assertSame('router:generate-openapi-document', $command->getName()); + + $commandTester = new CommandTester($command); + + $this->assertSame(0, $commandTester->execute([])); + } + + /** + * @return void + */ + public function testRunRenamedCommand() : void + { + // @codingStandardsIgnoreStart + $command = new class ($this->getOpenapi()) extends GenerateOpenapiDocumentCommand { + protected static $defaultName = 'foo'; + protected static $defaultDescription = 'bar'; + + public function __construct(OpenApi $openapi) { + parent::__construct($openapi); + } + }; + // @codingStandardsIgnoreEnd + + $this->assertSame('foo', $command->getName()); + $this->assertSame('bar', $command->getDescription()); + + $commandTester = new CommandTester($command); + + $this->assertSame(0, $commandTester->execute([])); + } + + /** + * @return void + */ + public function testRunWithoutOpenapi() : void + { + $command = new GenerateOpenapiDocumentCommand(); + $commandTester = new CommandTester($command); + + $this->expectException(RuntimeException::class); + + $this->assertSame(0, $commandTester->execute([])); + } } diff --git a/tests/Middleware/RequestValidationMiddlewareTest.php b/tests/Middleware/RequestValidationMiddlewareTest.php index 4cd9c4f..0c52a0d 100644 --- a/tests/Middleware/RequestValidationMiddlewareTest.php +++ b/tests/Middleware/RequestValidationMiddlewareTest.php @@ -12,7 +12,9 @@ use Sunrise\Http\Router\Exception\UnsupportedMediaTypeException; use Sunrise\Http\Router\OpenApi\Middleware\RequestValidationMiddleware; use Sunrise\Http\Router\OpenApi\Tests\Fixtures\OpenapiAwareTrait; +use Sunrise\Http\Router\OpenApi\Openapi; use Sunrise\Http\Router\Route; +use RuntimeException; /** * RequestValidationMiddlewareTest @@ -228,4 +230,53 @@ public function testRunWithoutRoute() : void $this->assertSame(201, $response->getStatusCode()); } + + /** + * @return void + */ + public function testRunWithoutOpenapi() : void + { + $route = $this->getRouter()->getRoute('users.create'); + + $request = (new ServerRequestFactory) + ->createServerRequest('GET', '/') + ->withAttribute(Route::ATTR_NAME_FOR_ROUTE, $route); + + $middleware = new RequestValidationMiddleware(); + + $this->expectException(RuntimeException::class); + + $middleware->process($request, $route); + } + + /** + * @return void + */ + public function testRunInheritedMiddleware() : void + { + // @codingStandardsIgnoreStart + $middleware = new class($this->getOpenapi()) extends RequestValidationMiddleware { + private $_openapi; + + public function __construct(Openapi $openapi) { + $this->_openapi = $openapi; + } + + protected function getOpenapi() : OpenApi { + return $this->_openapi; + } + }; + // @codingStandardsIgnoreEnd + + $route = $this->getRouter()->getRoute('users.list'); + + $request = (new ServerRequestFactory) + ->createServerRequest('GET', '/') + ->withCookieParams(['limit' => '100']) + ->withAttribute(Route::ATTR_NAME_FOR_ROUTE, $route); + + $response = $middleware->process($request, $route); + + $this->assertSame(200, $response->getStatusCode()); + } } diff --git a/tests/OpenApiTest.php b/tests/OpenApiTest.php index bba4dab..cafebdc 100644 --- a/tests/OpenApiTest.php +++ b/tests/OpenApiTest.php @@ -20,6 +20,7 @@ use Sunrise\Http\Router\OpenApi\Tests\Fixtures\SomeApp\Controller\InvalidController; use Sunrise\Http\Router\RequestHandler\CallableRequestHandler; use Sunrise\Http\Router\Route; +use TypeError; /** * Import functions @@ -313,13 +314,11 @@ public function testBuildCache() : void $document = $openapi->toArray(); - $this->assertArrayHasKey($openapi->getBuildCacheKey(), $cache->storage); + $this->assertSame($document, $cache->get($openapi->getBuildCacheKey())); - $this->assertSame($document, $cache->storage[$openapi->getBuildCacheKey()]); + $cache->set($openapi->getBuildCacheKey(), ['foo' => 'bar']); - $cache->storage[$openapi->getBuildCacheKey()] = ['foo' => 'bar']; - - $this->assertSame($cache->storage[$openapi->getBuildCacheKey()], $openapi->toArray()); + $this->assertSame($cache->get($openapi->getBuildCacheKey()), $openapi->toArray()); } /** @@ -332,24 +331,29 @@ public function testOperationsCache() : void $openapi = $this->getOpenapi(); $openapi->setCache($cache); - // background caching of operations... - $openapi->toArray(); - - $this->assertArrayHasKey($openapi->getOperationsCacheKey(), $cache->storage); - - $this->assertArrayHasKey('home', $cache->storage[$openapi->getOperationsCacheKey()]); - - $testOperation = new Operation(); - $testOperation->operationId = 'home'; - $testOperation->summary = '7AC99FFC-6AB0-4EF1-A75E-49E0B85E7849'; - - $cache->storage[$openapi->getBuildCacheKey()] = null; - $cache->storage[$openapi->getOperationsCacheKey()] = []; - $cache->storage[$openapi->getOperationsCacheKey()][$testOperation->operationId] = $testOperation; + $operation = new Operation(); + $operation->operationId = 'home'; + $operation->description = '7AC99FFC-6AB0-4EF1-A75E-49E0B85E7849'; - $document = $openapi->toArray(); + $cache->set($openapi->getOperationsCacheKey(), [ + $operation->operationId => $operation, + ]); - $this->assertSame($testOperation->summary, $document['paths']['/']['get']['summary'] ?? null); + $this->assertSame([ + 'openapi' => '3.0.2', + 'info' => [ + 'title' => 'Some application', + 'version' => '1.0.0', + ], + 'paths' => [ + '/' => [ + 'get' => [ + 'operationId' => $operation->operationId, + 'description' => $operation->description, + ], + ], + ], + ], $openapi->toArray()); } /** @@ -489,4 +493,16 @@ public function testReferenceToClassPropertyWithoutTarget() : void $openapi->toArray(); } + + /** + * @return void + */ + public function testAddInvalidRoute() : void + { + $openapi = $this->getOpenapi(); + + $this->expectException(TypeError::class); + + $openapi->addRoute(new \stdClass); + } } diff --git a/tests/fixtures/CacheAwareTrait.php b/tests/fixtures/CacheAwareTrait.php index dba7397..60ef288 100644 --- a/tests/fixtures/CacheAwareTrait.php +++ b/tests/fixtures/CacheAwareTrait.php @@ -4,6 +4,9 @@ use Psr\SimpleCache\CacheInterface; +use function serialize; +use function unserialize; + trait CacheAwareTrait { private static $permanentCacheStorage = []; @@ -21,7 +24,7 @@ private function getCache(bool $permanentStorage = false) : CacheInterface } $cache->method('get')->will($this->returnCallback(function ($key) use ($cache) { - return $cache->storage[$key] ?? null; + return isset($cache->storage[$key]) ? unserialize($cache->storage[$key]) : null; })); $cache->method('has')->will($this->returnCallback(function ($key) use ($cache) { @@ -29,7 +32,7 @@ private function getCache(bool $permanentStorage = false) : CacheInterface })); $cache->method('set')->will($this->returnCallback(function ($key, $value) use ($cache) { - $cache->storage[$key] = $value; + $cache->storage[$key] = serialize($value); })); return $cache;