From fbf03cffc3fdc3e184e1d9dc2c04c42ec3006009 Mon Sep 17 00:00:00 2001 From: Yuriy Belenko Date: Sun, 12 Jan 2020 01:58:03 +0300 Subject: [PATCH] [Slim4] Refresh samples --- samples/server/petstore/php-slim4/README.md | 4 + .../petstore/php-slim4/docs/MockServer.md | 135 ++++ samples/server/petstore/php-slim4/index.php | 32 + .../php-slim4/lib/Mock/OpenApiDataMocker.php | 2 +- .../lib/Mock/OpenApiDataMockerMiddleware.php | 186 +++++ .../petstore/php-slim4/lib/SlimRouter.php | 738 +++++++++++++++++- .../Mock/OpenApiDataMockerMiddlewareTest.php | 273 +++++++ 7 files changed, 1363 insertions(+), 7 deletions(-) create mode 100644 samples/server/petstore/php-slim4/docs/MockServer.md create mode 100644 samples/server/petstore/php-slim4/lib/Mock/OpenApiDataMockerMiddleware.php create mode 100644 samples/server/petstore/php-slim4/test/Mock/OpenApiDataMockerMiddlewareTest.php diff --git a/samples/server/petstore/php-slim4/README.md b/samples/server/petstore/php-slim4/README.md index b0efeb9dc660..325111aa6243 100644 --- a/samples/server/petstore/php-slim4/README.md +++ b/samples/server/petstore/php-slim4/README.md @@ -46,6 +46,8 @@ Command | Target `$ composer test` | All tests `$ composer test-apis` | Apis tests `$ composer test-models` | Models tests +`$ composer test-mock` | Mock feature tests +`$ composer test-utils` | Utils tests #### Config @@ -99,6 +101,8 @@ Switch on option in `./index.php`: +++ $app->addErrorMiddleware(true, true, true); ``` +## [Mock Server Documentation](./docs/MockServer.md) + ## API Endpoints All URIs are relative to *http://petstore.swagger.io:80/v2* diff --git a/samples/server/petstore/php-slim4/docs/MockServer.md b/samples/server/petstore/php-slim4/docs/MockServer.md new file mode 100644 index 000000000000..76f5668ad51d --- /dev/null +++ b/samples/server/petstore/php-slim4/docs/MockServer.md @@ -0,0 +1,135 @@ +# php-base - PHP Slim 4 Server library for OpenAPI Petstore + +## Mock Server Documentation + +### Mocker Options +To enable mock server uncomment these lines in `index.php` config file: + +```php +/** + * Mocker Middleware options. + */ +$config['mockerOptions'] = [ + 'dataMocker' => new OpenApiDataMocker(), + + 'getMockResponseCallback' => function (ServerRequestInterface $request, array $responses) { + // check if client clearly asks for mocked response + if ( + $request->hasHeader('X-OpenAPIServer-Mock') + && $request->getHeader('X-OpenAPIServer-Mock')[0] === 'ping' + ) { + if (array_key_exists('default', $responses)) { + return $responses['default']; + } + + // return first response + return $responses[array_key_first($responses)]; + } + + return false; + }, + + 'afterCallback' => function ($request, $response) { + // mark mocked response to distinguish real and fake responses + return $response->withHeader('X-OpenAPIServer-Mock', 'pong'); + }, +]; +``` + +* `dataMocker` is mocker class instance. To create custom data mocker extend `OpenAPIServer\Mock\OpenApiDataMockerInterface`. +* `getMockResponseCallback` is callback before mock data generation. Above example shows how to enable mock feature for only requests with `{{X-OpenAPIServer}}-mock: ping` HTTP header. Adjust requests filtering to fit your project requirements. This function must return single response schema from `$responses` array parameter. **Mock feature is disabled when callback returns anything beside array.** +* `afterCallback` is callback executed after mock data generation. Most obvious use case is append specific HTTP headers to distinguish real and fake responses. **This function must always return response instance.** + +### Supported features + +All data types supported except specific string formats: `email`, `uuid`, `password` which are poorly implemented. + +#### Data Types Support + +| Data Type | Data Format | Supported | +|:---------:|:-----------:|:------------------:| +| `integer` | `int32` | :white_check_mark: | +| `integer` | `int64` | :white_check_mark: | +| `number` | `float` | :white_check_mark: | +| `number` | `double` | | +| `string` | `byte` | :white_check_mark: | +| `string` | `binary` | :white_check_mark: | +| `boolean` | | :white_check_mark: | +| `string` | `date` | :white_check_mark: | +| `string` | `date-time` | :white_check_mark: | +| `string` | `password` | :white_check_mark: | +| `string` | `email` | :white_check_mark: | +| `string` | `uuid` | :white_check_mark: | + +#### Data Options Support + +| Data Type | Option | Supported | +|:-----------:|:----------------------:|:------------------:| +| `string` | `minLength` | :white_check_mark: | +| `string` | `maxLength` | :white_check_mark: | +| `string` | `enum` | :white_check_mark: | +| `string` | `pattern` | | +| `integer` | `minimum` | :white_check_mark: | +| `integer` | `maximum` | :white_check_mark: | +| `integer` | `exclusiveMinimum` | :white_check_mark: | +| `integer` | `exclusiveMaximum` | :white_check_mark: | +| `number` | `minimum` | :white_check_mark: | +| `number` | `maximum` | :white_check_mark: | +| `number` | `exclusiveMinimum` | :white_check_mark: | +| `number` | `exclusiveMaximum` | :white_check_mark: | +| `array` | `items` | :white_check_mark: | +| `array` | `additionalItems` | | +| `array` | `minItems` | :white_check_mark: | +| `array` | `maxItems` | :white_check_mark: | +| `array` | `uniqueItems` | | +| `object` | `properties` | :white_check_mark: | +| `object` | `maxProperties` | | +| `object` | `minProperties` | | +| `object` | `patternProperties` | | +| `object` | `additionalProperties` | | +| `object` | `required` | | +| `*` | `$ref` | :white_check_mark: | +| `*` | `allOf` | | +| `*` | `anyOf` | | +| `*` | `oneOf` | | +| `*` | `not` | | + +### Known Limitations + +Avoid circular refs in your schema. Schema below can cause infinite loop and `Out of Memory` PHP error: +```yml +# ModelA has reference to ModelB while ModelB has reference to ModelA. +# Mock server will produce huge nested JSON example and ended with `Out of Memory` error. +definitions: + ModelA: + type: object + properties: + model_b: + $ref: '#/definitions/ModelB' + ModelB: + type: array + items: + $ref: '#/definitions/ModelA' +``` + +Don't ref scalar types, because generator will not produce models which mock server can find. So schema below will cause error: +```yml +# generated build contains only `OuterComposite` model class which referenced to not existed `OuterNumber`, `OuterString`, `OuterBoolean` classes +# mock server cannot mock `OuterComposite` model and throws exception +definitions: + OuterComposite: + type: object + properties: + my_number: + $ref: '#/definitions/OuterNumber' + my_string: + $ref: '#/definitions/OuterString' + my_boolean: + $ref: '#/definitions/OuterBoolean' + OuterNumber: + type: number + OuterString: + type: string + OuterBoolean: + type: boolean +``` diff --git a/samples/server/petstore/php-slim4/index.php b/samples/server/petstore/php-slim4/index.php index 70cb6dede1a4..8baabc9b1398 100644 --- a/samples/server/petstore/php-slim4/index.php +++ b/samples/server/petstore/php-slim4/index.php @@ -14,6 +14,9 @@ require_once __DIR__ . '/vendor/autoload.php'; use OpenAPIServer\SlimRouter; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ResponseInterface; +use OpenAPIServer\Mock\OpenApiDataMocker; $config = []; @@ -50,6 +53,35 @@ // 'error' => null, ]; +/** + * Mocker Middleware options. + */ +$config['mockerOptions'] = [ + // 'dataMocker' => new OpenApiDataMocker(), + + // 'getMockResponseCallback' => function (ServerRequestInterface $request, array $responses) { + // // check if client clearly asks for mocked response + // if ( + // $request->hasHeader('X-OpenAPIServer-Mock') + // && $request->getHeader('X-OpenAPIServer-Mock')[0] === 'ping' + // ) { + // if (array_key_exists('default', $responses)) { + // return $responses['default']; + // } + + // // return first response + // return $responses[array_key_first($responses)]; + // } + + // return false; + // }, + + // 'afterCallback' => function ($request, $response) { + // // mark mocked response to distinguish real and fake responses + // return $response->withHeader('X-OpenAPIServer-Mock', 'pong'); + // }, +]; + $router = new SlimRouter($config); $app = $router->getSlimApp(); diff --git a/samples/server/petstore/php-slim4/lib/Mock/OpenApiDataMocker.php b/samples/server/petstore/php-slim4/lib/Mock/OpenApiDataMocker.php index 7f4412a74182..9179325771c2 100644 --- a/samples/server/petstore/php-slim4/lib/Mock/OpenApiDataMocker.php +++ b/samples/server/petstore/php-slim4/lib/Mock/OpenApiDataMocker.php @@ -357,7 +357,7 @@ public function mockObject( foreach ($properties as $propName => $propValue) { $options = $this->extractSchemaProperties($propValue); $dataType = $options['type']; - $dataFormat = $options['dataFormat'] ?? null; + $dataFormat = $options['format'] ?? null; $ref = $options['$ref'] ?? null; $data = $this->mockFromRef($ref); $obj->$propName = ($data) ? $data : $this->mock($dataType, $dataFormat, $options); diff --git a/samples/server/petstore/php-slim4/lib/Mock/OpenApiDataMockerMiddleware.php b/samples/server/petstore/php-slim4/lib/Mock/OpenApiDataMockerMiddleware.php new file mode 100644 index 000000000000..60ba929a403a --- /dev/null +++ b/samples/server/petstore/php-slim4/lib/Mock/OpenApiDataMockerMiddleware.php @@ -0,0 +1,186 @@ +hasHeader('X-OpenAPIServer-Mock') + * && $request->header('X-OpenAPIServer-Mock')[0] === 'ping' + * ) { + * return $responses[array_key_first($responses)]; + * } + * return false; + * }; + * @param callable|null $afterCallback After callback. + * Function must return response instance. + * @example $afterCallback = function (ServerRequestInterface $request, ResponseInterface $response) { + * // mark mocked response to distinguish real and fake responses + * return $response->withHeader('X-OpenAPIServer-Mock', 'pong'); + * }; + */ + public function __construct( + OpenApiDataMockerInterface $mocker, + array $responses, + $getMockResponseCallback = null, + $afterCallback = null + ) { + $this->mocker = $mocker; + $this->responses = $responses; + if (is_callable($getMockResponseCallback)) { + $this->getMockResponseCallback = $getMockResponseCallback; + } elseif ($getMockResponseCallback !== null) { + // wrong argument type + throw new InvalidArgumentException('\$getMockResponseCallback must be closure or null'); + } + + if (is_callable($afterCallback)) { + $this->afterCallback = $afterCallback; + } elseif ($afterCallback !== null) { + // wrong argument type + throw new InvalidArgumentException('\$afterCallback must be closure or null'); + } + } + + /** + * Parse incoming JSON input into a native PHP format + * + * @param ServerRequestInterface $request HTTP request + * @param RequestHandlerInterface $handler Request handler + * + * @return ResponseInterface HTTP response + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $customCallback = $this->getMockResponseCallback; + $customAfterCallback = $this->afterCallback; + $mockedResponse = (is_callable($customCallback)) ? $customCallback($request, $this->responses) : null; + if ( + is_array($mockedResponse) + && array_key_exists('code', $mockedResponse) + && array_key_exists('jsonSchema', $mockedResponse) + ) { + // response schema succesfully selected, we can mock it now + $statusCode = ($mockedResponse['code'] === 0) ? 200 : $mockedResponse['code']; + $contentType = '*/*'; + $response = AppFactory::determineResponseFactory()->createResponse($statusCode); + $responseSchema = json_decode($mockedResponse['jsonSchema'], true); + + if (is_array($responseSchema) && array_key_exists('headers', $responseSchema)) { + // response schema contains headers definitions, apply them one by one + foreach ($responseSchema['headers'] as $headerName => $headerDefinition) { + $response = $response->withHeader($headerName, $this->mocker->mockFromSchema($headerDefinition['schema'])); + } + } + + if ( + is_array($responseSchema) + && array_key_exists('content', $responseSchema) + && !empty($responseSchema['content']) + ) { + // response schema contains body definition + $responseContentSchema = null; + foreach ($responseSchema['content'] as $schemaContentType => $schemaDefinition) { + // we can respond in JSON format when any(*/*) content-type allowed + // or JSON(application/json) content-type specifically defined + if ( + $schemaContentType === '*/*' + || strtolower(substr($schemaContentType, 0, 16)) === 'application/json' + ) { + $contentType = 'application/json'; + $responseContentSchema = $schemaDefinition['schema']; + } + } + + if ($contentType === 'application/json') { + $responseBody = $this->mocker->mockFromSchema($responseContentSchema); + $response->getBody()->write(json_encode($responseBody)); + } else { + // notify developer that only application/json response supported so far + $response->getBody()->write('Mock feature supports only "application/json" content-type!'); + } + } + + // after callback applied only when mocked response schema has been selected + if (is_callable($customAfterCallback)) { + $response = $customAfterCallback($request, $response); + } + + // no reason to execute following middlewares (auth, validation etc.) + // return mocked response and end connection + return $response + ->withHeader('Content-Type', $contentType); + } + + // no response selected, mock feature disabled + // execute following middlewares + return $handler->handle($request); + } +} diff --git a/samples/server/petstore/php-slim4/lib/SlimRouter.php b/samples/server/petstore/php-slim4/lib/SlimRouter.php index ac60e425307b..440f59aa6ecb 100644 --- a/samples/server/petstore/php-slim4/lib/SlimRouter.php +++ b/samples/server/petstore/php-slim4/lib/SlimRouter.php @@ -33,6 +33,8 @@ use Dyorg\TokenAuthentication\TokenSearch; use Psr\Http\Message\ServerRequestInterface; use OpenAPIServer\Middleware\JsonBodyParserMiddleware; +use OpenAPIServer\Mock\OpenApiDataMocker; +use OpenAPIServer\Mock\OpenApiDataMockerMiddleware; use Exception; /** @@ -58,6 +60,22 @@ class SlimRouter 'classname' => 'AbstractAnotherFakeApi', 'userClassname' => 'AnotherFakeApi', 'operationId' => 'call123TestSpecialTags', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Client" + } + } + } +}', + ], + ], 'authMethods' => [ ], ], @@ -69,6 +87,16 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'createXmlItem', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -80,6 +108,22 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'fakeOuterBooleanSerialize', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'Output boolean', + 'jsonSchema' => '{ + "description" : "Output boolean", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/OuterBoolean" + } + } + } +}', + ], + ], 'authMethods' => [ ], ], @@ -91,6 +135,22 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'fakeOuterCompositeSerialize', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'Output composite', + 'jsonSchema' => '{ + "description" : "Output composite", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/OuterComposite" + } + } + } +}', + ], + ], 'authMethods' => [ ], ], @@ -102,6 +162,22 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'fakeOuterNumberSerialize', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'Output number', + 'jsonSchema' => '{ + "description" : "Output number", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/OuterNumber" + } + } + } +}', + ], + ], 'authMethods' => [ ], ], @@ -113,6 +189,22 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'fakeOuterStringSerialize', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'Output string', + 'jsonSchema' => '{ + "description" : "Output string", + "content" : { + "*/*" : { + "schema" : { + "$ref" : "#/components/schemas/OuterString" + } + } + } +}', + ], + ], 'authMethods' => [ ], ], @@ -124,6 +216,16 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'testBodyWithFileSchema', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'Success', + 'jsonSchema' => '{ + "description" : "Success", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -135,6 +237,16 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'testBodyWithQueryParams', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'Success', + 'jsonSchema' => '{ + "description" : "Success", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -146,6 +258,22 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'testClientModel', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Client" + } + } + } +}', + ], + ], 'authMethods' => [ ], ], @@ -157,6 +285,24 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'testEndpointParameters', + 'responses' => [ + '400' => [ + 'code' => 400, + 'message' => 'Invalid username supplied', + 'jsonSchema' => '{ + "description" : "Invalid username supplied", + "content" : { } +}', + ], + '404' => [ + 'code' => 404, + 'message' => 'User not found', + 'jsonSchema' => '{ + "description" : "User not found", + "content" : { } +}', + ], + ], 'authMethods' => [ // http security schema named 'http_basic_test' [ @@ -176,6 +322,24 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'testEnumParameters', + 'responses' => [ + '400' => [ + 'code' => 400, + 'message' => 'Invalid request', + 'jsonSchema' => '{ + "description" : "Invalid request", + "content" : { } +}', + ], + '404' => [ + 'code' => 404, + 'message' => 'Not found', + 'jsonSchema' => '{ + "description" : "Not found", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -187,6 +351,16 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'testGroupParameters', + 'responses' => [ + '400' => [ + 'code' => 400, + 'message' => 'Someting wrong', + 'jsonSchema' => '{ + "description" : "Someting wrong", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -198,6 +372,16 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'testInlineAdditionalProperties', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -209,6 +393,16 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'testJsonFormData', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -220,6 +414,16 @@ class SlimRouter 'classname' => 'AbstractFakeApi', 'userClassname' => 'FakeApi', 'operationId' => 'testQueryParameterCollectionFormat', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'Success', + 'jsonSchema' => '{ + "description" : "Success", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -231,6 +435,22 @@ class SlimRouter 'classname' => 'AbstractFakeClassnameTags123Api', 'userClassname' => 'FakeClassnameTags123Api', 'operationId' => 'testClassname', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Client" + } + } + } +}', + ], + ], 'authMethods' => [ // apiKey security schema named 'api_key_query' [ @@ -254,6 +474,24 @@ class SlimRouter 'classname' => 'AbstractPetApi', 'userClassname' => 'PetApi', 'operationId' => 'addPet', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { } +}', + ], + '405' => [ + 'code' => 405, + 'message' => 'Invalid input', + 'jsonSchema' => '{ + "description" : "Invalid input", + "content" : { } +}', + ], + ], 'authMethods' => [ // oauth2 security schema named 'petstore_auth' [ @@ -277,6 +515,41 @@ class SlimRouter 'classname' => 'AbstractPetApi', 'userClassname' => 'PetApi', 'operationId' => 'findPetsByStatus', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { + "application/xml" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/Pet" + } + } + }, + "application/json" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/Pet" + } + } + } + } +}', + ], + '400' => [ + 'code' => 400, + 'message' => 'Invalid status value', + 'jsonSchema' => '{ + "description" : "Invalid status value", + "content" : { } +}', + ], + ], 'authMethods' => [ // oauth2 security schema named 'petstore_auth' [ @@ -300,6 +573,41 @@ class SlimRouter 'classname' => 'AbstractPetApi', 'userClassname' => 'PetApi', 'operationId' => 'findPetsByTags', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { + "application/xml" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/Pet" + } + } + }, + "application/json" : { + "schema" : { + "type" : "array", + "items" : { + "$ref" : "#/components/schemas/Pet" + } + } + } + } +}', + ], + '400' => [ + 'code' => 400, + 'message' => 'Invalid tag value', + 'jsonSchema' => '{ + "description" : "Invalid tag value", + "content" : { } +}', + ], + ], 'authMethods' => [ // oauth2 security schema named 'petstore_auth' [ @@ -323,6 +631,40 @@ class SlimRouter 'classname' => 'AbstractPetApi', 'userClassname' => 'PetApi', 'operationId' => 'updatePet', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { } +}', + ], + '400' => [ + 'code' => 400, + 'message' => 'Invalid ID supplied', + 'jsonSchema' => '{ + "description" : "Invalid ID supplied", + "content" : { } +}', + ], + '404' => [ + 'code' => 404, + 'message' => 'Pet not found', + 'jsonSchema' => '{ + "description" : "Pet not found", + "content" : { } +}', + ], + '405' => [ + 'code' => 405, + 'message' => 'Validation exception', + 'jsonSchema' => '{ + "description" : "Validation exception", + "content" : { } +}', + ], + ], 'authMethods' => [ // oauth2 security schema named 'petstore_auth' [ @@ -346,6 +688,24 @@ class SlimRouter 'classname' => 'AbstractPetApi', 'userClassname' => 'PetApi', 'operationId' => 'deletePet', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { } +}', + ], + '400' => [ + 'code' => 400, + 'message' => 'Invalid pet value', + 'jsonSchema' => '{ + "description" : "Invalid pet value", + "content" : { } +}', + ], + ], 'authMethods' => [ // oauth2 security schema named 'petstore_auth' [ @@ -369,6 +729,43 @@ class SlimRouter 'classname' => 'AbstractPetApi', 'userClassname' => 'PetApi', 'operationId' => 'getPetById', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { + "application/xml" : { + "schema" : { + "$ref" : "#/components/schemas/Pet" + } + }, + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Pet" + } + } + } +}', + ], + '400' => [ + 'code' => 400, + 'message' => 'Invalid ID supplied', + 'jsonSchema' => '{ + "description" : "Invalid ID supplied", + "content" : { } +}', + ], + '404' => [ + 'code' => 404, + 'message' => 'Pet not found', + 'jsonSchema' => '{ + "description" : "Pet not found", + "content" : { } +}', + ], + ], 'authMethods' => [ // apiKey security schema named 'api_key' [ @@ -392,6 +789,16 @@ class SlimRouter 'classname' => 'AbstractPetApi', 'userClassname' => 'PetApi', 'operationId' => 'updatePetWithForm', + 'responses' => [ + '405' => [ + 'code' => 405, + 'message' => 'Invalid input', + 'jsonSchema' => '{ + "description" : "Invalid input", + "content" : { } +}', + ], + ], 'authMethods' => [ // oauth2 security schema named 'petstore_auth' [ @@ -415,6 +822,22 @@ class SlimRouter 'classname' => 'AbstractPetApi', 'userClassname' => 'PetApi', 'operationId' => 'uploadFile', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ApiResponse" + } + } + } +}', + ], + ], 'authMethods' => [ // oauth2 security schema named 'petstore_auth' [ @@ -438,6 +861,22 @@ class SlimRouter 'classname' => 'AbstractPetApi', 'userClassname' => 'PetApi', 'operationId' => 'uploadFileWithRequiredFile', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ApiResponse" + } + } + } +}', + ], + ], 'authMethods' => [ // oauth2 security schema named 'petstore_auth' [ @@ -461,6 +900,26 @@ class SlimRouter 'classname' => 'AbstractStoreApi', 'userClassname' => 'StoreApi', 'operationId' => 'getInventory', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { + "application/json" : { + "schema" : { + "type" : "object", + "additionalProperties" : { + "type" : "integer", + "format" : "int32" + } + } + } + } +}', + ], + ], 'authMethods' => [ // apiKey security schema named 'api_key' [ @@ -484,6 +943,35 @@ class SlimRouter 'classname' => 'AbstractStoreApi', 'userClassname' => 'StoreApi', 'operationId' => 'placeOrder', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { + "application/xml" : { + "schema" : { + "$ref" : "#/components/schemas/Order" + } + }, + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Order" + } + } + } +}', + ], + '400' => [ + 'code' => 400, + 'message' => 'Invalid Order', + 'jsonSchema' => '{ + "description" : "Invalid Order", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -495,6 +983,24 @@ class SlimRouter 'classname' => 'AbstractStoreApi', 'userClassname' => 'StoreApi', 'operationId' => 'deleteOrder', + 'responses' => [ + '400' => [ + 'code' => 400, + 'message' => 'Invalid ID supplied', + 'jsonSchema' => '{ + "description" : "Invalid ID supplied", + "content" : { } +}', + ], + '404' => [ + 'code' => 404, + 'message' => 'Order not found', + 'jsonSchema' => '{ + "description" : "Order not found", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -506,6 +1012,43 @@ class SlimRouter 'classname' => 'AbstractStoreApi', 'userClassname' => 'StoreApi', 'operationId' => 'getOrderById', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { + "application/xml" : { + "schema" : { + "$ref" : "#/components/schemas/Order" + } + }, + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Order" + } + } + } +}', + ], + '400' => [ + 'code' => 400, + 'message' => 'Invalid ID supplied', + 'jsonSchema' => '{ + "description" : "Invalid ID supplied", + "content" : { } +}', + ], + '404' => [ + 'code' => 404, + 'message' => 'Order not found', + 'jsonSchema' => '{ + "description" : "Order not found", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -517,6 +1060,16 @@ class SlimRouter 'classname' => 'AbstractUserApi', 'userClassname' => 'UserApi', 'operationId' => 'createUser', + 'responses' => [ + 'default' => [ + 'code' => 0, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -528,6 +1081,16 @@ class SlimRouter 'classname' => 'AbstractUserApi', 'userClassname' => 'UserApi', 'operationId' => 'createUsersWithArrayInput', + 'responses' => [ + 'default' => [ + 'code' => 0, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -539,6 +1102,16 @@ class SlimRouter 'classname' => 'AbstractUserApi', 'userClassname' => 'UserApi', 'operationId' => 'createUsersWithListInput', + 'responses' => [ + 'default' => [ + 'code' => 0, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -550,6 +1123,51 @@ class SlimRouter 'classname' => 'AbstractUserApi', 'userClassname' => 'UserApi', 'operationId' => 'loginUser', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "headers" : { + "X-Rate-Limit" : { + "description" : "calls per hour allowed by the user", + "schema" : { + "type" : "integer", + "format" : "int32" + } + }, + "X-Expires-After" : { + "description" : "date in UTC when token expires", + "schema" : { + "type" : "string", + "format" : "date-time" + } + } + }, + "content" : { + "application/xml" : { + "schema" : { + "type" : "string" + } + }, + "application/json" : { + "schema" : { + "type" : "string" + } + } + } +}', + ], + '400' => [ + 'code' => 400, + 'message' => 'Invalid username/password supplied', + 'jsonSchema' => '{ + "description" : "Invalid username/password supplied", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -561,6 +1179,16 @@ class SlimRouter 'classname' => 'AbstractUserApi', 'userClassname' => 'UserApi', 'operationId' => 'logoutUser', + 'responses' => [ + 'default' => [ + 'code' => 0, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -572,6 +1200,24 @@ class SlimRouter 'classname' => 'AbstractUserApi', 'userClassname' => 'UserApi', 'operationId' => 'deleteUser', + 'responses' => [ + '400' => [ + 'code' => 400, + 'message' => 'Invalid username supplied', + 'jsonSchema' => '{ + "description" : "Invalid username supplied", + "content" : { } +}', + ], + '404' => [ + 'code' => 404, + 'message' => 'User not found', + 'jsonSchema' => '{ + "description" : "User not found", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -583,6 +1229,43 @@ class SlimRouter 'classname' => 'AbstractUserApi', 'userClassname' => 'UserApi', 'operationId' => 'getUserByName', + 'responses' => [ + 'default' => [ + 'code' => 200, + 'message' => 'successful operation', + 'jsonSchema' => '{ + "description" : "successful operation", + "content" : { + "application/xml" : { + "schema" : { + "$ref" : "#/components/schemas/User" + } + }, + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/User" + } + } + } +}', + ], + '400' => [ + 'code' => 400, + 'message' => 'Invalid username supplied', + 'jsonSchema' => '{ + "description" : "Invalid username supplied", + "content" : { } +}', + ], + '404' => [ + 'code' => 404, + 'message' => 'User not found', + 'jsonSchema' => '{ + "description" : "User not found", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -594,6 +1277,24 @@ class SlimRouter 'classname' => 'AbstractUserApi', 'userClassname' => 'UserApi', 'operationId' => 'updateUser', + 'responses' => [ + '400' => [ + 'code' => 400, + 'message' => 'Invalid user supplied', + 'jsonSchema' => '{ + "description" : "Invalid user supplied", + "content" : { } +}', + ], + '404' => [ + 'code' => 404, + 'message' => 'User not found', + 'jsonSchema' => '{ + "description" : "User not found", + "content" : { } +}', + ], + ], 'authMethods' => [ ], ], @@ -631,12 +1332,13 @@ public function __construct($settings = []) throw new Exception($message); }; - $userOptions = null; - if ($settings instanceof ContainerInterface && $settings->has('tokenAuthenticationOptions')) { - $userOptions = $settings->get('tokenAuthenticationOptions'); - } elseif (is_array($settings) && isset($settings['tokenAuthenticationOptions'])) { - $userOptions = $settings['tokenAuthenticationOptions']; - } + $userOptions = $this->getSetting($settings, 'tokenAuthenticationOptions', null); + + // mocker options + $mockerOptions = $this->getSetting($settings, 'mockerOptions', null); + $dataMocker = $mockerOptions['dataMocker'] ?? new OpenApiDataMocker(); + $getMockResponseCallback = $mockerOptions['getMockResponseCallback'] ?? null; + $mockAfterCallback = $mockerOptions['afterCallback'] ?? null; foreach ($this->operations as $operation) { $callback = function ($request, $response, $arguments) use ($operation) { @@ -703,6 +1405,10 @@ public function __construct($settings = []) } } + if (is_callable($getMockResponseCallback)) { + $middlewares[] = new OpenApiDataMockerMiddleware($dataMocker, $operation['responses'], $getMockResponseCallback, $mockAfterCallback); + } + $this->addRoute( [$operation['httpMethod']], "{$operation['basePathWithoutHost']}{$operation['path']}", @@ -729,6 +1435,26 @@ private function getTokenAuthenticationOptions(array $staticOptions, array $user return array_merge($userOptions, $staticOptions); } + /** + * Returns app setting by name. + * + * @param ContainerInterface|array $settings Either a ContainerInterface or an associative array of app settings + * @param string $settingName Setting name + * @param mixed $default Default setting value. + * + * @return mixed + */ + private function getSetting($settings, $settingName, $default = null) + { + if ($settings instanceof ContainerInterface && $settings->has($settingName)) { + return $settings->get($settingName); + } elseif (is_array($settings) && array_key_exists($settingName, $settings)) { + return $settings[$settingName]; + } + + return $default; + } + /** * Add route with multiple methods * diff --git a/samples/server/petstore/php-slim4/test/Mock/OpenApiDataMockerMiddlewareTest.php b/samples/server/petstore/php-slim4/test/Mock/OpenApiDataMockerMiddlewareTest.php new file mode 100644 index 000000000000..67dbc36fd327 --- /dev/null +++ b/samples/server/petstore/php-slim4/test/Mock/OpenApiDataMockerMiddlewareTest.php @@ -0,0 +1,273 @@ +assertInstanceOf(OpenApiDataMockerMiddleware::class, $middleware); + $this->assertNotNull($middleware); + } + + public function provideConstructCorrectArguments() + { + $getMockResponseCallback = function () { + return false; + }; + $afterCallback = function () { + return false; + }; + return [ + [new OpenApiDataMocker(), [], null, null], + [new OpenApiDataMocker(), [], $getMockResponseCallback, $afterCallback], + ]; + } + + /** + * @covers ::__construct + * @dataProvider provideConstructInvalidArguments + * @expectedException \InvalidArgumentException + * @expectedException \TypeError + */ + public function testConstructorWithInvalidArguments( + $mocker, + $responses, + $getMockResponseCallback, + $afterCallback + ) { + $middleware = new OpenApiDataMockerMiddleware($mocker, $responses, $getMockResponseCallback, $afterCallback); + } + + public function provideConstructInvalidArguments() + { + return [ + 'getMockResponseCallback not callable' => [ + new OpenApiDataMocker(), [], 'foobar', null, + ], + 'afterCallback not callable' => [ + new OpenApiDataMocker(), [], null, 'foobar', + ], + ]; + } + + /** + * @covers ::process + * @dataProvider provideProcessArguments + */ + public function testProcess( + $mocker, + $responses, + $getMockResponseCallback, + $afterCallback, + $request, + $expectedStatusCode, + $expectedHeaders, + $notExpectedHeaders, + $expectedBody + ) { + + // Create a stub for the RequestHandlerInterface interface. + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->method('handle') + ->willReturn(AppFactory::determineResponseFactory()->createResponse()); + + $middleware = new OpenApiDataMockerMiddleware( + $mocker, + $responses, + $getMockResponseCallback, + $afterCallback + ); + $response = $middleware->process($request, $handler); + + // check status code + $this->assertSame($expectedStatusCode, $response->getStatusCode()); + + // check http headers in request + foreach ($expectedHeaders as $expectedHeader => $expectedHeaderValue) { + $this->assertTrue($response->hasHeader($expectedHeader)); + if ($expectedHeaderValue !== '*') { + $this->assertSame($expectedHeaderValue, $response->getHeader($expectedHeader)[0]); + } + } + foreach ($notExpectedHeaders as $notExpectedHeader) { + $this->assertFalse($response->hasHeader($notExpectedHeader)); + } + + // check body + if (is_array($expectedBody)) { + // random values, check keys only + foreach ($expectedBody as $attribute => $value) { + $this->assertObjectHasAttribute($attribute, json_decode((string) $response->getBody(), false)); + } + } else { + $this->assertEquals($expectedBody, (string) $response->getBody()); + } + } + + public function provideProcessArguments() + { + $mocker = new OpenApiDataMocker(); + $isMockResponseRequired = function (ServerRequestInterface $request) { + $mockHttpHeader = 'X-OpenAPIServer-Mock'; + return $request->hasHeader($mockHttpHeader) + && $request->getHeader($mockHttpHeader)[0] === 'ping'; + }; + + $getMockResponseCallback = function (ServerRequestInterface $request, array $responses) use ($isMockResponseRequired) { + if ($isMockResponseRequired($request)) { + if (array_key_exists('default', $responses)) { + return $responses['default']; + } + + // return first response + return $responses[array_key_first($responses)]; + } + + return false; + }; + + $afterCallback = function ($request, $response) use ($isMockResponseRequired) { + if ($isMockResponseRequired($request)) { + $response = $response->withHeader('X-OpenAPIServer-Mock', 'pong'); + } + + return $response; + }; + + $responses = [ + '400' => [ + 'code' => 400, + 'jsonSchema' => json_encode([ + 'description' => 'Bad Request Response', + 'content' => new StdClass(), + ]), + ], + 'default' => [ + 'code' => 201, + 'jsonSchema' => json_encode([ + 'description' => 'Success Response', + 'headers' => [ + 'X-Location' => ['schema' => ['type' => 'string']], + 'X-Created-Id' => ['schema' => ['type' => 'integer']], + ], + 'content' => [ + 'application/json;encoding=utf-8' => ['schema' => ['type' => 'object', 'properties' => ['id' => ['type' => 'integer'], 'className' => ['type' => 'string'], 'declawed' => ['type' => 'boolean']]]], + ], + ]), + ], + ]; + + $responsesXmlOnly = [ + 'default' => [ + 'code' => 201, + 'jsonSchema' => json_encode([ + 'description' => 'Success Response', + 'content' => [ + 'application/xml' => [ + 'schema' => [ + 'type' => 'string', + ], + ], + ], + ]), + ], + ]; + + $requestFactory = ServerRequestCreatorFactory::create(); + + return [ + 'callbacks null' => [ + $mocker, + $responses, + null, + null, + $requestFactory->createServerRequestFromGlobals(), + 200, + [], + ['X-OpenAPIServer-Mock', 'x-location', 'x-created-id'], + '', + ], + 'xml not supported' => [ + $mocker, + $responsesXmlOnly, + $getMockResponseCallback, + $afterCallback, + $requestFactory + ->createServerRequestFromGlobals() + ->withHeader('X-OpenAPIServer-Mock', 'ping'), + 201, + ['X-OpenAPIServer-Mock' => 'pong', 'content-type' => '*/*'], + ['x-location', 'x-created-id'], + 'Mock feature supports only "application/json" content-type!', + ], + 'mock response default schema' => [ + $mocker, + $responses, + $getMockResponseCallback, + $afterCallback, + $requestFactory + ->createServerRequestFromGlobals() + ->withHeader('X-OpenAPIServer-Mock', 'ping'), + 201, + ['X-OpenAPIServer-Mock' => 'pong', 'content-type' => 'application/json', 'x-location' => '*', 'x-created-id' => '*'], + [], + [ + 'id' => 1, + 'className' => 'cat', + 'declawed' => false, + ], + ], + ]; + } +}