diff --git a/.gitignore b/.gitignore index c595ffd..48513f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -.phpunit.result.cache +/.phpunit.result.cache /composer.lock /coverage +/infection.log /vendor diff --git a/README.md b/README.md index f24b44c..3e3d5cf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# Antidot Framework Tactician Adapter +# Antidot React Framework + diff --git a/composer.json b/composer.json index 428ebe4..d7d5066 100644 --- a/composer.json +++ b/composer.json @@ -1,10 +1,10 @@ { - "name": "antidot-fw/tactician", - "description": "Tactician Command bus adapter for Antidot Framework.", + "name": "antidot-fw/react-framework", + "description": "Antidot React Framework", "keywords": [ - "psr-11", - "tacticican", - "command-bus" + "psr-15", + "react-php", + "antidot-framework" ], "type": "library", "license": "BSD-2-Clause", @@ -15,27 +15,28 @@ ], "require": { "php": "^7.4.3", - "beberlei/assert": "^3.2", - "enqueue/enqueue": "^0.10.1", + "antidot-fw/framework": "^0.1.3", "psr/container": "^1.0.0", - "psr/event-dispatcher": "^1.0" + "ramsey/uuid": "^4.1", + "react/http": "^1.2" }, "require-dev": { - "phpro/grumphp": "~0.17", + "clue/block-react": "^1.4", + "infection/infection": "^0.20", + "phpro/grumphp": "^1.0.0", "phpunit/phpunit": "^8.0 || ^9.0", - "infection/infection": "^0.17", "squizlabs/php_codesniffer": "^3.4", "symfony/var-dumper": "^5.1", - "vimeo/psalm": "^3.14" + "vimeo/psalm": "^4.4" }, "autoload": { "psr-4": { - "Antidot\\Tactician\\": "src" + "Antidot\\React\\": "src" } }, "autoload-dev": { "psr-4": { - "AntidotTest\\Tactician\\": "test" + "AntidotTest\\React\\": "test" } }, "scripts": { @@ -47,7 +48,7 @@ ], "cs-check": "phpcs src --colors", "cs-fix": "phpcbf src --colors", - "infection": "infection", + "infection": "XDEBUG_MODE=coverage infection", "psalm": "psalm", "test": "phpunit --colors=always" }, diff --git a/infection.log b/infection.log deleted file mode 100644 index d874a4b..0000000 --- a/infection.log +++ /dev/null @@ -1,11 +0,0 @@ -Escaped mutants: -================ - -Timed Out mutants: -================== - -Skipped mutants: -================ - -Not Covered mutants: -==================== diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 0329f5d..18e187e 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -1,6 +1,6 @@ - - Anti.Framework DBAL adapter coding standard + + Antidot React Framework coding standard diff --git a/phpunit.xml.dist.bak b/phpunit.xml.dist.bak deleted file mode 100644 index 14f965c..0000000 --- a/phpunit.xml.dist.bak +++ /dev/null @@ -1,17 +0,0 @@ - - - - - ./test - - - - - - ./src - - - diff --git a/psalm.xml b/psalm.xml index 02a8094..3e59f74 100644 --- a/psalm.xml +++ b/psalm.xml @@ -5,6 +5,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" + autoloader="vendor/autoload.php" > @@ -33,7 +34,6 @@ - diff --git a/src/Container/Config/ConfigProvider.php b/src/Container/Config/ConfigProvider.php index 5802b84..0dc5a2d 100644 --- a/src/Container/Config/ConfigProvider.php +++ b/src/Container/Config/ConfigProvider.php @@ -1,11 +1,20 @@ [ + 'factories' => [ + Application::class => ReactApplicationFactory::class, + ], + ] + ]; } } diff --git a/src/MiddlewarePipeline.php b/src/MiddlewarePipeline.php new file mode 100644 index 0000000..8497d10 --- /dev/null +++ b/src/MiddlewarePipeline.php @@ -0,0 +1,108 @@ + */ + public array $concurrentPipelines; + /** @var array */ + private array $middlewareCollection; + + /** + * @param array $middlewareCollection + * @param array $concurrentPipelines + */ + public function __construct( + array $middlewareCollection = [], + array $concurrentPipelines = [] + ) { + $this->concurrentPipelines = $concurrentPipelines; + $this->middlewareCollection = $middlewareCollection; + } + + public function pipe(MiddlewareInterface $middleware): void + { + $this->middlewareCollection[] = $middleware; + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + /** @var string $requestId */ + $requestId = $request->getAttribute('request_id'); + $this->setCurrentPipeline($requestId); + + return new PromiseResponse(resolve($request)->then( + function (ServerRequestInterface $request) { + /** @var string $requestId */ + $requestId = $request->getAttribute('request_id'); + try { + /** @var MiddlewareInterface $middleware */ + $middleware = $this->concurrentPipelines[$requestId]->dequeue(); + + $response = $middleware->process($request, $this); + unset($this->concurrentPipelines[$requestId]); + + return resolve($response); + } catch (Throwable $exception) { + unset($this->concurrentPipelines[$requestId]); + + return reject($exception); + } + } + )); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + /** @var ?string $requestId */ + $requestId = $request->getAttribute('request_id'); + if (!$requestId) { + $requestId = Uuid::uuid4()->toString(); + $request = $request->withAttribute('request_id', $requestId); + } + $this->setCurrentPipeline($requestId); + + return new PromiseResponse(resolve($request) + ->then(function (ServerRequestInterface $request) use ($handler) { + /** @var string $requestId */ + $requestId = $request->getAttribute('request_id'); + try { + /** @var SplQueue $queue */ + $queue = $this->concurrentPipelines[$requestId]; + $next = new NextHandler($queue, $handler); + + return resolve($next->handle($request)); + } catch (Throwable $exception) { + unset($this->concurrentPipelines[$requestId]); + + return reject($exception); + } + })); + } + + private function setCurrentPipeline(string $requestId): void + { + if (empty($this->concurrentPipelines[$requestId])) { + $queue = new SplQueue(); + foreach ($this->middlewareCollection as $middlewareName) { + $queue->enqueue($middlewareName); + } + $this->concurrentPipelines[$requestId] = $queue; + } + } +} diff --git a/src/PromiseResponse.php b/src/PromiseResponse.php new file mode 100644 index 0000000..00bfb16 --- /dev/null +++ b/src/PromiseResponse.php @@ -0,0 +1,40 @@ +promise = $promise; + } + + final public function then( + callable $onFulfilled = null, + callable $onRejected = null, + callable $onProgress = null + ): PromiseInterface { + return $this->promise->then($onFulfilled, $onRejected, $onProgress); + } +} diff --git a/src/ReactApplication.php b/src/ReactApplication.php new file mode 100644 index 0000000..a859819 --- /dev/null +++ b/src/ReactApplication.php @@ -0,0 +1,99 @@ +routeFactory = $routeFactory; + $this->middlewareFactory = $middlewareFactory; + $this->router = $router; + $this->pipeline = $pipeline; + } + + public function pipe(string $middlewareName): void + { + $this->pipeline->pipe($this->middlewareFactory->create($middlewareName)); + } + + public function get(string $uri, array $middleware, string $name): void + { + $this->route('GET', $uri, $middleware, $name); + } + + public function post(string $uri, array $middleware, string $name): void + { + $this->route('POST', $uri, $middleware, $name); + } + + public function patch(string $uri, array $middleware, string $name): void + { + $this->route('PATCH', $uri, $middleware, $name); + } + + public function put(string $uri, array $middleware, string $name): void + { + $this->route('PUT', $uri, $middleware, $name); + } + + public function delete(string $uri, array $middleware, string $name): void + { + $this->route('DELETE', $uri, $middleware, $name); + } + + public function options(string $uri, array $middleware, string $name): void + { + $this->route('OPTIONS', $uri, $middleware, $name); + } + + public function route(string $method, string $uri, array $middleware, string $name): void + { + $this->router->append( + $this->routeFactory->create([$method], $middleware, $uri, $name) + ); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return new PromiseResponse(resolve($request) + ->then( + fn(ServerRequestInterface $request): ResponseInterface => $this->pipeline->process($request, $handler) + )); + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new PromiseResponse(resolve($request) + ->then( + fn (ServerRequestInterface $request): ResponseInterface => $this->pipeline->handle($request) + )); + } + + public function run(): void + { + throw new RuntimeException('You can\'t run application out of React PHP server.'); + } +} diff --git a/src/ReactApplicationFactory.php b/src/ReactApplicationFactory.php new file mode 100644 index 0000000..ac3cc53 --- /dev/null +++ b/src/ReactApplicationFactory.php @@ -0,0 +1,30 @@ +get(Router::class); + /** @var MiddlewareFactory $middlewareFactory */ + $middlewareFactory = $container->get(MiddlewareFactory::class); + /** @var RouteFactory $routeFactory */ + $routeFactory = $container->get(RouteFactory::class); + + return new ReactApplication( + new MiddlewarePipeline(), + $router, + $middlewareFactory, + $routeFactory + ); + } +} diff --git a/test/Container/Config/ConfigProviderTest.php b/test/Container/Config/ConfigProviderTest.php index c72b28e..5e81e73 100644 --- a/test/Container/Config/ConfigProviderTest.php +++ b/test/Container/Config/ConfigProviderTest.php @@ -2,7 +2,9 @@ namespace AntidotTest\Tactician\Container\Config; -use Antidot\Tactician\Container\Config\ConfigProvider; +use Antidot\Application\Http\Application; +use Antidot\React\Container\Config\ConfigProvider; +use Antidot\React\ReactApplicationFactory; use PHPUnit\Framework\TestCase; class ConfigProviderTest extends TestCase @@ -11,5 +13,9 @@ public function testItShouldReturnTheConfigArray(): void { $configProvider = new ConfigProvider(); $this->assertIsArray($configProvider()); + $this->assertSame( + ['dependencies' => ['factories' => [Application::class => ReactApplicationFactory::class]]], + $configProvider(), + ); } } diff --git a/test/MiddlewarePipelineTest.php b/test/MiddlewarePipelineTest.php new file mode 100644 index 0000000..4a96dd5 --- /dev/null +++ b/test/MiddlewarePipelineTest.php @@ -0,0 +1,133 @@ +request = $this->createMock(ServerRequestInterface::class); + $this->middleware = $this->createMock(MiddlewareInterface::class); + } + + public function testItShouldHandleRequestThenReturnPromiseResponse(): void + { + $this->request->expects($this->exactly(2)) + ->method('getAttribute') + ->willReturn(self::REQUEST_ID); + $this->middleware->expects($this->once()) + ->method('process') + ->with( + $this->isInstanceOf(ServerRequestInterface::class), + $this->isInstanceOf(RequestHandlerInterface::class) + ); + $pipeline = new MiddlewarePipeline(); + $pipeline->pipe($this->middleware); + $promiseResponse = $pipeline->handle($this->request)->then( + function ($response) { + $this->assertInstanceOf(ResponseInterface::class, $response); + return $response; + }, + function ($error) { + $this->assertTrue(false); + return new Exception($error); + } + ); + + await(resolve($promiseResponse), Factory::create()); + } + + public function testItShouldRejectResponseWhenRequestHandlingFailed(): void + { + $this->request->expects($this->exactly(2)) + ->method('getAttribute') + ->willReturn(self::REQUEST_ID); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Error: test success.'); + $middleware = $this->createMock(MiddlewareInterface::class); + $middleware->expects($this->once()) + ->method('process') + ->with( + $this->isInstanceOf(ServerRequestInterface::class), + $this->isInstanceOf(RequestHandlerInterface::class) + ) + ->willThrowException(new Exception('Error: test success.')); + $pipeline = new MiddlewarePipeline(); + $pipeline->pipe($middleware); + $promiseResponse = $pipeline->handle($this->request); + + await(resolve($promiseResponse), Factory::create()); + } + + public function testItShouldProcessRequestThenReturnPromiseResponse(): void + { + $this->request->expects($this->exactly(2)) + ->method('getAttribute') + ->willReturn(self::REQUEST_ID); + $this->middleware->expects($this->once()) + ->method('process') + ->with( + $this->isInstanceOf(ServerRequestInterface::class), + $this->isInstanceOf(RequestHandlerInterface::class) + ); + + $pipeline = new MiddlewarePipeline(); + + $pipeline->pipe($this->middleware); + $promiseResponse = $pipeline->process( + $this->request, + $this->createMock(RequestHandlerInterface::class) + )->then( + function ($response) { + $this->assertInstanceOf(ResponseInterface::class, $response); + return $response; + }, + function ($error) { + $this->assertTrue(false, $error->getMessage()); + return new Exception($error); + } + ); + + await(resolve($promiseResponse), Factory::create()); + } + + public function testItShouldRejectResponseWhenRequestProcessingFailed(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Error: test success.'); + $middleware = $this->createMock(MiddlewareInterface::class); + $middleware->expects($this->once()) + ->method('process') + ->with( + $this->isInstanceOf(ServerRequestInterface::class), + $this->isInstanceOf(RequestHandlerInterface::class) + ) + ->willThrowException(new Exception('Error: test success.')); + $pipeline = new MiddlewarePipeline(); + $pipeline->pipe($middleware); + $promiseResponse = $pipeline->process( + new ServerRequest('GET', '/'), + $this->createMock(RequestHandlerInterface::class) + ); + + await(resolve($promiseResponse), Factory::create()); + } +} diff --git a/test/PromiseResponseTest.php b/test/PromiseResponseTest.php new file mode 100644 index 0000000..1f2cd35 --- /dev/null +++ b/test/PromiseResponseTest.php @@ -0,0 +1,61 @@ +then( + function ($response) { + $this->assertSame(self::RESPONSE_BODY, $response); + return new HtmlResponse($response); + }, + function ($error): Throwable { + $this->assertTrue(false); + return new Exception($error); + } + ); + + $promiseResponse = new PromiseResponse($promise, 'Waiting...'); + $this->assertSame(200, $promiseResponse->getStatusCode()); + $this->assertSame('Waiting...', $promiseResponse->getBody()->getContents()); + + $response = await(resolve($promiseResponse), Factory::create()); + $this->assertSame(self::RESPONSE_BODY, $response->getBody()->getContents()); + } + + public function testItShouldContainAndRejectAPromise(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage(self::RESPONSE_BODY); + $promise = reject(new Exception(self::RESPONSE_BODY)); + + $promiseResponse = new PromiseResponse($promise->then( + function ($data) { + throw $data; + }, + function (Throwable $error): Throwable { + $this->assertSame(self::RESPONSE_BODY, $error->getMessage()); + $this->assertInstanceOf(Exception::class, $error); + throw $error; + } + )); + + await(resolve($promiseResponse), Factory::create()); + } +} diff --git a/test/ReactApplicationFactoryTest.php b/test/ReactApplicationFactoryTest.php new file mode 100644 index 0000000..3078a7c --- /dev/null +++ b/test/ReactApplicationFactoryTest.php @@ -0,0 +1,35 @@ +createMock(ContainerInterface::class); + $container->expects($this->exactly(3)) + ->method('get') + ->withConsecutive( + [Router::class], + [MiddlewareFactory::class], + [RouteFactory::class], + ) + ->willReturnOnConsecutiveCalls( + $this->createMock(Router::class), + $this->createMock(MiddlewareFactory::class), + $this->createMock(RouteFactory::class) + ); + + $factory = new ReactApplicationFactory(); + $factory($container); + } +} diff --git a/test/ReactApplicationTest.php b/test/ReactApplicationTest.php new file mode 100644 index 0000000..e09b9dc --- /dev/null +++ b/test/ReactApplicationTest.php @@ -0,0 +1,133 @@ +pipeline = $this->createMock(MiddlewarePipeline::class); + $this->router = $this->createMock(Router::class); + $this->middlewareFactory = $this->createMock(MiddlewareFactory::class); + $this->routeFactory = $this->createMock(RouteFactory::class); + } + + /** @dataProvider getRoutes */ + public function testItShouldAddRoutesInSomeApplicationHttpMethod(string $method, array $params): void + { + $this->routeFactory->expects($this->once()) + ->method('create') + ->with([strtoupper($method)], $params[1], $params[0], $params[2]); + $this->router->expects($this->once()) + ->method('append'); + + $application = new ReactApplication( + $this->pipeline, + $this->router, + $this->middlewareFactory, + $this->routeFactory + ); + + $application->{$method}(...$params); + } + + public function testItShouldAddMiddlewareInApplication(): void + { + $middleware = $this->createMock(MiddlewareInterface::class); + $this->middlewareFactory->expects($this->once()) + ->method('create') + ->with('SomeMiddleware') + ->willReturn($middleware); + $this->pipeline->expects($this->once()) + ->method('pipe') + ->with($middleware); + + $application = new ReactApplication( + $this->pipeline, + $this->router, + $this->middlewareFactory, + $this->routeFactory + ); + + $application->pipe('SomeMiddleware'); + } + + public function testItShouldHandleProcessThenReturnPromiseResponse(): void + { + $handler = $this->createMock(RequestHandlerInterface::class); + $request = $this->createMock(ServerRequestInterface::class); + $this->pipeline->expects($this->once()) + ->method('process'); + + $application = new ReactApplication( + $this->pipeline, + $this->router, + $this->middlewareFactory, + $this->routeFactory + ); + + await($application->process($request, $handler), Factory::create()); + } + + public function testItShouldHandleRequestThenReturnPromiseResponse(): void + { + $request = $this->createMock(ServerRequestInterface::class); + $this->pipeline->expects($this->once()) + ->method('handle'); + + $application = new ReactApplication( + $this->pipeline, + $this->router, + $this->middlewareFactory, + $this->routeFactory + ); + + await($application->handle($request), Factory::create()); + } + + public function testItSouldFailWhenTryToRun(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('You can\'t run application out of React PHP server.'); + + $application = new ReactApplication( + $this->pipeline, + $this->router, + $this->middlewareFactory, + $this->routeFactory + ); + + $application->run(); + } + + public function getRoutes(): array + { + return [ + ['get', ['/', [], 'home']], + ['post', ['/', [], 'home']], + ['patch', ['/', [], 'home']], + ['put', ['/', [], 'home']], + ['delete', ['/', [], 'home']], + ['options', ['/', [], 'home']], + ]; + } +}