diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f75d5e5..dab278f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,6 @@ jobs: fail-fast: false matrix: php-version: [ - '8.1', '8.2', ] steps: diff --git a/README.md b/README.md index ca12483..24f8e0d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AppKernel Module [![Latest Stable Version](https://poser.pugx.org/spryker/app-kernel/v/stable.svg)](https://packagist.org/packages/spryker/app-kernel) -[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.1-8892BF.svg)](https://php.net/) +[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.2-8892BF.svg)](https://php.net/) Provides SyncAPI and AsyncAPI schema files and the needed code to make the Mini-Framework an App. @@ -17,11 +17,37 @@ composer require spryker/app-kernel config/Shared/config_default.php ``` -use Spryker\Shared\AppKernel\AppConstants; +use Spryker\Shared\AppKernel\AppKernelConstants; -$config[AppConstants::APP_IDENTIFIER] = getenv('APP_IDENTIFIER') ?: 'hello-world'; +$config[AppKernelConstants::APP_IDENTIFIER] = getenv('APP_IDENTIFIER') ?: 'hello-world'; +$config[AppKernelConstants::OPEN_API_SCHEMA_PATH] = 'path/to/your/openApiSchema.yml'; ``` +### Validating Requests against the OpenAPI Schema + +Low level validation can be done by using the `Spryker\Zed\AppKernel\Communication\Plugin\OpenApiSchemaValidatorPlugin` plugin. When this plugin is added to the GlueApplicationDependencyProvider all API requests against this App will be validated against the defined OpenAPI schema. + +To enable this, you need to have a well-defined OpenAPI schema file, and you need to add the `OpenApiSchemaValidatorPlugin` plugin to the `getRestApplicationPlugins` method in your GlueApplicationDependencyProvider. + +```php +use Spryker\Zed\AppKernel\Communication\Plugin\OpenApiSchemaValidatorPlugin; + +... + + protected function getRestApplicationPlugins(): array + { + return [ + new OpenApiSchemaValidatorPlugin(), + ]; + } + +... +``` + +Pay intention that this will be a hard validation that gets executed before any other code from your App gets executed. If the validation fails, the request will be rejected with a 400 Bad Request response with a proper message that explains what exactly is wrong in the request. + +Make sure you have tests for your API. + ### Testing the AppKernel You can test the AppKernel as usual with Codeception. Before that you need to run some commands: diff --git a/composer.json b/composer.json index 0946c80..e84485e 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,9 @@ "description": "AppKernel module", "license": "proprietary", "require": { - "php": ">=8.1", + "php": ">=8.2", + "guzzlehttp/psr7": "^2.7", + "league/openapi-psr7-validator": "^0.22.0", "spryker/app-kernel-extension": "^1.0.0", "spryker/glue-application-extension": "^1.0.0", "spryker/kernel": "^3.30.0", diff --git a/resources/api/openapi.yml b/resources/api/openapi.yml index f364b56..2500df0 100644 --- a/resources/api/openapi.yml +++ b/resources/api/openapi.yml @@ -5,20 +5,24 @@ info: name: Spryker url: 'https://spryker.com/app-composition-platform/' email: support@spryker.com - title: 'Hello World' + title: 'AppKernel API' license: name: MIT + url: 'https://opensource.org/licenses/MIT' servers: - - url: 'http://glue-backend.hello-world.spryker.local/' + - url: 'http://glue-backend.app-kernel.spryker.local' description: 'Local development endpoint' - - url: 'https://api.hello-world-testing.aop.demo-spryker.com/' + - url: 'https://api.app-kernel-testing.aop.demo-spryker.com' description: 'Testing' - - url: 'https://api.hello-world-staging.aop.demo-spryker.com/' + - url: 'https://api.app-kernel-staging.aop.demo-spryker.com' description: 'Staging' paths: '/private/configure': post: + operationId: 'configure' summary: 'Saves or updates Hello World App configuration between Tenants and this App.' + security: + - Bearer: [ ] parameters: - $ref: '#/components/parameters/tenantIdentifier' requestBody: @@ -49,8 +53,11 @@ paths: type: string '/private/disconnect': post: + operationId: 'disconnect' summary: 'Disconnects this App from a Tenants Application. Finds configuration and removes it.' + security: + - Bearer: [ ] parameters: - $ref: '#/components/parameters/tenantIdentifier' responses: @@ -69,6 +76,13 @@ paths: schema: type: string components: + securitySchemes: + Bearer: + type: http + scheme: bearer + bearerFormat: JWT + description: >- + Enter the token with the `Bearer ` prefix, e.g. "Bearer abcde12345". schemas: ConfigurationApiRequest: properties: diff --git a/src/Spryker/Glue/AppKernel/AppKernelConfig.php b/src/Spryker/Glue/AppKernel/AppKernelConfig.php index c596419..9e34aec 100644 --- a/src/Spryker/Glue/AppKernel/AppKernelConfig.php +++ b/src/Spryker/Glue/AppKernel/AppKernelConfig.php @@ -8,6 +8,7 @@ namespace Spryker\Glue\AppKernel; use Spryker\Glue\Kernel\AbstractBundleConfig; +use Spryker\Shared\AppKernel\AppKernelConstants; class AppKernelConfig extends AbstractBundleConfig { @@ -52,7 +53,14 @@ class AppKernelConfig extends AbstractBundleConfig public const RESPONSE_MESSAGE_DISCONNECT_ERROR = 'Disconnecting error.'; /** + * @api + * * @var string */ public const ERROR_CODE_PAYMENT_DISCONNECTION_TENANT_IDENTIFIER_MISSING = '20000'; + + public function getOpenApiSchemaPath(): ?string + { + return $this->getConfig()->hasKey(AppKernelConstants::OPEN_API_SCHEMA_PATH) ? $this->getConfig()->get(AppKernelConstants::OPEN_API_SCHEMA_PATH) : null; + } } diff --git a/src/Spryker/Glue/AppKernel/AppKernelFactory.php b/src/Spryker/Glue/AppKernel/AppKernelFactory.php index 62a4e36..2158fd9 100644 --- a/src/Spryker/Glue/AppKernel/AppKernelFactory.php +++ b/src/Spryker/Glue/AppKernel/AppKernelFactory.php @@ -17,12 +17,16 @@ use Spryker\Glue\AppKernel\Validator\BodyStructureValidator; use Spryker\Glue\AppKernel\Validator\ConfigurationValidator; use Spryker\Glue\AppKernel\Validator\HeaderValidator; +use Spryker\Glue\AppKernel\Validator\OpenApiRequestSchemaValidator; use Spryker\Glue\AppKernel\Validator\RequestValidator; use Spryker\Glue\AppKernel\Validator\RequestValidatorInterface; use Spryker\Glue\Kernel\Backend\AbstractFactory; use Symfony\Component\Validator\Validation; use Symfony\Component\Validator\Validator\ValidatorInterface; +/** + * @method \Spryker\Glue\AppKernel\AppKernelConfig getConfig() + */ class AppKernelFactory extends AbstractFactory { public function createHeaderValidator(): RequestValidatorInterface @@ -109,4 +113,9 @@ public function getApiRequestDisconnectValidatorPlugins(): array { return $this->getProvidedDependency(AppKernelDependencyProvider::PLUGINS_REQUEST_DISCONNECT_VALIDATOR); } + + public function createOpenApiRequestSchemaValidator(): OpenApiRequestSchemaValidator + { + return new OpenApiRequestSchemaValidator($this->getConfig()); + } } diff --git a/src/Spryker/Glue/AppKernel/Plugin/GlueApplication/AppGlueRequestSchemaValidatorPlugin.php b/src/Spryker/Glue/AppKernel/Plugin/GlueApplication/AppGlueRequestSchemaValidatorPlugin.php new file mode 100644 index 0000000..fc18d29 --- /dev/null +++ b/src/Spryker/Glue/AppKernel/Plugin/GlueApplication/AppGlueRequestSchemaValidatorPlugin.php @@ -0,0 +1,24 @@ +getFactory()->createOpenApiRequestSchemaValidator()->validate($glueRequestTransfer); + } +} diff --git a/src/Spryker/Glue/AppKernel/Validator/OpenApiRequestSchemaValidator.php b/src/Spryker/Glue/AppKernel/Validator/OpenApiRequestSchemaValidator.php new file mode 100644 index 0000000..d746644 --- /dev/null +++ b/src/Spryker/Glue/AppKernel/Validator/OpenApiRequestSchemaValidator.php @@ -0,0 +1,83 @@ +setIsValid(true); + + $openApiSchemaPath = $this->appKernelConfig->getOpenApiSchemaPath(); + + if ($openApiSchemaPath === null || $openApiSchemaPath === '' || $openApiSchemaPath === '0') { + return $glueRequestValidationTransfer; + } + + // Converting the HTTP request to PSR7 request + $psr7Request = new Request( + $glueRequestTransfer->getMethod() ?? '', + $glueRequestTransfer->getPath() ?? '', + $glueRequestTransfer->getMeta(), + $glueRequestTransfer->getContent(), + ); + + // Validate the request + $validator = (new ValidatorBuilder()) + ->fromYamlFile($openApiSchemaPath) + ->getRequestValidator(); + + try { + $validator->validate($psr7Request); + } catch (Throwable $throwable) { + $this->getLogger()->error( + $this->getMessageFromThrowable($throwable), + $glueRequestTransfer->getMeta(), + ); + + $glueErrorTransfer = new GlueErrorTransfer(); + $glueErrorTransfer + ->setMessage($this->getMessageFromThrowable($throwable)); + + $glueRequestValidationTransfer + ->setIsValid(false) + ->addError($glueErrorTransfer) + ->setStatus(Response::HTTP_BAD_REQUEST); + + return $glueRequestValidationTransfer; + } + + return $glueRequestValidationTransfer; + } + + protected function getMessageFromThrowable(Throwable $throwable): string + { + return match (get_class($throwable)) { + InvalidBody::class => $throwable->getVerboseMessage(), + default => $throwable->getMessage(), + }; + } +} diff --git a/src/Spryker/Shared/AppKernel/AppKernelConstants.php b/src/Spryker/Shared/AppKernel/AppKernelConstants.php index 9f0f750..fab0a15 100644 --- a/src/Spryker/Shared/AppKernel/AppKernelConstants.php +++ b/src/Spryker/Shared/AppKernel/AppKernelConstants.php @@ -31,4 +31,14 @@ class AppKernelConstants * @var string */ public const APP_VERSION = 'APP:APP_VERSION'; + + /** + * Specification: + * - Path to an OpenAPI schema path that can be used for request validation. + * + * @api + * + * @var string + */ + public const OPEN_API_SCHEMA_PATH = 'APP:OPEN_API_SCHEMA_PATH'; } diff --git a/tests/SprykerTest/Glue/AppKernel/Plugin/GlueApplication/AppGlueRequestSchemaValidatorPluginTest.php b/tests/SprykerTest/Glue/AppKernel/Plugin/GlueApplication/AppGlueRequestSchemaValidatorPluginTest.php new file mode 100644 index 0000000..2f3c38a --- /dev/null +++ b/tests/SprykerTest/Glue/AppKernel/Plugin/GlueApplication/AppGlueRequestSchemaValidatorPluginTest.php @@ -0,0 +1,259 @@ + codecept_data_dir('Fixtures/OpenApi/valid-openapi-schema.yml'), + ]); + + $appKernelFactory = Stub::make(AppKernelFactory::class, [ + 'getConfig' => $appKernelConfigStub, + ]); + + $appGlueRequestSchemaValidatorPlugin = new AppGlueRequestSchemaValidatorPlugin(); + $appGlueRequestSchemaValidatorPlugin->setFactory($appKernelFactory); + + $glueRequestTransfer = new GlueRequestTransfer(); + $glueRequestTransfer + ->setMethod('POST') + ->setPath('/existing-endpoint') + ->setMeta([ + 'X-Tenant-Identifier' => 'tenant-identifier', + 'Content-Type' => 'application/json', + ]) + ->setContent('{"data": {"attributes": {"foo": "bar"}}}'); + + // Act + $glueRequestValidationTransfer = $appGlueRequestSchemaValidatorPlugin->validate($glueRequestTransfer); + + // Assert + $this->assertTrue($glueRequestValidationTransfer->getIsValid()); + } + + public function testGivenNoOpenApiSchemaFileDefinedWhenTheValidationIsExecutedThenAValidValidationResponseIsReturned(): void + { + // Arrange + $appGlueRequestSchemaValidatorPlugin = new AppGlueRequestSchemaValidatorPlugin(); + + $glueRequestTransfer = new GlueRequestTransfer(); + + // Act + $glueRequestValidationTransfer = $appGlueRequestSchemaValidatorPlugin->validate($glueRequestTransfer); + + // Assert + $this->assertTrue($glueRequestValidationTransfer->getIsValid()); + } + + public function testGivenAValidOpenApiSchemaWhenTheRequestedPathIsNotDefinedThenAnInValidValidationResponseIsReturned(): void + { + // Arrange + $appKernelConfigStub = Stub::make(AppKernelConfig::class, [ + 'getOpenApiSchemaPath' => codecept_data_dir('Fixtures/OpenApi/valid-openapi-schema.yml'), + ]); + + $appKernelFactory = Stub::make(AppKernelFactory::class, [ + 'getConfig' => $appKernelConfigStub, + ]); + + $appGlueRequestSchemaValidatorPlugin = new AppGlueRequestSchemaValidatorPlugin(); + $appGlueRequestSchemaValidatorPlugin->setFactory($appKernelFactory); + + $glueRequestTransfer = new GlueRequestTransfer(); + $glueRequestTransfer + ->setMethod('GET') + ->setPath('/non-existing-endpoint'); + + // Act + $glueRequestValidationTransfer = $appGlueRequestSchemaValidatorPlugin->validate($glueRequestTransfer); + + // Assert + $this->assertFalse($glueRequestValidationTransfer->getIsValid()); + $this->assertSame('OpenAPI spec contains no such operation [/non-existing-endpoint]', $glueRequestValidationTransfer->getErrors()[0]->getMessage()); + } + + public function testGivenAValidOpenApiSchemaWhenTheRequestDoesNotHaveARequiredHeaderThenAnInValidValidationResponseIsReturned(): void + { + // Arrange + $appKernelConfigStub = Stub::make(AppKernelConfig::class, [ + 'getOpenApiSchemaPath' => codecept_data_dir('Fixtures/OpenApi/valid-openapi-schema.yml'), + ]); + + $appKernelFactory = Stub::make(AppKernelFactory::class, [ + 'getConfig' => $appKernelConfigStub, + ]); + + $appGlueRequestSchemaValidatorPlugin = new AppGlueRequestSchemaValidatorPlugin(); + $appGlueRequestSchemaValidatorPlugin->setFactory($appKernelFactory); + + $glueRequestTransfer = new GlueRequestTransfer(); + $glueRequestTransfer + ->setMethod('GET') + ->setPath('/existing-endpoint'); + + // Act + $glueRequestValidationTransfer = $appGlueRequestSchemaValidatorPlugin->validate($glueRequestTransfer); + + // Assert + $this->assertFalse($glueRequestValidationTransfer->getIsValid()); + $this->assertSame('Missing required header "X-Tenant-Identifier" for Request [get /existing-endpoint]', $glueRequestValidationTransfer->getErrors()[0]->getMessage()); + } + + public function testGivenAValidOpenApiSchemaWhenTheRequestDoesNotHaveTheContentTypeHeaderSetThenAnInValidValidationResponseIsReturned(): void + { + // Arrange + $appKernelConfigStub = Stub::make(AppKernelConfig::class, [ + 'getOpenApiSchemaPath' => codecept_data_dir('Fixtures/OpenApi/valid-openapi-schema.yml'), + ]); + + $appKernelFactory = Stub::make(AppKernelFactory::class, [ + 'getConfig' => $appKernelConfigStub, + ]); + + $appGlueRequestSchemaValidatorPlugin = new AppGlueRequestSchemaValidatorPlugin(); + $appGlueRequestSchemaValidatorPlugin->setFactory($appKernelFactory); + + $glueRequestTransfer = new GlueRequestTransfer(); + $glueRequestTransfer + ->setMethod('POST') + ->setPath('/existing-endpoint') + ->setMeta([ + 'X-Tenant-Identifier' => 'tenant-identifier', + ]); + + // Act + $glueRequestValidationTransfer = $appGlueRequestSchemaValidatorPlugin->validate($glueRequestTransfer); + + // Assert + $this->assertFalse($glueRequestValidationTransfer->getIsValid()); + $this->assertSame('Missing required header "Content-Type" for Request [post /existing-endpoint]', $glueRequestValidationTransfer->getErrors()[0]->getMessage()); + } + + public function testGivenAValidOpenApiSchemaWhenTheRequestDoesNotHaveABodyThenAnInValidValidationResponseIsReturned(): void + { + // Arrange + $appKernelConfigStub = Stub::make(AppKernelConfig::class, [ + 'getOpenApiSchemaPath' => codecept_data_dir('Fixtures/OpenApi/valid-openapi-schema.yml'), + ]); + + $appKernelFactory = Stub::make(AppKernelFactory::class, [ + 'getConfig' => $appKernelConfigStub, + ]); + + $appGlueRequestSchemaValidatorPlugin = new AppGlueRequestSchemaValidatorPlugin(); + $appGlueRequestSchemaValidatorPlugin->setFactory($appKernelFactory); + + $glueRequestTransfer = new GlueRequestTransfer(); + $glueRequestTransfer + ->setMethod('POST') + ->setPath('/existing-endpoint') + ->setMeta([ + 'X-Tenant-Identifier' => 'tenant-identifier', + 'Content-Type' => 'application/json', + ]); + + // Act + $glueRequestValidationTransfer = $appGlueRequestSchemaValidatorPlugin->validate($glueRequestTransfer); + + // Assert + $this->assertFalse($glueRequestValidationTransfer->getIsValid()); + $this->assertSame('JSON parsing failed with "Syntax error" for Request [post /existing-endpoint]', $glueRequestValidationTransfer->getErrors()[0]->getMessage()); + } + + public function testGivenAValidOpenApiSchemaWhenTheRequestDoesNotHaveARequiredFieldThenAnInValidValidationResponseIsReturned(): void + { + // Arrange + $appKernelConfigStub = Stub::make(AppKernelConfig::class, [ + 'getOpenApiSchemaPath' => codecept_data_dir('Fixtures/OpenApi/valid-openapi-schema.yml'), + ]); + + $appKernelFactory = Stub::make(AppKernelFactory::class, [ + 'getConfig' => $appKernelConfigStub, + ]); + + $appGlueRequestSchemaValidatorPlugin = new AppGlueRequestSchemaValidatorPlugin(); + $appGlueRequestSchemaValidatorPlugin->setFactory($appKernelFactory); + + $glueRequestTransfer = new GlueRequestTransfer(); + $glueRequestTransfer + ->setMethod('POST') + ->setPath('/existing-endpoint') + ->setMeta([ + 'X-Tenant-Identifier' => 'tenant-identifier', + 'Content-Type' => 'application/json', + ]) + ->setContent('{"key": "value"}'); + + // Act + $glueRequestValidationTransfer = $appGlueRequestSchemaValidatorPlugin->validate($glueRequestTransfer); + + // Assert + $this->assertFalse($glueRequestValidationTransfer->getIsValid()); + $this->assertSame('Body does not match schema for content-type "application/json" for Request [post /existing-endpoint]. [Keyword validation failed: Required property \'data\' must be present in the object in data]', $glueRequestValidationTransfer->getErrors()[0]->getMessage()); + } + + public function testGivenAValidOpenApiSchemaWhenTheRequestDoesNotHaveARequiredFieldInTheSecondLevelThenAnInValidValidationResponseIsReturned(): void + { + // Arrange + $appKernelConfigStub = Stub::make(AppKernelConfig::class, [ + 'getOpenApiSchemaPath' => codecept_data_dir('Fixtures/OpenApi/valid-openapi-schema.yml'), + ]); + + $appKernelFactory = Stub::make(AppKernelFactory::class, [ + 'getConfig' => $appKernelConfigStub, + ]); + + $appGlueRequestSchemaValidatorPlugin = new AppGlueRequestSchemaValidatorPlugin(); + $appGlueRequestSchemaValidatorPlugin->setFactory($appKernelFactory); + + $glueRequestTransfer = new GlueRequestTransfer(); + $glueRequestTransfer + ->setMethod('POST') + ->setPath('/existing-endpoint') + ->setMeta([ + 'X-Tenant-Identifier' => 'tenant-identifier', + 'Content-Type' => 'application/json', + ]) + ->setContent('{"data": {"foo": "bar"}}'); + + // Act + $glueRequestValidationTransfer = $appGlueRequestSchemaValidatorPlugin->validate($glueRequestTransfer); + + // Assert + $this->assertFalse($glueRequestValidationTransfer->getIsValid()); + $this->assertSame('Body does not match schema for content-type "application/json" for Request [post /existing-endpoint]. [Keyword validation failed: Required property \'attributes\' must be present in the object in data->attributes]', $glueRequestValidationTransfer->getErrors()[0]->getMessage()); + } +} diff --git a/tests/SprykerTest/Glue/AppKernel/_data/Fixtures/OpenApi/valid-openapi-schema.yml b/tests/SprykerTest/Glue/AppKernel/_data/Fixtures/OpenApi/valid-openapi-schema.yml new file mode 100644 index 0000000..fde5653 --- /dev/null +++ b/tests/SprykerTest/Glue/AppKernel/_data/Fixtures/OpenApi/valid-openapi-schema.yml @@ -0,0 +1,128 @@ +openapi: 3.0.0 +info: + version: 1.1.0 + contact: + name: Spryker + url: 'https://spryker.com/app-composition-platform' + email: support@spryker.com + title: 'Hello World' + license: + name: MIT + url: 'https://opensource.org/licenses/MIT' +servers: + - url: 'http://glue-backend.hello-world.spryker.local' + description: 'Local development endpoint' + - url: 'https://api.hello-world-testing.aop.demo-spryker.com' + description: 'Testing' + - url: 'https://api.hello-world-staging.aop.demo-spryker.com' + description: 'Staging' + +paths: + '/existing-endpoint': + get: + operationId: 'getExistingEndpoint' + summary: 'Test' + parameters: + - $ref: '#/components/parameters/tenantIdentifier' + responses: + 200: + description: 'Expected response to a valid request.' + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + 400: + description: 'Bad request.' + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorMessages' + default: + description: 'Expected response to a bad request.' + content: + text/plain: + schema: + type: string + post: + operationId: 'postExistingEndpoint' + summary: 'Test' + parameters: + - $ref: '#/components/parameters/tenantIdentifier' + requestBody: + description: 'Test' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Request' + responses: + 200: + description: 'Expected response to a valid request.' + content: + application/json: + schema: + $ref: '#/components/schemas/Response' + 400: + description: 'Bad request.' + content: + application/json: + schema: + $ref: '#/components/schemas/ApiErrorMessages' + default: + description: 'Expected response to a bad request.' + content: + text/plain: + schema: + type: string + +components: + schemas: + Request: + properties: + data: + type: object + required: + - attributes + properties: + attributes: + type: object + required: + - foo + properties: + foo: + type: string + required: + - data + Response: + properties: + configuration: + type: object + properties: + # Add your properties here + isActive: + type: boolean + ApiErrorMessages: + properties: + data: + type: array + items: + $ref: '#/components/schemas/ApiErrorMessage' + ApiErrorMessage: + properties: + code: + type: string + detail: + type: string + status: + type: string + parameters: + tenantIdentifier: + name: X-Tenant-Identifier + in: header + required: true + description: 'Identifier of the Tenant.' + schema: + type: string + examples: + local_de: + value: 1234-5678-9012-3456 diff --git a/tests/SprykerTest/Glue/AppKernel/_support/AppKernelTester.php b/tests/SprykerTest/Glue/AppKernel/_support/AppKernelTester.php index 78793b7..2bd2f61 100644 --- a/tests/SprykerTest/Glue/AppKernel/_support/AppKernelTester.php +++ b/tests/SprykerTest/Glue/AppKernel/_support/AppKernelTester.php @@ -27,6 +27,7 @@ * @method void pause() * * @SuppressWarnings(\SprykerTest\Glue\AppKernel\PHPMD) + * @method \Spryker\Glue\AppKernel\AppKernelFactory getFactory() */ class AppKernelTester extends Actor {