diff --git a/composer.json b/composer.json index 747c9bad3..5d10d678a 100644 --- a/composer.json +++ b/composer.json @@ -78,7 +78,7 @@ "Spiral\\Cookies\\": "src/Cookies/src", "Spiral\\Core\\": [ "src/Core/src", - "src/Hmvc/src" + "src/Hmvc/src/Core" ], "Spiral\\Csrf\\": "src/Csrf/src", "Spiral\\Debug\\": "src/Debug/src", @@ -90,6 +90,7 @@ "Spiral\\Files\\": "src/Files/src", "Spiral\\Filters\\": "src/Filters/src", "Spiral\\Http\\": "src/Http/src", + "Spiral\\Interceptors\\": "src/Hmvc/src/Interceptors", "Spiral\\Logger\\": "src/Logger/src", "Spiral\\Mailer\\": "src/Mailer/src", "Spiral\\Models\\": "src/Models/src", @@ -135,7 +136,7 @@ "rector/rector": "0.18.1", "spiral/code-style": "^1.1", "spiral/nyholm-bridge": "^1.2", - "spiral/testing": "^2.7", + "spiral/testing": "^2.8", "spiral/validator": "^1.3", "google/protobuf": "^3.25", "symplify/monorepo-builder": "^10.2.7", @@ -158,7 +159,7 @@ "Spiral\\Tests\\Cookies\\": "src/Cookies/tests", "Spiral\\Tests\\Core\\": [ "src/Core/tests", - "src/Hmvc/tests" + "src/Hmvc/tests/Core" ], "Spiral\\Tests\\Csrf\\": "src/Csrf/tests", "Spiral\\Tests\\Debug\\": "src/Debug/tests", @@ -171,6 +172,7 @@ "Spiral\\Tests\\Filters\\": "src/Filters/tests", "Spiral\\Tests\\Framework\\": "tests/Framework", "Spiral\\Tests\\Http\\": "src/Http/tests", + "Spiral\\Tests\\Interceptors\\": "src/Hmvc/tests/Interceptors", "Spiral\\Tests\\Logger\\": "src/Logger/tests", "Spiral\\Tests\\Mailer\\": "src/Mailer/tests", "Spiral\\Tests\\Models\\": "src/Models/tests", diff --git a/src/AnnotatedRoutes/composer.json b/src/AnnotatedRoutes/composer.json index bc51bbcee..9ab7af469 100644 --- a/src/AnnotatedRoutes/composer.json +++ b/src/AnnotatedRoutes/composer.json @@ -40,6 +40,7 @@ "mockery/mockery": "^1.5", "phpunit/phpunit": "^10.1", "spiral/framework": "^3.1", + "spiral/testing": "^2.8", "spiral/nyholm-bridge": "^1.2", "vimeo/psalm": "^5.9" }, diff --git a/src/AnnotatedRoutes/tests/App/App.php b/src/AnnotatedRoutes/tests/App/App.php deleted file mode 100644 index d582ac009..000000000 --- a/src/AnnotatedRoutes/tests/App/App.php +++ /dev/null @@ -1,35 +0,0 @@ -container->get(Http::class); - } - - public function getConsole(): Console - { - return $this->container->get(Console::class); - } - - public function getContainer(): Container - { - return $this->container; - } -} diff --git a/src/AnnotatedRoutes/tests/App/runtime/.gitignore b/src/AnnotatedRoutes/tests/App/runtime/.gitignore deleted file mode 100644 index d6b7ef32c..000000000 --- a/src/AnnotatedRoutes/tests/App/runtime/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/src/AnnotatedRoutes/tests/IntegrationTest.php b/src/AnnotatedRoutes/tests/IntegrationTest.php index c441ef85a..9424f463a 100644 --- a/src/AnnotatedRoutes/tests/IntegrationTest.php +++ b/src/AnnotatedRoutes/tests/IntegrationTest.php @@ -4,124 +4,35 @@ namespace Spiral\Tests\Router; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestFactoryInterface; -use Psr\Http\Message\ServerRequestInterface; -use Spiral\Tests\Router\App\App; +use Spiral\Testing\Attribute\TestScope; -class IntegrationTest extends TestCase +#[TestScope('http')] +final class IntegrationTest extends TestCase { - private App $app; - - public function setUp(): void - { - parent::setUp(); - $this->app = $this->makeApp(['DEBUG' => true]); - } - public function testRoute(): void { - $r = $this->get('/'); - $this->assertStringContainsString('index', $r->getBody()->__toString()); + $this->fakeHttp()->get('/')->assertBodySame('index'); } public function testRoute2(): void { - $r = $this->post('/'); - $this->assertStringContainsString('method', $r->getBody()->__toString()); + $this->fakeHttp()->post('/')->assertBodySame('method'); } public function testRoute3(): void { - $r = $this->get('/page/test'); - - $this->assertSame('page-test', $r->getBody()->__toString()); + $this->fakeHttp()->get('/page/test')->assertBodySame('page-test'); } public function testRoute4(): void { - $r = $this->get('/page/about'); - - $this->assertSame('about', $r->getBody()->__toString()); + $this->fakeHttp()->get('/page/about')->assertBodySame('about'); } public function testRoutesWithoutNames(): void { - $r = $this->get('/nameless'); - $this->assertSame('index', $r->getBody()->__toString()); - - $r = $this->post('/nameless'); - $this->assertSame('method', $r->getBody()->__toString()); - - $r = $this->get('/nameless/route'); - $this->assertSame('route', $r->getBody()->__toString()); - } - - public function get( - $uri, - array $query = [], - array $headers = [], - array $cookies = [] - ): ResponseInterface { - return $this->app->getHttp()->handle($this->request($uri, 'GET', $query, $headers, $cookies)); - } - - public function getWithAttributes( - $uri, - array $attributes, - array $headers = [] - ): ResponseInterface { - $r = $this->request($uri, 'GET', [], $headers, []); - foreach ($attributes as $k => $v) { - $r = $r->withAttribute($k, $v); - } - - return $this->app->getHttp()->handle($r); - } - - public function post( - $uri, - array $data = [], - array $headers = [], - array $cookies = [] - ): ResponseInterface { - return $this->app->getHttp()->handle( - $this->request($uri, 'POST', [], $headers, $cookies)->withParsedBody($data) - ); - } - - public function request( - $uri, - string $method, - array $query = [], - array $headers = [], - array $cookies = [] - ): ServerRequestInterface { - $headers = array_merge([ - 'accept-language' => 'en' - ], $headers); - - /** @var ServerRequestFactoryInterface $factory */ - $factory = $this->app->getContainer()->get(ServerRequestFactoryInterface::class); - $request = $factory->createServerRequest($method, $uri); - - foreach ($headers as $name => $value) { - $request = $request->withAddedHeader($name, $value); - } - - return $request - ->withCookieParams($cookies) - ->withQueryParams($query); - } - - public function fetchCookies(array $header) - { - $result = []; - foreach ($header as $line) { - $cookie = explode('=', $line); - $result[$cookie[0]] = rawurldecode(substr($cookie[1], 0, strpos($cookie[1], ';'))); - } - - return $result; + $this->fakeHttp()->get('/nameless')->assertBodySame('index'); + $this->fakeHttp()->post('/nameless')->assertBodySame('method'); + $this->fakeHttp()->get('/nameless/route')->assertBodySame('route'); } } diff --git a/src/AnnotatedRoutes/tests/RouteLocatorListenerTest.php b/src/AnnotatedRoutes/tests/RouteLocatorListenerTest.php index ce6751b2a..e79aa0990 100644 --- a/src/AnnotatedRoutes/tests/RouteLocatorListenerTest.php +++ b/src/AnnotatedRoutes/tests/RouteLocatorListenerTest.php @@ -4,6 +4,7 @@ namespace Spiral\Tests\Router; +use PHPUnit\Framework\TestCase; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\UriFactoryInterface; use Spiral\Attributes\Factory; diff --git a/src/AnnotatedRoutes/tests/TestCase.php b/src/AnnotatedRoutes/tests/TestCase.php index 68face2db..b3561f3fd 100644 --- a/src/AnnotatedRoutes/tests/TestCase.php +++ b/src/AnnotatedRoutes/tests/TestCase.php @@ -4,25 +4,37 @@ namespace Spiral\Tests\Router; -use PHPUnit\Framework\TestCase as BaseTestCase; -use Spiral\Boot\Environment; -use Spiral\Tests\Router\App\App; +use Spiral\Nyholm\Bootloader\NyholmBootloader; +use Spiral\Router\Bootloader\AnnotatedRoutesBootloader; +use Spiral\Testing\TestCase as BaseTestCase; /** * @requires function \Spiral\Framework\Kernel::init */ abstract class TestCase extends BaseTestCase { - /** - * @throws \Throwable - */ - protected function makeApp(array $env): App + public function defineBootloaders(): array { - $config = [ - 'root' => __DIR__ . '/App', - 'app' => __DIR__ . '/App', + return [ + NyholmBootloader::class, + AnnotatedRoutesBootloader::class, ]; + } + + public function rootDirectory(): string + { + return __DIR__; + } + + public function defineDirectories(string $root): array + { + return \array_merge(parent::defineDirectories($root), ['app' => $root . '/App']); + } + + protected function tearDown(): void + { + parent::tearDown(); - return (App::create($config, false))->run(new Environment($env)); + $this->cleanUpRuntimeDirectory(); } } diff --git a/src/AuthHttp/src/Middleware/AuthMiddleware.php b/src/AuthHttp/src/Middleware/AuthMiddleware.php index 73c5898bb..5d779a863 100644 --- a/src/AuthHttp/src/Middleware/AuthMiddleware.php +++ b/src/AuthHttp/src/Middleware/AuthMiddleware.php @@ -22,13 +22,17 @@ final class AuthMiddleware implements MiddlewareInterface { public const ATTRIBUTE = 'authContext'; + public const TOKEN_STORAGE_ATTRIBUTE = 'tokenStorage'; + /** + * @param ScopeInterface $scope Deprecated, will be removed in v4.0. + */ public function __construct( private readonly ScopeInterface $scope, private readonly ActorProviderInterface $actorProvider, private readonly TokenStorageInterface $tokenStorage, private readonly TransportRegistry $transportRegistry, - private readonly ?EventDispatcherInterface $eventDispatcher = null + private readonly ?EventDispatcherInterface $eventDispatcher = null, ) { } @@ -39,12 +43,10 @@ public function process(Request $request, RequestHandlerInterface $handler): Res { $authContext = $this->initContext($request, new AuthContext($this->actorProvider, $this->eventDispatcher)); - $response = $this->scope->runScope( - [ - AuthContextInterface::class => $authContext, - TokenStorageInterface::class => $this->tokenStorage, - ], - static fn () => $handler->handle($request->withAttribute(self::ATTRIBUTE, $authContext)) + $response = $handler->handle( + $request + ->withAttribute(self::ATTRIBUTE, $authContext) + ->withAttribute(self::TOKEN_STORAGE_ATTRIBUTE, $this->tokenStorage), ); return $this->closeContext($request, $response, $authContext); @@ -85,7 +87,7 @@ private function closeContext(Request $request, Response $response, AuthContextI return $transport->removeToken( $request, $response, - $authContext->getToken()->getID() + $authContext->getToken()->getID(), ); } @@ -93,7 +95,7 @@ private function closeContext(Request $request, Response $response, AuthContextI $request, $response, $authContext->getToken()->getID(), - $authContext->getToken()->getExpiresAt() + $authContext->getToken()->getExpiresAt(), ); } } diff --git a/src/AuthHttp/src/Middleware/AuthTransportMiddleware.php b/src/AuthHttp/src/Middleware/AuthTransportMiddleware.php index c64576732..603f085e2 100644 --- a/src/AuthHttp/src/Middleware/AuthTransportMiddleware.php +++ b/src/AuthHttp/src/Middleware/AuthTransportMiddleware.php @@ -21,6 +21,9 @@ final class AuthTransportMiddleware implements MiddlewareInterface { private readonly AuthMiddleware $authMiddleware; + /** + * @param ScopeInterface $scope Deprecated, will be removed in v4.0. + */ public function __construct( string $transportName, ScopeInterface $scope, diff --git a/src/AuthHttp/src/Middleware/AuthTransportWithStorageMiddleware.php b/src/AuthHttp/src/Middleware/AuthTransportWithStorageMiddleware.php index 92281a010..b92363eef 100644 --- a/src/AuthHttp/src/Middleware/AuthTransportWithStorageMiddleware.php +++ b/src/AuthHttp/src/Middleware/AuthTransportWithStorageMiddleware.php @@ -21,6 +21,9 @@ final class AuthTransportWithStorageMiddleware implements MiddlewareInterface { private readonly MiddlewareInterface $authMiddleware; + /** + * @param ScopeInterface $scope Deprecated, will be removed in v4.0. + */ public function __construct( string $transportName, ScopeInterface $scope, diff --git a/src/AuthHttp/tests/AuthTransportWithStorageMiddlewareTest.php b/src/AuthHttp/tests/AuthTransportWithStorageMiddlewareTest.php index 30f5a41c3..670b5a070 100644 --- a/src/AuthHttp/tests/AuthTransportWithStorageMiddlewareTest.php +++ b/src/AuthHttp/tests/AuthTransportWithStorageMiddlewareTest.php @@ -4,8 +4,6 @@ namespace Spiral\Tests\Auth; -use Mockery as m; -use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; @@ -19,54 +17,63 @@ use Spiral\Auth\TransportRegistry; use Spiral\Core\ScopeInterface; -final class AuthTransportWithStorageMiddlewareTest extends TestCase +final class AuthTransportWithStorageMiddlewareTest extends BaseTestCase { public function testProcessMiddlewareWithTokenStorageProvider(): void { - $serverRequest = m::mock(ServerRequestInterface::class); - $storageProvider = m::mock(TokenStorageProviderInterface::class); - $scope = m::mock(ScopeInterface::class); - $response = m::mock(ResponseInterface::class); + $storageProvider = $this->createMock(TokenStorageProviderInterface::class); + $storageProvider + ->expects($this->once()) + ->method('getStorage') + ->with('session') + ->willReturn($tokenStorage = $this->createMock(TokenStorageInterface::class)); - $storageProvider->shouldReceive('getStorage')->once()->with('session')->andReturn( - $tokenStorage = m::mock(TokenStorageInterface::class) - ); + $matcher = $this->exactly(2); + $request = $this->createMock(ServerRequestInterface::class); + $request + ->expects($this->exactly(2)) + ->method('withAttribute') + ->willReturnCallback(function (string $key, string $value) use ($matcher, $tokenStorage) { + match ($matcher->numberOfInvocations()) { + 1 => $this->assertInstanceOf(AuthContextInterface::class, $value), + 2 => $this->assertSame($tokenStorage, $value), + }; + }) + ->willReturnSelf(); + + $response = $this->createMock(ResponseInterface::class); $registry = new TransportRegistry(); - $registry->setTransport('header', $transport = m::mock(HttpTransportInterface::class)); + $registry->setTransport('header', $transport = $this->createMock(HttpTransportInterface::class)); - $transport->shouldReceive('fetchToken')->once()->with($serverRequest)->andReturn('fooToken'); - $transport->shouldReceive('commitToken')->once()->with($serverRequest, $response, '123', null) - ->andReturn($response); + $transport->expects($this->once())->method('fetchToken')->with($request)->willReturn('fooToken'); + $transport->expects($this->once())->method('commitToken')->with($request, $response, '123', null) + ->willReturn($response); - $tokenStorage->shouldReceive('load')->once()->with('fooToken')->andReturn( - $token = m::mock(TokenInterface::class) - ); + $tokenStorage + ->expects($this->once()) + ->method('load') + ->with('fooToken') + ->willReturn($token = $this->createMock(TokenInterface::class)); - $scope - ->shouldReceive('runScope') - ->once() - ->withArgs( - fn(array $bindings, callable $callback) => $bindings[AuthContextInterface::class] - ->getToken() instanceof $token - ) - ->andReturn($response); - - $token->shouldReceive('getID')->once()->andReturn('123'); - $token->shouldReceive('getExpiresAt')->once()->andReturnNull(); + $token->expects($this->once())->method('getID')->willReturn('123'); + $token->expects($this->once())->method('getExpiresAt')->willReturn(null); $middleware = new AuthTransportWithStorageMiddleware( 'header', - $scope, - m::mock(ActorProviderInterface::class), + $this->createMock(ScopeInterface::class), + $this->createMock(ActorProviderInterface::class), $storageProvider, $registry, storage: 'session' ); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once())->method('handle')->with($request)->willReturn($response); + $this->assertSame( $response, - $middleware->process($serverRequest, m::mock(RequestHandlerInterface::class)) + $middleware->process($request, $handler) ); } } diff --git a/src/AuthHttp/tests/BaseTestCase.php b/src/AuthHttp/tests/BaseTestCase.php index e35fff70b..fac77fa7d 100644 --- a/src/AuthHttp/tests/BaseTestCase.php +++ b/src/AuthHttp/tests/BaseTestCase.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Spiral\Core\Container; +use Spiral\Core\Options; use Spiral\Telemetry\NullTracer; use Spiral\Telemetry\TracerInterface; @@ -15,7 +16,9 @@ abstract class BaseTestCase extends TestCase protected function setUp(): void { - $this->container = new Container(); + $options = new Options(); + $options->checkScope = false; + $this->container = new Container(options: $options); $this->container->bind( TracerInterface::class, diff --git a/src/AuthHttp/tests/CookieTransportTest.php b/src/AuthHttp/tests/CookieTransportTest.php index 86fcc88d9..0409cd4c9 100644 --- a/src/AuthHttp/tests/CookieTransportTest.php +++ b/src/AuthHttp/tests/CookieTransportTest.php @@ -20,7 +20,7 @@ use Spiral\Tests\Auth\Stub\TestAuthHttpStorage; use Spiral\Tests\Auth\Stub\TestAuthHttpToken; -class CookieTransportTest extends BaseTestCase +final class CookieTransportTest extends BaseTestCase { public function testCookieToken(): void { diff --git a/src/AuthHttp/tests/HeaderTransportTest.php b/src/AuthHttp/tests/HeaderTransportTest.php index e12702206..3a05ccbee 100644 --- a/src/AuthHttp/tests/HeaderTransportTest.php +++ b/src/AuthHttp/tests/HeaderTransportTest.php @@ -4,41 +4,28 @@ namespace Spiral\Tests\Auth; -use PHPUnit\Framework\TestCase; -use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Spiral\Auth\HttpTransportInterface; use Spiral\Auth\Middleware\AuthMiddleware; use Spiral\Auth\Transport\HeaderTransport; use Spiral\Auth\TransportRegistry; -use Spiral\Core\Container; use Spiral\Http\Config\HttpConfig; use Spiral\Http\Http; use Spiral\Http\Pipeline; -use Spiral\Telemetry\NullTracer; -use Spiral\Telemetry\TracerInterface; use Spiral\Tests\Auth\Diactoros\ResponseFactory; use Nyholm\Psr7\ServerRequest; use Spiral\Tests\Auth\Stub\TestAuthHttpProvider; use Spiral\Tests\Auth\Stub\TestAuthHttpStorage; use Spiral\Tests\Auth\Stub\TestAuthHttpToken; -class HeaderTransportTest extends TestCase +final class HeaderTransportTest extends BaseTestCase { - private Container $container; - - public function setUp(): void - { - $this->container = new Container(); - $this->container->bind(TracerInterface::class, new NullTracer($this->container)); - } - public function testHeaderToken(): void { $http = $this->getCore(new HeaderTransport()); $http->setHandler( - static function (ServerRequestInterface $request, ResponseInterface $response): void { + static function (ServerRequestInterface $request): void { if ($request->getAttribute('authContext')->getToken() === null) { echo 'no token'; } else { @@ -62,7 +49,7 @@ public function testHeaderTokenWithCustomValueFormat(): void $http = $this->getCore(new HeaderTransport('Authorization', 'Bearer %s')); $http->setHandler( - static function (ServerRequestInterface $request, ResponseInterface $response): void { + static function (ServerRequestInterface $request): void { if ($request->getAttribute('authContext')->getToken() === null) { echo 'no token'; } else { @@ -85,7 +72,7 @@ public function testBadHeaderToken(): void $http = $this->getCore(new HeaderTransport()); $http->setHandler( - static function (ServerRequestInterface $request, ResponseInterface $response): void { + static function (ServerRequestInterface $request): void { if ($request->getAttribute('authContext')->getToken() === null) { echo 'no token'; } else { @@ -109,7 +96,7 @@ public function testDeleteToken(): void $http = $this->getCore(new HeaderTransport()); $http->setHandler( - static function (ServerRequestInterface $request, ResponseInterface $response): void { + static function (ServerRequestInterface $request): void { $request->getAttribute('authContext')->close(); echo 'closed'; } @@ -127,7 +114,7 @@ public function testCommitToken(): void $http = $this->getCore(new HeaderTransport()); $http->setHandler( - static function (ServerRequestInterface $request, ResponseInterface $response): void { + static function (ServerRequestInterface $request): void { $request->getAttribute('authContext')->start( new TestAuthHttpToken('new-token', ['ok' => 1]) ); @@ -144,7 +131,7 @@ public function testCommitTokenWithCustomValueFormat(): void $http = $this->getCore(new HeaderTransport('Authorization', 'Bearer %s')); $http->setHandler( - static function (ServerRequestInterface $request, ResponseInterface $response): void { + static function (ServerRequestInterface $request): void { $request->getAttribute('authContext')->start( new TestAuthHttpToken('new-token', ['ok' => 1]) ); diff --git a/src/AuthHttp/tests/Middleware/AuthMiddlewareTest.php b/src/AuthHttp/tests/Middleware/AuthMiddlewareTest.php index 8031bbf1f..efc22c800 100644 --- a/src/AuthHttp/tests/Middleware/AuthMiddlewareTest.php +++ b/src/AuthHttp/tests/Middleware/AuthMiddlewareTest.php @@ -18,7 +18,7 @@ use Spiral\Tests\Auth\Stub\TestAuthHttpProvider; use Spiral\Tests\Auth\Stub\TestAuthHttpStorage; -class AuthMiddlewareTest extends BaseTestCase +final class AuthMiddlewareTest extends BaseTestCase { public function testAttributeRead(): void { diff --git a/src/Console/src/Bootloader/ConsoleBootloader.php b/src/Console/src/Bootloader/ConsoleBootloader.php index e522837ec..b53ab06fa 100644 --- a/src/Console/src/Bootloader/ConsoleBootloader.php +++ b/src/Console/src/Bootloader/ConsoleBootloader.php @@ -9,13 +9,17 @@ use Spiral\Config\ConfiguratorInterface; use Spiral\Config\Patch\Append; use Spiral\Config\Patch\Prepend; +use Spiral\Console\CommandCore; +use Spiral\Console\CommandCoreFactory; use Spiral\Console\CommandLocatorListener; use Spiral\Console\Config\ConsoleConfig; +use Spiral\Console\Confirmation\ApplicationInProduction; use Spiral\Console\Console; use Spiral\Console\ConsoleDispatcher; use Spiral\Console\Sequence\CallableSequence; use Spiral\Console\Sequence\CommandSequence; use Spiral\Core\Attribute\Singleton; +use Spiral\Core\BinderInterface; use Spiral\Core\CoreInterceptorInterface; use Spiral\Tokenizer\Bootloader\TokenizerListenerBootloader; use Spiral\Tokenizer\TokenizerListenerRegistryInterface; @@ -40,13 +44,21 @@ public function __construct( ) { } - public function init(AbstractKernel $kernel): void + public function init(AbstractKernel $kernel, BinderInterface $binder): void { // Lowest priority $kernel->bootstrapped(static function (AbstractKernel $kernel): void { $kernel->addDispatcher(ConsoleDispatcher::class); }); + // Registering necessary scope bindings + $commandBinder = $binder->getBinder('console.command'); + $commandBinder->bindSingleton(ApplicationInProduction::class, ApplicationInProduction::class); + $commandBinder->bindSingleton(CommandCoreFactory::class, CommandCoreFactory::class); + $commandBinder->bindSingleton(CommandCore::class, CommandCore::class); + + $binder->getBinder('console')->bindSingleton(Console::class, Console::class); + $this->config->setDefaults( ConsoleConfig::CONFIG, [ diff --git a/src/Console/src/Command.php b/src/Console/src/Command.php index 110ad53d9..2a6ab2772 100644 --- a/src/Console/src/Command.php +++ b/src/Console/src/Command.php @@ -14,13 +14,19 @@ use Spiral\Console\Configurator\SignatureBasedConfigurator; use Spiral\Console\Event\CommandFinished; use Spiral\Console\Event\CommandStarting; -use Spiral\Console\Interceptor\AttributeInterceptor; use Spiral\Console\Traits\HelpersTrait; +use Spiral\Core\ContainerScope; use Spiral\Core\CoreInterceptorInterface; use Spiral\Core\CoreInterface; use Spiral\Core\Exception\ScopeException; -use Spiral\Core\InterceptableCore; +use Spiral\Core\Scope; +use Spiral\Core\ScopeInterface; +use Spiral\Core\InterceptorPipeline; use Spiral\Events\EventDispatcherAwareInterface; +use Spiral\Interceptors\Context\CallContext; +use Spiral\Interceptors\Context\Target; +use Spiral\Interceptors\HandlerInterface; +use Spiral\Interceptors\InterceptorInterface; use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -29,6 +35,7 @@ /** * Provides automatic command configuration and access to global container scope. */ +#[\Spiral\Core\Attribute\Scope('console')] abstract class Command extends SymfonyCommand implements EventDispatcherAwareInterface { use HelpersTrait; @@ -47,18 +54,18 @@ abstract class Command extends SymfonyCommand implements EventDispatcherAwareInt protected ?ContainerInterface $container = null; protected ?EventDispatcherInterface $eventDispatcher = null; - /** @var array> */ + /** @var array> */ protected array $interceptors = []; - /** {@internal} */ + /** @internal */ public function setContainer(ContainerInterface $container): void { $this->container = $container; } /** - * {@internal} - * @param array> $interceptors + * @internal + * @param array> $interceptors */ public function setInterceptors(array $interceptors): void { @@ -72,6 +79,8 @@ public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): v /** * Pass execution to "perform" method using container to resolve method dependencies. + * @final + * @TODO Change to final in v4.0 */ protected function execute(InputInterface $input, OutputInterface $output): int { @@ -81,19 +90,35 @@ protected function execute(InputInterface $input, OutputInterface $output): int $method = method_exists($this, 'perform') ? 'perform' : '__invoke'; - $core = $this->buildCore(); - try { [$this->input, $this->output] = [$this->prepareInput($input), $this->prepareOutput($input, $output)]; $this->eventDispatcher?->dispatch(new CommandStarting($this, $this->input, $this->output)); + // Executing perform method with method injection - $code = (int)$core->callAction(static::class, $method, [ - 'input' => $this->input, - 'output' => $this->output, - 'command' => $this, - ]); + $code = $this->container->get(ScopeInterface::class) + ->runScope( + new Scope( + name: 'console.command', + bindings: [ + InputInterface::class => $input, + OutputInterface::class => $output, + ], + autowire: false, + ), + function () use ($method) { + $core = $this->buildCore(); + $arguments = ['input' => $this->input, 'output' => $this->output, 'command' => $this]; + + return $core instanceof HandlerInterface + ? (int)$core->handle(new CallContext( + Target::fromPair($this, $method), + $arguments, + )) + : (int)$core->callAction(static::class, $method, $arguments); + }, + ); $this->eventDispatcher?->dispatch(new CommandFinished($this, $code, $this->input, $this->output)); @@ -103,18 +128,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - protected function buildCore(): CoreInterface + /** + * @deprecated This method will be removed in v4.0. + */ + protected function buildCore(): CoreInterface|HandlerInterface { - $core = $this->container->get(CommandCore::class); - - $interceptableCore = new InterceptableCore($core, $this->eventDispatcher); - - foreach ($this->interceptors as $interceptor) { - $interceptableCore->addInterceptor($this->container->get($interceptor)); - } - $interceptableCore->addInterceptor($this->container->get(AttributeInterceptor::class)); - - return $interceptableCore; + return ContainerScope::getContainer() + ->get(CommandCoreFactory::class) + ->make($this->interceptors, $this->eventDispatcher); } protected function prepareInput(InputInterface $input): InputInterface diff --git a/src/Console/src/CommandCore.php b/src/Console/src/CommandCore.php index 436043af3..895139bb2 100644 --- a/src/Console/src/CommandCore.php +++ b/src/Console/src/CommandCore.php @@ -4,15 +4,17 @@ namespace Spiral\Console; +use Spiral\Core\Attribute\Scope as ScopeAttribute; use Spiral\Core\CoreInterface; use Spiral\Core\InvokerInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +#[ScopeAttribute('console.command')] final class CommandCore implements CoreInterface { public function __construct( - private readonly InvokerInterface $invoker + private readonly InvokerInterface $invoker, ) { } @@ -21,10 +23,8 @@ public function __construct( */ public function callAction(string $controller, string $action, array $parameters = []): int { - $input = $parameters['input']; - $output = $parameters['output']; $command = $parameters['command']; - return (int)$this->invoker->invoke([$command, $action], \compact('input', 'output')); + return (int)$this->invoker->invoke([$command, $action]); } } diff --git a/src/Console/src/CommandCoreFactory.php b/src/Console/src/CommandCoreFactory.php new file mode 100644 index 000000000..15a70261c --- /dev/null +++ b/src/Console/src/CommandCoreFactory.php @@ -0,0 +1,44 @@ +> $interceptors + */ + public function make( + array $interceptors, + ?EventDispatcherInterface $eventDispatcher = null, + ): CoreInterface|HandlerInterface { + /** @var CommandCore $core */ + $core = $this->container->get(CommandCore::class); + + $interceptableCore = (new InterceptorPipeline($eventDispatcher))->withCore($core); + + foreach ($interceptors as $interceptor) { + $interceptableCore->addInterceptor($this->container->get($interceptor)); + } + $interceptableCore->addInterceptor($this->container->get(AttributeInterceptor::class)); + + return $interceptableCore; + } +} diff --git a/src/Console/src/Config/ConsoleConfig.php b/src/Console/src/Config/ConsoleConfig.php index 9e1963b22..6a842c383 100644 --- a/src/Console/src/Config/ConsoleConfig.php +++ b/src/Console/src/Config/ConsoleConfig.php @@ -10,6 +10,7 @@ use Spiral\Console\SequenceInterface; use Spiral\Core\CoreInterceptorInterface; use Spiral\Core\InjectableConfig; +use Spiral\Interceptors\InterceptorInterface; final class ConsoleConfig extends InjectableConfig { @@ -34,7 +35,7 @@ public function getVersion(): string } /** - * @return array> + * @return array> */ public function getInterceptors(): array { diff --git a/src/Console/src/Console.php b/src/Console/src/Console.php index e7cd5a64b..18c1718ca 100644 --- a/src/Console/src/Console.php +++ b/src/Console/src/Console.php @@ -8,7 +8,9 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Spiral\Console\Config\ConsoleConfig; use Spiral\Console\Exception\LocatorException; +use Spiral\Core\Attribute\Proxy; use Spiral\Core\Container; +use Spiral\Core\Scope; use Spiral\Core\ScopeInterface; use Spiral\Events\EventDispatcherAwareInterface; use Symfony\Component\Console\Application; @@ -22,6 +24,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface as SymfonyEventDispatcherInterface; +#[\Spiral\Core\Attribute\Scope('console')] final class Console { // Undefined response code for command (errors). See below. @@ -32,9 +35,9 @@ final class Console public function __construct( private readonly ConsoleConfig $config, private readonly ?LocatorInterface $locator = null, - private readonly ContainerInterface $container = new Container(), + #[Proxy] private readonly ContainerInterface $container = new Container(), private readonly ScopeInterface $scope = new Container(), - private readonly ?EventDispatcherInterface $dispatcher = null + private readonly ?EventDispatcherInterface $dispatcher = null, ) { } @@ -45,14 +48,11 @@ public function __construct( */ public function start(InputInterface $input = new ArgvInput(), OutputInterface $output = new ConsoleOutput()): int { - return $this->scope->runScope( - [], - fn () => $this->run( - $input->getFirstArgument() ?? 'list', - $input, - $output - )->getCode() - ); + return $this->run( + $input->getFirstArgument() ?? 'list', + $input, + $output, + )->getCode(); } /** @@ -64,7 +64,7 @@ public function start(InputInterface $input = new ArgvInput(), OutputInterface $ public function run( ?string $command, array|InputInterface $input = [], - OutputInterface $output = new BufferedOutput() + OutputInterface $output = new BufferedOutput(), ): CommandOutput { $input = \is_array($input) ? new ArrayInput($input) : $input; @@ -74,10 +74,18 @@ public function run( $input = new InputProxy($input, ['firstArgument' => $command]); } - $code = $this->scope->runScope([ - InputInterface::class => $input, - OutputInterface::class => $output, - ], fn () => $this->getApplication()->doRun($input, $output)); + /** + * @psalm-suppress InvalidArgument + */ + $code = $this->scope->runScope( + new Scope( + bindings: [ + InputInterface::class => $input, + OutputInterface::class => $output, + ], + ), + fn () => $this->getApplication()->doRun($input, $output), + ); return new CommandOutput($code ?? self::CODE_NONE, $output); } @@ -108,7 +116,7 @@ public function getApplication(): Application $static = new StaticLocator( $this->config->getCommands(), $this->config->getInterceptors(), - $this->container + $this->container, ); $this->addCommands($static->locateCommands()); @@ -161,7 +169,7 @@ private function configureIO(InputInterface $input, OutputInterface $output): vo } } - match ($shellVerbosity = (int) getenv('SHELL_VERBOSITY')) { + match ($shellVerbosity = (int)getenv('SHELL_VERBOSITY')) { -1 => $output->setVerbosity(OutputInterface::VERBOSITY_QUIET), 1 => $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE), 2 => $output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE), diff --git a/src/Console/src/StaticLocator.php b/src/Console/src/StaticLocator.php index e12eb9ca9..c023d1ae7 100644 --- a/src/Console/src/StaticLocator.php +++ b/src/Console/src/StaticLocator.php @@ -8,6 +8,7 @@ use Spiral\Console\Traits\LazyTrait; use Spiral\Core\Container; use Spiral\Core\CoreInterceptorInterface; +use Spiral\Interceptors\InterceptorInterface; use Symfony\Component\Console\Command\Command as SymfonyCommand; final class StaticLocator implements LocatorInterface @@ -16,7 +17,7 @@ final class StaticLocator implements LocatorInterface /** * @param array> $commands - * @param array> $interceptors + * @param array> $interceptors */ public function __construct( private readonly array $commands, diff --git a/src/Console/src/Traits/LazyTrait.php b/src/Console/src/Traits/LazyTrait.php index 3f5f93dbc..873ae9d42 100644 --- a/src/Console/src/Traits/LazyTrait.php +++ b/src/Console/src/Traits/LazyTrait.php @@ -7,14 +7,16 @@ use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Spiral\Console\Command as SpiralCommand; -use Spiral\Console\Config\ConsoleConfig; +use Spiral\Core\CoreInterceptorInterface; use Spiral\Events\EventDispatcherAwareInterface; +use Spiral\Interceptors\InterceptorInterface; use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Command\LazyCommand; trait LazyTrait { private ContainerInterface $container; + /** @var array> */ private array $interceptors = []; private ?EventDispatcherInterface $dispatcher = null; diff --git a/src/Cookies/src/CookieQueue.php b/src/Cookies/src/CookieQueue.php index 7b83fab91..c6bfdaea5 100644 --- a/src/Cookies/src/CookieQueue.php +++ b/src/Cookies/src/CookieQueue.php @@ -4,6 +4,9 @@ namespace Spiral\Cookies; +use Spiral\Core\Attribute\Scope; + +#[Scope('http')] final class CookieQueue { public const ATTRIBUTE = 'cookieQueue'; diff --git a/src/Cookies/tests/CookiesTest.php b/src/Cookies/tests/CookiesTest.php index 4834e330b..c87fc0d91 100644 --- a/src/Cookies/tests/CookiesTest.php +++ b/src/Cookies/tests/CookiesTest.php @@ -12,7 +12,7 @@ use Spiral\Cookies\CookieQueue; use Spiral\Cookies\Middleware\CookiesMiddleware; use Spiral\Core\Container; -use Spiral\Core\ContainerScope; +use Spiral\Core\Options; use Spiral\Encrypter\Config\EncrypterConfig; use Spiral\Encrypter\Encrypter; use Spiral\Encrypter\EncrypterFactory; @@ -22,17 +22,16 @@ use Spiral\Http\Http; use Spiral\Http\Pipeline; use Nyholm\Psr7\ServerRequest; -use Spiral\Telemetry\NullTracer; -use Spiral\Telemetry\TracerInterface; -class CookiesTest extends TestCase +final class CookiesTest extends TestCase { - private $container; + private Container $container; public function setUp(): void { - $this->container = new Container(); - $this->container->bind(TracerInterface::class, new NullTracer($this->container)); + $options = new Options(); + $options->checkScope = false; + $this->container = new Container(options: $options); $this->container->bind(CookiesConfig::class, new CookiesConfig([ 'domain' => '.%s', 'method' => CookiesConfig::COOKIE_ENCRYPT, @@ -50,22 +49,11 @@ public function setUp(): void $this->container->bind(EncrypterInterface::class, Encrypter::class); } - public function testScope(): void + public function testCookieQueueInRequestAttribute(): void { $core = $this->httpCore([CookiesMiddleware::class]); - $core->setHandler(function ($r) { - $this->assertInstanceOf( - CookieQueue::class, - ContainerScope::getContainer()->get(ServerRequestInterface::class) - ->getAttribute(CookieQueue::ATTRIBUTE) - ); - - $this->assertSame( - ContainerScope::getContainer()->get(ServerRequestInterface::class) - ->getAttribute(CookieQueue::ATTRIBUTE), - $r->getAttribute(CookieQueue::ATTRIBUTE) - ); - + $core->setHandler(function (ServerRequestInterface $r) { + $this->assertInstanceOf(CookieQueue::class, $r->getAttribute(CookieQueue::ATTRIBUTE)); return 'all good'; }); @@ -77,9 +65,8 @@ public function testScope(): void public function testSetEncryptedCookie(): void { $core = $this->httpCore([CookiesMiddleware::class]); - $core->setHandler(function ($r) { - ContainerScope::getContainer()->get(ServerRequestInterface::class) - ->getAttribute(CookieQueue::ATTRIBUTE)->set('name', 'value'); + $core->setHandler(function (ServerRequestInterface $r) { + $r->getAttribute(CookieQueue::ATTRIBUTE)->set('name', 'value'); return 'all good'; }); @@ -99,9 +86,8 @@ public function testSetEncryptedCookie(): void public function testSetNotProtectedCookie(): void { $core = $this->httpCore([CookiesMiddleware::class]); - $core->setHandler(function ($r) { - ContainerScope::getContainer()->get(ServerRequestInterface::class) - ->getAttribute(CookieQueue::ATTRIBUTE)->set('PHPSESSID', 'value'); + $core->setHandler(function (ServerRequestInterface $r) { + $r->getAttribute(CookieQueue::ATTRIBUTE)->set('PHPSESSID', 'value'); return 'all good'; }); @@ -118,11 +104,7 @@ public function testSetNotProtectedCookie(): void public function testDecrypt(): void { $core = $this->httpCore([CookiesMiddleware::class]); - $core->setHandler(function ($r) { - - /** - * @var ServerRequest $r - */ + $core->setHandler(function (ServerRequestInterface $r) { return $r->getCookieParams()['name']; }); @@ -136,11 +118,7 @@ public function testDecrypt(): void public function testDecryptArray(): void { $core = $this->httpCore([CookiesMiddleware::class]); - $core->setHandler(function ($r) { - - /** - * @var ServerRequest $r - */ + $core->setHandler(function (ServerRequestInterface $r) { return $r->getCookieParams()['name'][0]; }); @@ -154,11 +132,7 @@ public function testDecryptArray(): void public function testDecryptBroken(): void { $core = $this->httpCore([CookiesMiddleware::class]); - $core->setHandler(function ($r) { - - /** - * @var ServerRequest $r - */ + $core->setHandler(function (ServerRequestInterface $r) { return $r->getCookieParams()['name']; }); @@ -172,12 +146,9 @@ public function testDecryptBroken(): void public function testDelete(): void { $core = $this->httpCore([CookiesMiddleware::class]); - $core->setHandler(function ($r) { - ContainerScope::getContainer()->get(ServerRequestInterface::class) - ->getAttribute(CookieQueue::ATTRIBUTE)->set('name', 'value'); - - ContainerScope::getContainer()->get(ServerRequestInterface::class) - ->getAttribute(CookieQueue::ATTRIBUTE)->delete('name'); + $core->setHandler(function (ServerRequestInterface $r) { + $r->getAttribute(CookieQueue::ATTRIBUTE)->set('name', 'value'); + $r->getAttribute(CookieQueue::ATTRIBUTE)->delete('name'); return 'all good'; }); @@ -200,9 +171,8 @@ public function testUnprotected(): void ])); $core = $this->httpCore([CookiesMiddleware::class]); - $core->setHandler(function ($r) { - ContainerScope::getContainer()->get(ServerRequestInterface::class) - ->getAttribute(CookieQueue::ATTRIBUTE)->set('name', 'value'); + $core->setHandler(function (ServerRequestInterface $r) { + $r->getAttribute(CookieQueue::ATTRIBUTE)->set('name', 'value'); return 'all good'; }); @@ -225,11 +195,7 @@ public function testGetUnprotected(): void ])); $core = $this->httpCore([CookiesMiddleware::class]); - $core->setHandler(function ($r) { - - /** - * @var ServerRequest $r - */ + $core->setHandler(function (ServerRequestInterface $r) { return $r->getCookieParams()['name']; }); @@ -249,9 +215,8 @@ public function testHMAC(): void ])); $core = $this->httpCore([CookiesMiddleware::class]); - $core->setHandler(function ($r) { - ContainerScope::getContainer()->get(ServerRequestInterface::class) - ->getAttribute(CookieQueue::ATTRIBUTE)->set('name', 'value'); + $core->setHandler(function (ServerRequestInterface $r) { + $r->getAttribute(CookieQueue::ATTRIBUTE)->set('name', 'value'); return 'all good'; }); @@ -272,7 +237,7 @@ public function testHMAC(): void $this->assertSame('value', (string)$response->getBody()); } - protected function httpCore(array $middleware = []): Http + private function httpCore(array $middleware = []): Http { $config = new HttpConfig([ 'basePath' => '/', @@ -290,9 +255,9 @@ protected function httpCore(array $middleware = []): Http ); } - protected function get( + private function get( Http $core, - $uri, + string $uri, array $query = [], array $headers = [], array $cookies = [] @@ -300,8 +265,8 @@ protected function get( return $core->handle($this->request($uri, 'GET', $query, $headers, $cookies)); } - protected function request( - $uri, + private function request( + string $uri, string $method, array $query = [], array $headers = [], @@ -314,7 +279,7 @@ protected function request( ->withCookieParams($cookies); } - protected function fetchCookies(ResponseInterface $response) + private function fetchCookies(ResponseInterface $response): array { $result = []; diff --git a/src/Core/src/BinderInterface.php b/src/Core/src/BinderInterface.php index e7283b33c..41a0926c3 100644 --- a/src/Core/src/BinderInterface.php +++ b/src/Core/src/BinderInterface.php @@ -9,7 +9,7 @@ /** * Manages container bindings. * - * @method BinderInterface getBinder(?string $scope = null) + * @method BinderInterface getBinder(string|\BackedEnum|null $scope = null) * * @psalm-type TResolver = class-string|non-empty-string|object|callable|array{class-string, non-empty-string} */ diff --git a/src/Core/src/Config/DeprecationProxy.php b/src/Core/src/Config/DeprecationProxy.php index fa63fd8db..8d6bba84c 100644 --- a/src/Core/src/Config/DeprecationProxy.php +++ b/src/Core/src/Config/DeprecationProxy.php @@ -34,7 +34,7 @@ public function getInterface(): string $message = $this->message ?? \sprintf( 'Using `%s` outside of the `%s` scope is deprecated and will be impossible in version %s.', $this->interface, - $this->scope, + $this->scope instanceof \BackedEnum ? $this->scope->value : $this->scope, $this->version ); diff --git a/src/Core/src/Exception/Shared/InvalidContainerScopeException.php b/src/Core/src/Exception/Shared/InvalidContainerScopeException.php new file mode 100644 index 000000000..48952d3e7 --- /dev/null +++ b/src/Core/src/Exception/Shared/InvalidContainerScopeException.php @@ -0,0 +1,30 @@ +scope = \is_string($scopeOrContainer) + ? $scopeOrContainer + : Introspector::scopeName($scopeOrContainer); + + $req = $this->requiredScope !== null ? ", `$this->requiredScope` is required" : ''; + + parent::__construct("Unable to resolve `$id` in the `$this->scope` scope{$req}."); + } +} diff --git a/src/Core/tests/Internal/Proxy/ProxyTest.php b/src/Core/tests/Internal/Proxy/ProxyTest.php index 7e6ae64ed..33888bad4 100644 --- a/src/Core/tests/Internal/Proxy/ProxyTest.php +++ b/src/Core/tests/Internal/Proxy/ProxyTest.php @@ -12,6 +12,7 @@ use Spiral\Core\Container; use Spiral\Core\Exception\Container\ContainerException; use Spiral\Core\Scope; +use Spiral\Tests\Core\Fixtures\ScopeEnum; use Spiral\Tests\Core\Internal\Proxy\Stub\EmptyInterface; use Spiral\Tests\Core\Internal\Proxy\Stub\MockInterface; use Spiral\Tests\Core\Internal\Proxy\Stub\MockInterfaceImpl; @@ -281,6 +282,35 @@ interface: $interface, \restore_error_handler(); } + #[DataProvider('interfacesProvider')] + #[WithoutErrorHandler] + public function testDeprecationProxyConfigWithEnumScope(string $interface): void + { + \set_error_handler(static function (int $errno, string $error) use ($interface): void { + self::assertSame( + \sprintf('Using `%s` outside of the `a` scope is deprecated and will be ' . + 'impossible in version 4.0.', $interface), + $error + ); + }); + + $root = new Container(); + $root->getBinder('foo')->bindSingleton($interface, Stub\MockInterfaceImpl::class); + $root->bindSingleton($interface, new Config\DeprecationProxy($interface, true, ScopeEnum::A, '4.0')); + + $proxy = $root->get($interface); + $this->assertInstanceOf($interface, $proxy); + + $root->runScope(new Scope('foo'), static function () use ($proxy) { + $proxy->bar(name: 'foo'); // Possible to run + self::assertSame('foo', $proxy->baz('foo', 42)); + self::assertSame(123, $proxy->qux(age: 123)); + self::assertSame(69, $proxy->space(testĀ age: 69)); + }); + + \restore_error_handler(); + } + #[DataProvider('interfacesProvider')] #[WithoutErrorHandler] public function testDeprecationProxyConfigDontThrowIfNotConstructed(string $interface): void diff --git a/src/Csrf/tests/CsrfTest.php b/src/Csrf/tests/CsrfTest.php index 862b37374..9888cab99 100644 --- a/src/Csrf/tests/CsrfTest.php +++ b/src/Csrf/tests/CsrfTest.php @@ -8,6 +8,7 @@ use Psr\Http\Message\ResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Spiral\Core\Container; +use Spiral\Core\Options; use Spiral\Csrf\Config\CsrfConfig; use Spiral\Csrf\Middleware\CsrfFirewall; use Spiral\Csrf\Middleware\CsrfMiddleware; @@ -19,13 +20,15 @@ use Spiral\Telemetry\NullTracer; use Spiral\Telemetry\TracerInterface; -class CsrfTest extends TestCase +final class CsrfTest extends TestCase { private Container $container; public function setUp(): void { - $this->container = new Container(); + $options = new Options(); + $options->checkScope = false; + $this->container = new Container(options: $options); $this->container->bind( CsrfConfig::class, new CsrfConfig( @@ -37,11 +40,7 @@ public function setUp(): void ) ); - $this->container->bind( - TracerInterface::class, - new NullTracer($this->container) - ); - + $this->container->bind(TracerInterface::class, new NullTracer($this->container)); $this->container->bind( ResponseFactoryInterface::class, new TestResponseFactory(new HttpConfig(['headers' => []])) @@ -214,7 +213,7 @@ static function () { self::assertSame('all good', (string)$response->getBody()); } - protected function httpCore(array $middleware = []): Http + private function httpCore(array $middleware = []): Http { $config = new HttpConfig( [ @@ -234,9 +233,9 @@ protected function httpCore(array $middleware = []): Http ); } - protected function get( + private function get( Http $core, - $uri, + string $uri, array $query = [], array $headers = [], array $cookies = [] @@ -244,9 +243,9 @@ protected function get( return $core->handle($this->request($uri, 'GET', $query, $headers, $cookies)); } - protected function post( + private function post( Http $core, - $uri, + string $uri, array $data = [], array $headers = [], array $cookies = [] @@ -254,8 +253,8 @@ protected function post( return $core->handle($this->request($uri, 'POST', [], $headers, $cookies)->withParsedBody($data)); } - protected function request( - $uri, + private function request( + string $uri, string $method, array $query = [], array $headers = [], @@ -268,7 +267,7 @@ protected function request( ->withCookieParams($cookies); } - protected function fetchCookies(ResponseInterface $response): array + private function fetchCookies(ResponseInterface $response): array { $result = []; diff --git a/src/Events/src/Bootloader/EventsBootloader.php b/src/Events/src/Bootloader/EventsBootloader.php index bfaaccec1..8d235f8b6 100644 --- a/src/Events/src/Bootloader/EventsBootloader.php +++ b/src/Events/src/Bootloader/EventsBootloader.php @@ -4,6 +4,7 @@ namespace Spiral\Events\Bootloader; +use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Spiral\Boot\AbstractKernel; @@ -16,7 +17,7 @@ use Spiral\Core\Container\Autowire; use Spiral\Core\CoreInterceptorInterface; use Spiral\Core\FactoryInterface; -use Spiral\Core\InterceptableCore; +use Spiral\Core\InterceptorPipeline; use Spiral\Events\AutowireListenerFactory; use Spiral\Events\Config\EventsConfig; use Spiral\Events\EventDispatcher; @@ -27,6 +28,7 @@ use Spiral\Events\Processor\AttributeProcessor; use Spiral\Events\Processor\ConfigProcessor; use Spiral\Events\Processor\ProcessorInterface; +use Spiral\Interceptors\InterceptorInterface; use Spiral\Tokenizer\Bootloader\TokenizerListenerBootloader; /** @@ -93,8 +95,9 @@ public function boot( /** * @param TInterceptor $interceptor */ - public function addInterceptor(string|CoreInterceptorInterface|Container\Autowire $interceptor): void - { + public function addInterceptor( + string|InterceptorInterface|CoreInterceptorInterface|Container\Autowire $interceptor, + ): void { $this->configs->modify(EventsConfig::CONFIG, new Append('interceptors', null, $interceptor)); } @@ -104,27 +107,34 @@ private function initEventDispatcher( Container $container, FactoryInterface $factory ): void { - $core = new InterceptableCore($core); + $pipeline = (new InterceptorPipeline())->withCore($core); foreach ($config->getInterceptors() as $interceptor) { $interceptor = $this->autowire($interceptor, $container, $factory); - \assert($interceptor instanceof CoreInterceptorInterface); - $core->addInterceptor($interceptor); + \assert($interceptor instanceof CoreInterceptorInterface || $interceptor instanceof InterceptorInterface); + $pipeline->addInterceptor($interceptor); } $container->removeBinding(EventDispatcherInterface::class); - $container->bindSingleton(EventDispatcherInterface::class, new EventDispatcher($core)); + $container->bindSingleton(EventDispatcherInterface::class, new EventDispatcher($pipeline)); } + /** + * @template T of object + * + * @param class-string|Autowire|T $id + * + * @return T + * + * @throws ContainerExceptionInterface + */ private function autowire(string|object $id, ContainerInterface $container, FactoryInterface $factory): object { - if (\is_string($id)) { - $id = $container->get($id); - } elseif ($id instanceof Autowire) { - $id = $id->resolve($factory); - } - - return $id; + return match (true) { + \is_string($id) => $container->get($id), + $id instanceof Autowire => $id->resolve($factory), + default => $id, + }; } } diff --git a/src/Events/src/Config/EventsConfig.php b/src/Events/src/Config/EventsConfig.php index 897fcbcce..0e196f21c 100644 --- a/src/Events/src/Config/EventsConfig.php +++ b/src/Events/src/Config/EventsConfig.php @@ -8,11 +8,14 @@ use Spiral\Core\CoreInterceptorInterface; use Spiral\Core\InjectableConfig; use Spiral\Events\Processor\ProcessorInterface; +use Spiral\Interceptors\InterceptorInterface; /** * @psalm-type TProcessor = ProcessorInterface|class-string|Autowire * @psalm-type TListener = class-string|EventListener - * @psalm-type TInterceptor = class-string|CoreInterceptorInterface|Autowire + * @psalm-type TLegacyInterceptor = class-string|CoreInterceptorInterface|Autowire + * @psalm-type TNewInterceptor = class-string|InterceptorInterface|Autowire + * @psalm-type TInterceptor = TLegacyInterceptor|TNewInterceptor * @property array{ * processors: TProcessor[], * listeners: array, diff --git a/src/Events/src/EventDispatcher.php b/src/Events/src/EventDispatcher.php index 03ea2613a..47e5b2231 100644 --- a/src/Events/src/EventDispatcher.php +++ b/src/Events/src/EventDispatcher.php @@ -6,16 +6,26 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Spiral\Core\CoreInterface; +use Spiral\Interceptors\Context\CallContext; +use Spiral\Interceptors\Context\Target; +use Spiral\Interceptors\HandlerInterface; final class EventDispatcher implements EventDispatcherInterface { + private readonly bool $isLegacy; public function __construct( - private readonly CoreInterface $core + private readonly HandlerInterface|CoreInterface $core ) { + $this->isLegacy = !$core instanceof HandlerInterface; } public function dispatch(object $event): object { - return $this->core->callAction($event::class, 'dispatch', ['event' => $event]); + return $this->isLegacy + ? $this->core->callAction($event::class, 'dispatch', ['event' => $event]) + : $this->core->handle(new CallContext( + Target::fromPair($event, 'dispatch'), + ['event' => $event], + )); } } diff --git a/src/Filters/src/Config/FiltersConfig.php b/src/Filters/src/Config/FiltersConfig.php index 82f724ec3..5155385e5 100644 --- a/src/Filters/src/Config/FiltersConfig.php +++ b/src/Filters/src/Config/FiltersConfig.php @@ -4,7 +4,9 @@ namespace Spiral\Filters\Config; +use Spiral\Core\CoreInterceptorInterface; use Spiral\Core\InjectableConfig; +use Spiral\Interceptors\InterceptorInterface; final class FiltersConfig extends InjectableConfig { @@ -14,6 +16,9 @@ final class FiltersConfig extends InjectableConfig 'interceptors' => [], ]; + /** + * @return array> + */ public function getInterceptors(): array { return (array)($this->config['interceptors'] ?? []); diff --git a/src/Filters/src/Model/FilterProvider.php b/src/Filters/src/Model/FilterProvider.php index 65022e9e1..f8f7b4780 100644 --- a/src/Filters/src/Model/FilterProvider.php +++ b/src/Filters/src/Model/FilterProvider.php @@ -11,6 +11,9 @@ use Spiral\Filters\Model\Schema\Builder; use Spiral\Filters\Model\Schema\InputMapper; use Spiral\Filters\InputInterface; +use Spiral\Interceptors\Context\CallContext; +use Spiral\Interceptors\Context\Target; +use Spiral\Interceptors\HandlerInterface; use Spiral\Models\SchematicEntity; /** @@ -19,11 +22,13 @@ */ final class FilterProvider implements FilterProviderInterface { + private readonly bool $isLegacy; public function __construct( private readonly ContainerInterface $container, private readonly ResolverInterface $resolver, - private readonly CoreInterface $core + private readonly HandlerInterface|CoreInterface $core ) { + $this->isLegacy = !$core instanceof HandlerInterface; } public function createFilter(string $name, InputInterface $input): FilterInterface @@ -56,9 +61,12 @@ public function createFilter(string $name, InputInterface $input): FilterInterfa $errors = \array_merge($errors, $inputErrors); $entity = new SchematicEntity($data, $schema); - return $this->core->callAction($name, 'handle', [ + $args = [ 'filterBag' => new FilterBag($filter, $entity, $schema, $errors), - ]); + ]; + return $this->isLegacy + ? $this->core->callAction($name, 'handle', $args) + : $this->core->handle(new CallContext(Target::fromPair($name, 'handle'), $args)); } private function createFilterInstance(string $name): FilterInterface diff --git a/src/Filters/src/Model/Interceptor/Core.php b/src/Filters/src/Model/Interceptor/Core.php index 8ce26fab1..61afa6219 100644 --- a/src/Filters/src/Model/Interceptor/Core.php +++ b/src/Filters/src/Model/Interceptor/Core.php @@ -7,11 +7,13 @@ use Spiral\Core\CoreInterface; use Spiral\Filters\Model\FilterBag; use Spiral\Filters\Model\FilterInterface; +use Spiral\Interceptors\Context\CallContext; +use Spiral\Interceptors\HandlerInterface; /** * @psalm-type TParameters = array{filterBag: FilterBag} */ -final class Core implements CoreInterface +final class Core implements CoreInterface, HandlerInterface { /** * @param-assert TParameters $parameters @@ -22,4 +24,12 @@ public function callAction(string $controller, string $action, array $parameters return $parameters['filterBag']->filter; } + + public function handle(CallContext $context): FilterInterface + { + $args = $context->getArguments(); + \assert($args['filterBag'] instanceof FilterBag); + + return $args['filterBag']->filter; + } } diff --git a/src/Filters/tests/BaseTestCase.php b/src/Filters/tests/BaseTestCase.php index 4b6ab9695..adbf765b9 100644 --- a/src/Filters/tests/BaseTestCase.php +++ b/src/Filters/tests/BaseTestCase.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use Spiral\Core\Container; +use Spiral\Core\Options; use Spiral\Validation\ValidationInterface; use Spiral\Validation\ValidationProvider; @@ -16,7 +17,9 @@ abstract class BaseTestCase extends TestCase public function setUp(): void { - $this->container = new Container(); + $options = new Options(); + $options->checkScope = false; + $this->container = new Container(options: $options); $this->container->bindSingleton(ValidationInterface::class, ValidationProvider::class); } } diff --git a/src/Filters/tests/InputScopeTest.php b/src/Filters/tests/InputScopeTest.php index c7e8797cd..54de734d0 100644 --- a/src/Filters/tests/InputScopeTest.php +++ b/src/Filters/tests/InputScopeTest.php @@ -8,6 +8,7 @@ use Psr\Http\Message\ServerRequestInterface; use Spiral\Filter\InputScope; use Spiral\Filters\InputInterface; +use Spiral\Http\Request\InputManager; final class InputScopeTest extends BaseTestCase { @@ -16,6 +17,7 @@ public function setUp(): void parent::setUp(); $this->container->bindSingleton(InputInterface::class, InputScope::class); + $this->container->bindSingleton(InputManager::class, new InputManager($this->container)); $this->container->bindSingleton( ServerRequestInterface::class, (new ServerRequest('POST', '/test'))->withParsedBody([ diff --git a/src/Framework/Auth/AuthScope.php b/src/Framework/Auth/AuthScope.php index 4c6b4edcd..c9af68b61 100644 --- a/src/Framework/Auth/AuthScope.php +++ b/src/Framework/Auth/AuthScope.php @@ -6,15 +6,17 @@ use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; +use Spiral\Core\Attribute\Proxy; use Spiral\Core\Exception\ScopeException; /** * Provides global access to temporary authentication scope. + * @deprecated Use {@see AuthContextInterface} instead. Will be removed in v4.0. */ final class AuthScope implements AuthContextInterface { public function __construct( - private readonly ContainerInterface $container + #[Proxy] private readonly ContainerInterface $container ) { } diff --git a/src/Framework/Auth/TokenStorageScope.php b/src/Framework/Auth/TokenStorageScope.php index 078fe5c3c..642b146a7 100644 --- a/src/Framework/Auth/TokenStorageScope.php +++ b/src/Framework/Auth/TokenStorageScope.php @@ -7,14 +7,18 @@ use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use Spiral\Auth\Exception\TokenStorageException; +use Spiral\Core\Attribute\Proxy; use Spiral\Core\Attribute\Singleton; use Spiral\Core\Exception\ScopeException; +/** + * @deprecated Use {@see TokenStorageInterface} instead. Will be removed in v4.0. + */ #[Singleton] final class TokenStorageScope implements TokenStorageInterface { public function __construct( - private readonly ContainerInterface $container + #[Proxy] private readonly ContainerInterface $container ) { } diff --git a/src/Framework/Bootloader/Auth/HttpAuthBootloader.php b/src/Framework/Bootloader/Auth/HttpAuthBootloader.php index e30e6fb8b..e6dfd7716 100644 --- a/src/Framework/Bootloader/Auth/HttpAuthBootloader.php +++ b/src/Framework/Bootloader/Auth/HttpAuthBootloader.php @@ -4,8 +4,11 @@ namespace Spiral\Bootloader\Auth; +use Psr\Http\Message\ServerRequestInterface; +use Spiral\Auth\AuthContextInterface; use Spiral\Auth\Config\AuthConfig; use Spiral\Auth\HttpTransportInterface; +use Spiral\Auth\Middleware\AuthMiddleware; use Spiral\Auth\Session\TokenStorage as SessionTokenStorage; use Spiral\Auth\TokenStorageInterface; use Spiral\Auth\TokenStorageProvider; @@ -16,13 +19,19 @@ use Spiral\Boot\AbstractKernel; use Spiral\Boot\Bootloader\Bootloader; use Spiral\Boot\EnvironmentInterface; +use Spiral\Bootloader\Http\Exception\ContextualObjectNotFoundException; +use Spiral\Bootloader\Http\Exception\InvalidRequestScopeException; use Spiral\Bootloader\Http\HttpBootloader; use Spiral\Config\ConfiguratorInterface; use Spiral\Config\Patch\Append; use Spiral\Core\Attribute\Singleton; +use Spiral\Core\BinderInterface; +use Spiral\Core\Config\Proxy; use Spiral\Core\Container\Autowire; use Spiral\Core\FactoryInterface; +use Spiral\Framework\Spiral; use Spiral\Http\Config\HttpConfig; +use Spiral\Http\CurrentRequest; /** * Enables Auth middleware and http transports to read and write tokens in PSR-7 request/response. @@ -30,22 +39,57 @@ #[Singleton] final class HttpAuthBootloader extends Bootloader { - protected const DEPENDENCIES = [ - AuthBootloader::class, - HttpBootloader::class, - ]; - - protected const SINGLETONS = [ - TransportRegistry::class => [self::class, 'transportRegistry'], - TokenStorageInterface::class => [self::class, 'getDefaultTokenStorage'], - TokenStorageProviderInterface::class => TokenStorageProvider::class, - ]; - public function __construct( - private readonly ConfiguratorInterface $config + private readonly ConfiguratorInterface $config, + private readonly BinderInterface $binder, ) { } + public function defineDependencies(): array + { + return [ + AuthBootloader::class, + HttpBootloader::class, + ]; + } + + public function defineBindings(): array + { + $this->binder + ->getBinder(Spiral::Http) + ->bind( + AuthContextInterface::class, + static fn (?ServerRequestInterface $request): AuthContextInterface => + ($request ?? throw new InvalidRequestScopeException(AuthContextInterface::class)) + ->getAttribute(AuthMiddleware::ATTRIBUTE) ?? throw new ContextualObjectNotFoundException( + AuthContextInterface::class, + AuthMiddleware::ATTRIBUTE, + ) + ); + $this->binder->bind(AuthContextInterface::class, new Proxy(AuthContextInterface::class, false)); + + return []; + } + + public function defineSingletons(): array + { + // Default token storage outside of HTTP scope + $this->binder->bindSingleton( + TokenStorageInterface::class, + static fn (TokenStorageProviderInterface $provider): TokenStorageInterface => $provider->getStorage(), + ); + + // Token storage from request attribute in HTTP scope + $this->binder + ->getBinder(Spiral::Http) + ->bindSingleton(TokenStorageInterface::class, [self::class, 'getTokenStorage']); + + return [ + TransportRegistry::class => [self::class, 'transportRegistry'], + TokenStorageProviderInterface::class => TokenStorageProvider::class, + ]; + } + public function init(AbstractKernel $kernel, EnvironmentInterface $env): void { $this->config->setDefaults( @@ -119,8 +163,10 @@ private function transportRegistry(AuthConfig $config, FactoryInterface $factory /** * Get default token storage from provider */ - private function getDefaultTokenStorage(TokenStorageProviderInterface $provider): TokenStorageInterface - { - return $provider->getStorage(); + private function getTokenStorage( + TokenStorageProviderInterface $provider, + ServerRequestInterface $request + ): TokenStorageInterface { + return $request->getAttribute(AuthMiddleware::TOKEN_STORAGE_ATTRIBUTE) ?? $provider->getStorage(); } } diff --git a/src/Framework/Bootloader/DomainBootloader.php b/src/Framework/Bootloader/DomainBootloader.php index e6bdeff0c..027f2be0c 100644 --- a/src/Framework/Bootloader/DomainBootloader.php +++ b/src/Framework/Bootloader/DomainBootloader.php @@ -9,7 +9,10 @@ use Spiral\Boot\Bootloader\Bootloader; use Spiral\Core\Core; use Spiral\Core\CoreInterceptorInterface; -use Spiral\Core\InterceptableCore; +use Spiral\Core\CoreInterface; +use Spiral\Core\InterceptorPipeline; +use Spiral\Interceptors\HandlerInterface; +use Spiral\Interceptors\InterceptorInterface; /** * Configures global domain core (CoreInterface) with the set of interceptors to alter domain layer functionality. @@ -25,17 +28,18 @@ protected static function domainCore( Core $core, ContainerInterface $container, ?EventDispatcherInterface $dispatcher = null - ): InterceptableCore { - $interceptableCore = new InterceptableCore($core, $dispatcher); + ): CoreInterface&HandlerInterface { + $pipeline = (new InterceptorPipeline($dispatcher))->withCore($core); foreach (static::defineInterceptors() as $interceptor) { - if (!$interceptor instanceof CoreInterceptorInterface) { + if (!$interceptor instanceof CoreInterceptorInterface && !$interceptor instanceof InterceptorInterface) { $interceptor = $container->get($interceptor); } - $interceptableCore->addInterceptor($interceptor); + + $pipeline->addInterceptor($interceptor); } - return $interceptableCore; + return $pipeline; } /** diff --git a/src/Framework/Bootloader/Http/CookiesBootloader.php b/src/Framework/Bootloader/Http/CookiesBootloader.php index df83d98e8..0d3e1998d 100644 --- a/src/Framework/Bootloader/Http/CookiesBootloader.php +++ b/src/Framework/Bootloader/Http/CookiesBootloader.php @@ -6,26 +6,33 @@ use Psr\Http\Message\ServerRequestInterface; use Spiral\Boot\Bootloader\Bootloader; +use Spiral\Bootloader\Http\Exception\ContextualObjectNotFoundException; +use Spiral\Bootloader\Http\Exception\InvalidRequestScopeException; use Spiral\Config\ConfiguratorInterface; use Spiral\Config\Patch\Append; use Spiral\Cookies\Config\CookiesConfig; use Spiral\Cookies\CookieQueue; use Spiral\Core\Attribute\Singleton; -use Spiral\Core\Exception\ScopeException; +use Spiral\Core\BinderInterface; +use Spiral\Framework\Spiral; #[Singleton] final class CookiesBootloader extends Bootloader { - protected const BINDINGS = [ - CookieQueue::class => [self::class, 'cookieQueue'], - ]; - public function __construct( - private readonly ConfiguratorInterface $config + private readonly ConfiguratorInterface $config, + private readonly BinderInterface $binder, ) { } - public function init(HttpBootloader $http): void + public function defineBindings(): array + { + $this->binder->getBinder(Spiral::Http)->bind(CookieQueue::class, [self::class, 'cookieQueue']); + + return []; + } + + public function init(): void { $this->config->setDefaults( CookiesConfig::CONFIG, @@ -45,16 +52,15 @@ public function whitelistCookie(string $cookie): void $this->config->modify(CookiesConfig::CONFIG, new Append('excluded', null, $cookie)); } - /** - * @noRector RemoveUnusedPrivateMethodRector - */ - private function cookieQueue(ServerRequestInterface $request): CookieQueue + private function cookieQueue(?ServerRequestInterface $request): CookieQueue { - $cookieQueue = $request->getAttribute(CookieQueue::ATTRIBUTE, null); - if ($cookieQueue === null) { - throw new ScopeException('Unable to resolve CookieQueue, invalid request scope'); + if ($request === null) { + throw new InvalidRequestScopeException(CookieQueue::class); } - return $cookieQueue; + return $request->getAttribute(CookieQueue::ATTRIBUTE) ?? throw new ContextualObjectNotFoundException( + CookieQueue::class, + CookieQueue::ATTRIBUTE, + ); } } diff --git a/src/Framework/Bootloader/Http/Exception/ContextualObjectNotFoundException.php b/src/Framework/Bootloader/Http/Exception/ContextualObjectNotFoundException.php new file mode 100644 index 000000000..c2ee5c219 --- /dev/null +++ b/src/Framework/Bootloader/Http/Exception/ContextualObjectNotFoundException.php @@ -0,0 +1,31 @@ +value); + } +} diff --git a/src/Framework/Bootloader/Http/HttpBootloader.php b/src/Framework/Bootloader/Http/HttpBootloader.php index dd7d3bdd1..4ce96cae1 100644 --- a/src/Framework/Bootloader/Http/HttpBootloader.php +++ b/src/Framework/Bootloader/Http/HttpBootloader.php @@ -6,14 +6,21 @@ use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Spiral\Boot\Bootloader\Bootloader; use Spiral\Config\ConfiguratorInterface; use Spiral\Config\Patch\Append; +use Spiral\Core\Attribute\Proxy; use Spiral\Core\Attribute\Singleton; +use Spiral\Core\BinderInterface; use Spiral\Core\Container\Autowire; +use Spiral\Core\InvokerInterface; +use Spiral\Framework\Spiral; use Spiral\Http\Config\HttpConfig; +use Spiral\Http\CurrentRequest; +use Spiral\Http\Exception\HttpException; use Spiral\Http\Http; use Spiral\Http\Pipeline; use Spiral\Telemetry\Bootloader\TelemetryBootloader; @@ -25,19 +32,51 @@ #[Singleton] final class HttpBootloader extends Bootloader { - protected const DEPENDENCIES = [ - TelemetryBootloader::class, - ]; - - protected const SINGLETONS = [ - Http::class => [self::class, 'httpCore'], - ]; - public function __construct( - private readonly ConfiguratorInterface $config + private readonly ConfiguratorInterface $config, + private readonly BinderInterface $binder, ) { } + public function defineDependencies(): array + { + return [ + TelemetryBootloader::class, + ]; + } + + public function defineSingletons(): array + { + $httpBinder = $this->binder->getBinder(Spiral::Http); + + $httpBinder->bindSingleton(Http::class, [self::class, 'httpCore']); + $httpBinder->bindSingleton(CurrentRequest::class, CurrentRequest::class); + $httpBinder->bind( + ServerRequestInterface::class, + static fn (CurrentRequest $request): ServerRequestInterface => $request->get() ?? throw new HttpException( + 'Unable to resolve current server request.', + ) + ); + + /** + * @deprecated since v3.12. Will be removed in v4.0. + */ + $this->binder->bindSingleton( + Http::class, + function (InvokerInterface $invoker, #[Proxy] ContainerInterface $container): Http { + @trigger_error(\sprintf( + 'Using `%s` outside of the `%s` scope is deprecated and will be impossible in version 4.0.', + Http::class, + Spiral::Http->value + ), \E_USER_DEPRECATED); + + return $invoker->invoke([self::class, 'httpCore'], ['container' => $container]); + } + ); + + return []; + } + public function init(): void { $this->config->setDefaults( @@ -73,6 +112,9 @@ public function addInputBag(string $bag, array $config): void $this->config->modify(HttpConfig::CONFIG, new Append('inputBags', $bag, $config)); } + /** + * @deprecated since v3.12. Will be removed in v4.0 and replaced with callback. + */ protected function httpCore( HttpConfig $config, Pipeline $pipeline, diff --git a/src/Framework/Bootloader/Http/PaginationBootloader.php b/src/Framework/Bootloader/Http/PaginationBootloader.php index d74573ce2..c8355b636 100644 --- a/src/Framework/Bootloader/Http/PaginationBootloader.php +++ b/src/Framework/Bootloader/Http/PaginationBootloader.php @@ -5,16 +5,37 @@ namespace Spiral\Bootloader\Http; use Spiral\Boot\Bootloader\Bootloader; +use Spiral\Core\BinderInterface; +use Spiral\Core\Config\DeprecationProxy; +use Spiral\Framework\Spiral; use Spiral\Http\PaginationFactory; use Spiral\Pagination\PaginationProviderInterface; final class PaginationBootloader extends Bootloader { - protected const DEPENDENCIES = [ - HttpBootloader::class, - ]; + public function __construct( + private readonly BinderInterface $binder, + ) { + } - protected const SINGLETONS = [ - PaginationProviderInterface::class => PaginationFactory::class, - ]; + public function defineDependencies(): array + { + return [ + HttpBootloader::class, + ]; + } + + public function defineSingletons(): array + { + $httpRequest = $this->binder->getBinder(Spiral::HttpRequest); + $httpRequest->bindSingleton(PaginationFactory::class, PaginationFactory::class); + $httpRequest->bindSingleton(PaginationProviderInterface::class, PaginationFactory::class); + + $this->binder->bind( + PaginationProviderInterface::class, + new DeprecationProxy(PaginationProviderInterface::class, true, Spiral::HttpRequest, '4.0') + ); + + return []; + } } diff --git a/src/Framework/Bootloader/Http/RouterBootloader.php b/src/Framework/Bootloader/Http/RouterBootloader.php index 1c49a8446..b8ec10e11 100644 --- a/src/Framework/Bootloader/Http/RouterBootloader.php +++ b/src/Framework/Bootloader/Http/RouterBootloader.php @@ -11,11 +11,15 @@ use Spiral\Boot\AbstractKernel; use Spiral\Boot\Bootloader\Bootloader; use Spiral\Config\ConfiguratorInterface; +use Spiral\Core\Attribute\Proxy; +use Spiral\Core\BinderInterface; use Spiral\Core\Core; use Spiral\Core\CoreInterface; use Spiral\Core\Exception\ScopeException; use Spiral\Framework\Kernel; +use Spiral\Framework\Spiral; use Spiral\Http\Config\HttpConfig; +use Spiral\Interceptors\HandlerInterface; use Spiral\Router\GroupRegistry; use Spiral\Router\Loader\Configurator\RoutingConfigurator; use Spiral\Router\Loader\DelegatingLoader; @@ -34,28 +38,39 @@ final class RouterBootloader extends Bootloader { - protected const DEPENDENCIES = [ - HttpBootloader::class, - TelemetryBootloader::class, - ]; - - protected const SINGLETONS = [ - CoreInterface::class => Core::class, - RouterInterface::class => [self::class, 'router'], - RouteInterface::class => [self::class, 'route'], - RequestHandlerInterface::class => RouterInterface::class, - LoaderInterface::class => DelegatingLoader::class, - LoaderRegistryInterface::class => [self::class, 'initRegistry'], - GroupRegistry::class => GroupRegistry::class, - RoutingConfigurator::class => RoutingConfigurator::class, - RoutePatternRegistryInterface::class => DefaultPatternRegistry::class, - ]; - public function __construct( - private readonly ConfiguratorInterface $config + private readonly ConfiguratorInterface $config, + private readonly BinderInterface $binder, ) { } + public function defineDependencies(): array + { + return [ + HttpBootloader::class, + TelemetryBootloader::class, + ]; + } + + public function defineSingletons(): array + { + $this->binder + ->getBinder(Spiral::HttpRequest) + ->bindSingleton(RouteInterface::class, [self::class, 'route']); + + return [ + HandlerInterface::class => Core::class, + CoreInterface::class => Core::class, + RouterInterface::class => [self::class, 'router'], + RequestHandlerInterface::class => RouterInterface::class, + LoaderInterface::class => DelegatingLoader::class, + LoaderRegistryInterface::class => [self::class, 'initRegistry'], + GroupRegistry::class => GroupRegistry::class, + RoutingConfigurator::class => RoutingConfigurator::class, + RoutePatternRegistryInterface::class => DefaultPatternRegistry::class, + ]; + } + public function boot(AbstractKernel $kernel): void { $configuratorCallback = static function (RouterInterface $router, RoutingConfigurator $routes): void { @@ -79,7 +94,7 @@ public function boot(AbstractKernel $kernel): void */ private function router( UriHandler $uriHandler, - ContainerInterface $container, + #[Proxy] ContainerInterface $container, TracerInterface $tracer, ?EventDispatcherInterface $dispatcher = null ): RouterInterface { diff --git a/src/Framework/Bootloader/Http/SessionBootloader.php b/src/Framework/Bootloader/Http/SessionBootloader.php index 11d6e8b3f..4939d9ffe 100644 --- a/src/Framework/Bootloader/Http/SessionBootloader.php +++ b/src/Framework/Bootloader/Http/SessionBootloader.php @@ -4,20 +4,58 @@ namespace Spiral\Bootloader\Http; +use Psr\Http\Message\ServerRequestInterface; use Spiral\Boot\Bootloader\Bootloader; use Spiral\Boot\DirectoriesInterface; +use Spiral\Bootloader\Http\Exception\ContextualObjectNotFoundException; +use Spiral\Bootloader\Http\Exception\InvalidRequestScopeException; use Spiral\Config\ConfiguratorInterface; +use Spiral\Core\BinderInterface; +use Spiral\Core\Config\Proxy; use Spiral\Core\Container\Autowire; +use Spiral\Framework\Spiral; use Spiral\Session\Config\SessionConfig; use Spiral\Session\Handler\FileHandler; +use Spiral\Session\Middleware\SessionMiddleware; use Spiral\Session\SessionFactory; use Spiral\Session\SessionFactoryInterface; +use Spiral\Session\SessionInterface; final class SessionBootloader extends Bootloader { - protected const SINGLETONS = [ - SessionFactoryInterface::class => SessionFactory::class, - ]; + public function __construct( + private readonly BinderInterface $binder, + ) { + } + + public function defineBindings(): array + { + $this->binder + ->getBinder(Spiral::Http) + ->bind( + SessionInterface::class, + static fn (?ServerRequestInterface $request): SessionInterface => + ($request ?? throw new InvalidRequestScopeException(SessionInterface::class)) + ->getAttribute(SessionMiddleware::ATTRIBUTE) ?? throw new ContextualObjectNotFoundException( + SessionInterface::class, + SessionMiddleware::ATTRIBUTE, + ) + ); + $this->binder->bind(SessionInterface::class, new Proxy(SessionInterface::class, false)); + + return []; + } + + public function defineSingletons(): array + { + $http = $this->binder->getBinder(Spiral::Http); + $http->bindSingleton(SessionFactory::class, SessionFactory::class); + $http->bindSingleton(SessionFactoryInterface::class, SessionFactory::class); + + $this->binder->bind(SessionFactoryInterface::class, new Proxy(SessionFactoryInterface::class, true)); + + return []; + } /** * Automatically registers session starter middleware and excludes session cookie from diff --git a/src/Framework/Bootloader/Security/FiltersBootloader.php b/src/Framework/Bootloader/Security/FiltersBootloader.php index 915b2556c..38be17b5c 100644 --- a/src/Framework/Bootloader/Security/FiltersBootloader.php +++ b/src/Framework/Bootloader/Security/FiltersBootloader.php @@ -11,9 +11,10 @@ use Spiral\Config\Patch\Append; use Spiral\Core\Attribute\Singleton; use Spiral\Core\BinderInterface; +use Spiral\Core\Config\Proxy; use Spiral\Core\Container; use Spiral\Core\CoreInterceptorInterface; -use Spiral\Core\InterceptableCore; +use Spiral\Core\InterceptorPipeline; use Spiral\Filter\InputScope; use Spiral\Filters\Config\FiltersConfig; use Spiral\Filters\Model\FilterBag; @@ -28,6 +29,9 @@ use Spiral\Filters\Model\Mapper\CasterRegistry; use Spiral\Filters\Model\Mapper\CasterRegistryInterface; use Spiral\Filters\Model\Mapper\UuidCaster; +use Spiral\Framework\Spiral; +use Spiral\Http\Config\HttpConfig; +use Spiral\Http\Request\InputManager; /** * @implements Container\InjectorInterface @@ -35,12 +39,6 @@ #[Singleton] final class FiltersBootloader extends Bootloader implements Container\InjectorInterface { - protected const SINGLETONS = [ - FilterProviderInterface::class => [self::class, 'initFilterProvider'], - InputInterface::class => InputScope::class, - CasterRegistryInterface::class => [self::class, 'initCasterRegistry'], - ]; - public function __construct( private readonly ContainerInterface $container, private readonly BinderInterface $binder, @@ -48,6 +46,25 @@ public function __construct( ) { } + public function defineSingletons(): array + { + $this->binder + ->getBinder(Spiral::HttpRequest) + ->bindSingleton( + InputInterface::class, + static function (ContainerInterface $container, HttpConfig $config): InputScope { + return new InputScope(new InputManager($container, $config)); + } + ); + + $this->binder->bind(InputInterface::class, new Proxy(InputInterface::class, true)); + + return [ + FilterProviderInterface::class => [self::class, 'initFilterProvider'], + CasterRegistryInterface::class => [self::class, 'initCasterRegistry'], + ]; + } + /** * Declare Filter injection. */ @@ -94,12 +111,13 @@ private function initFilterProvider( FiltersConfig $config, ?EventDispatcherInterface $dispatcher = null ): FilterProvider { - $core = new InterceptableCore(new Core(), $dispatcher); + $pipeline = (new InterceptorPipeline($dispatcher))->withHandler(new Core()); + foreach ($config->getInterceptors() as $interceptor) { - $core->addInterceptor($container->get($interceptor)); + $pipeline->addInterceptor($container->get($interceptor)); } - return new FilterProvider($container, $container, $core); + return new FilterProvider($container, $container, $pipeline); } private function initCasterRegistry(): CasterRegistryInterface diff --git a/src/Framework/Console/CommandLocator.php b/src/Framework/Console/CommandLocator.php index d74c96d3c..2c2130e3d 100644 --- a/src/Framework/Console/CommandLocator.php +++ b/src/Framework/Console/CommandLocator.php @@ -6,6 +6,8 @@ use Psr\Container\ContainerInterface; use Spiral\Console\Traits\LazyTrait; +use Spiral\Core\CoreInterceptorInterface; +use Spiral\Interceptors\InterceptorInterface; use Spiral\Tokenizer\ScopedClassesInterface; use Symfony\Component\Console\Command\Command as SymfonyCommand; @@ -13,6 +15,9 @@ final class CommandLocator implements LocatorInterface { use LazyTrait; + /** + * @param array> $interceptors + */ public function __construct( private readonly ScopedClassesInterface $classes, ContainerInterface $container, diff --git a/src/Framework/Console/Confirmation/ApplicationInProduction.php b/src/Framework/Console/Confirmation/ApplicationInProduction.php index e6227f5ef..0e7de6231 100644 --- a/src/Framework/Console/Confirmation/ApplicationInProduction.php +++ b/src/Framework/Console/Confirmation/ApplicationInProduction.php @@ -6,9 +6,12 @@ use Spiral\Boot\Environment\AppEnvironment; use Spiral\Console\Traits\HelpersTrait; +use Spiral\Core\Attribute\Scope; +use Spiral\Framework\Spiral; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +#[Scope(Spiral::ConsoleCommand)] final class ApplicationInProduction { use HelpersTrait; diff --git a/src/Framework/Cookies/CookieManager.php b/src/Framework/Cookies/CookieManager.php index 01bedb234..4b750e6ff 100644 --- a/src/Framework/Cookies/CookieManager.php +++ b/src/Framework/Cookies/CookieManager.php @@ -7,17 +7,21 @@ use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Http\Message\ServerRequestInterface; +use Spiral\Core\Attribute\Proxy; +use Spiral\Core\Attribute\Scope; use Spiral\Core\Attribute\Singleton; use Spiral\Core\Exception\ScopeException; +use Spiral\Framework\Spiral; /** * Cookies manages provides the ability to write and read cookies from the active request/response scope. */ #[Singleton] +#[Scope(Spiral::HttpRequest)] final class CookieManager { public function __construct( - private readonly ContainerInterface $container + #[Proxy] private readonly ContainerInterface $container, ) { } diff --git a/src/Framework/Domain/PipelineInterceptor.php b/src/Framework/Domain/PipelineInterceptor.php index f8fbe4b19..6443bafce 100644 --- a/src/Framework/Domain/PipelineInterceptor.php +++ b/src/Framework/Domain/PipelineInterceptor.php @@ -13,6 +13,9 @@ use Spiral\Core\InterceptorPipeline; use Spiral\Domain\Annotation\Pipeline; +/** + * @deprecated Will be removed in future releases. + */ class PipelineInterceptor implements CoreInterceptorInterface { private array $cache = []; diff --git a/src/Framework/Filter/InputScope.php b/src/Framework/Filter/InputScope.php index 8e04df2d9..b6fd80ece 100644 --- a/src/Framework/Filter/InputScope.php +++ b/src/Framework/Filter/InputScope.php @@ -4,13 +4,16 @@ namespace Spiral\Filter; +use Spiral\Core\Attribute\Scope; use Spiral\Filters\Exception\InputException; use Spiral\Filters\InputInterface; +use Spiral\Framework\Spiral; use Spiral\Http\Request\InputManager; /** * Provides ability to use http request scope as filters input. */ +#[Scope(Spiral::HttpRequest)] final class InputScope implements InputInterface { public function __construct( diff --git a/src/Framework/Http/PaginationFactory.php b/src/Framework/Http/PaginationFactory.php index 65fc98bc6..cf644e4b9 100644 --- a/src/Framework/Http/PaginationFactory.php +++ b/src/Framework/Http/PaginationFactory.php @@ -6,8 +6,10 @@ use Psr\Container\ContainerInterface; use Psr\Http\Message\ServerRequestInterface; +use Spiral\Core\Attribute\Scope; use Spiral\Core\Exception\ScopeException; use Spiral\Core\FactoryInterface; +use Spiral\Framework\Spiral; use Spiral\Pagination\PaginationProviderInterface; use Spiral\Pagination\Paginator; use Spiral\Pagination\PaginatorInterface; @@ -15,6 +17,7 @@ /** * Paginators factory binded to active request scope in order to select page number. */ +#[Scope(Spiral::HttpRequest)] final class PaginationFactory implements PaginationProviderInterface { public function __construct( diff --git a/src/Framework/Session/Middleware/SessionMiddleware.php b/src/Framework/Session/Middleware/SessionMiddleware.php index ee134e066..47f850b43 100644 --- a/src/Framework/Session/Middleware/SessionMiddleware.php +++ b/src/Framework/Session/Middleware/SessionMiddleware.php @@ -25,6 +25,9 @@ final class SessionMiddleware implements MiddlewareInterface // Header set used to sign session private const SIGNATURE_HEADERS = ['User-Agent', 'Accept-Language', 'Accept-Encoding']; + /** + * @param ScopeInterface $scope Deprecated, will be removed in v4.0. + */ public function __construct( private readonly SessionConfig $config, private readonly HttpConfig $httpConfig, @@ -46,10 +49,7 @@ public function process(Request $request, Handler $handler): Response ); try { - $response = $this->scope->runScope( - [SessionInterface::class => $session], - static fn () => $handler->handle($request->withAttribute(self::ATTRIBUTE, $session)) - ); + $response = $handler->handle($request->withAttribute(self::ATTRIBUTE, $session)); } catch (\Throwable $e) { $session->abort(); throw $e; diff --git a/src/Framework/Session/SectionScope.php b/src/Framework/Session/SectionScope.php index 3990b9ddb..278c3c131 100644 --- a/src/Framework/Session/SectionScope.php +++ b/src/Framework/Session/SectionScope.php @@ -4,6 +4,9 @@ namespace Spiral\Session; +/** + * @deprecated Use {@see SessionInterface::getSection()} instead. Will be removed in v4.0. + */ final class SectionScope implements SessionSectionInterface { public function __construct( diff --git a/src/Framework/Session/SessionScope.php b/src/Framework/Session/SessionScope.php index 3a974ae46..9ac2508be 100644 --- a/src/Framework/Session/SessionScope.php +++ b/src/Framework/Session/SessionScope.php @@ -6,11 +6,13 @@ use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; +use Spiral\Core\Attribute\Proxy; use Spiral\Core\Attribute\Singleton; use Spiral\Core\Exception\ScopeException; /** * Provides access to the currently active session scope. + * @deprecated Use {@see SessionInterface} instead. Will be removed in v4.0. */ #[Singleton] final class SessionScope implements SessionInterface @@ -19,7 +21,7 @@ final class SessionScope implements SessionInterface private const DEFAULT_SECTION = '_DEFAULT'; public function __construct( - private readonly ContainerInterface $container + #[Proxy] private readonly ContainerInterface $container ) { } diff --git a/src/Hmvc/composer.json b/src/Hmvc/composer.json index ff3a2ee01..f55714a21 100644 --- a/src/Hmvc/composer.json +++ b/src/Hmvc/composer.json @@ -33,16 +33,19 @@ }, "require-dev": { "phpunit/phpunit": "^10.1", - "vimeo/psalm": "^5.9" + "vimeo/psalm": "^5.9", + "spiral/testing": "^2.8" }, "autoload": { "psr-4": { - "Spiral\\Core\\": "src" + "Spiral\\Core\\": "src/Core", + "Spiral\\Interceptors\\": "src/Interceptors" } }, "autoload-dev": { "psr-4": { - "Spiral\\Tests\\Core\\": "tests" + "Spiral\\Tests\\Core\\": "tests/Core", + "Spiral\\Tests\\Interceptors\\": "tests/Interceptors" } }, "extra": { diff --git a/src/Hmvc/phpunit.xml b/src/Hmvc/phpunit.xml index fbd70dd46..765a2e98c 100644 --- a/src/Hmvc/phpunit.xml +++ b/src/Hmvc/phpunit.xml @@ -1,28 +1,28 @@ - - - - tests - - - - - src - - - - - - + stderr="true" + cacheDirectory=".phpunit.cache" + backupStaticProperties="false" +> + + + tests + + + + + + + + + src + + diff --git a/src/Hmvc/psalm.xml b/src/Hmvc/psalm.xml index 3f2636b8d..d03402bf2 100644 --- a/src/Hmvc/psalm.xml +++ b/src/Hmvc/psalm.xml @@ -4,7 +4,7 @@ xmlns="https://getpsalm.org/schema/config" xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" - errorLevel="4" + errorLevel="1" hoistConstants="true" resolveFromConfigFile="true" findUnusedPsalmSuppress="true" @@ -17,4 +17,8 @@ + + + + diff --git a/src/Hmvc/src/AbstractCore.php b/src/Hmvc/src/AbstractCore.php deleted file mode 100644 index 9b473e189..000000000 --- a/src/Hmvc/src/AbstractCore.php +++ /dev/null @@ -1,90 +0,0 @@ -resolver = $container->get(ResolverInterface::class); - } - - public function callAction(string $controller, string $action, array $parameters = []): mixed - { - try { - $method = new \ReflectionMethod($controller, $action); - } catch (\ReflectionException $e) { - throw new ControllerException( - \sprintf('Invalid action `%s`->`%s`', $controller, $action), - ControllerException::BAD_ACTION, - $e - ); - } - - if ($method->isStatic() || !$method->isPublic()) { - throw new ControllerException( - \sprintf('Invalid action `%s`->`%s`', $controller, $action), - ControllerException::BAD_ACTION - ); - } - - try { - $args = $this->resolveArguments($method, $parameters); - } catch (ArgumentResolvingException|InvalidArgumentException $e) { - throw new ControllerException( - \sprintf('Missing/invalid parameter %s of `%s`->`%s`', $e->getParameter(), $controller, $action), - ControllerException::BAD_ARGUMENT, - $e - ); - } catch (ContainerExceptionInterface $e) { - throw new ControllerException( - $e->getMessage(), - ControllerException::ERROR, - $e - ); - } - - $container = $this->container; - return ContainerScope::runScope( - $container, - static fn () => $method->invokeArgs($container->get($controller), $args) - ); - } - - protected function resolveArguments(\ReflectionMethod $method, array $parameters): array - { - foreach ($method->getParameters() as $parameter) { - $name = $parameter->getName(); - if ( - \array_key_exists($name, $parameters) && - $parameters[$name] === null && - $parameter->isDefaultValueAvailable() - ) { - $parameters[$name] = $parameter->getDefaultValue(); - } - } - - // getting the set of arguments should be sent to requested method - return $this->resolver->resolveArguments($method, $parameters, validate: true); - } -} diff --git a/src/Hmvc/src/Core/AbstractCore.php b/src/Hmvc/src/Core/AbstractCore.php new file mode 100644 index 000000000..09029a8e7 --- /dev/null +++ b/src/Hmvc/src/Core/AbstractCore.php @@ -0,0 +1,112 @@ +resolver = $container instanceof ResolverInterface + ? $container + : $container + ->get(InvokerInterface::class) + ->invoke(static fn (#[Proxy] ResolverInterface $resolver) => $resolver); + } + + /** + * @psalm-assert class-string $controller + * @psalm-assert non-empty-string $action + */ + public function callAction(string $controller, string $action, array $parameters = []): mixed + { + $method = ActionResolver::pathToReflection($controller, $action); + + // Validate method + ActionResolver::validateControllerMethod($method); + + return $this->invoke(null, $controller, $method, $parameters); + } + + public function handle(CallContext $context): mixed + { + $target = $context->getTarget(); + $reflection = $target->getReflection(); + return $reflection instanceof \ReflectionMethod + ? $this->invoke($target->getObject(), $target->getPath()[0], $reflection, $context->getArguments()) + : $this->callAction($target->getPath()[0], $target->getPath()[1], $context->getArguments()); + } + + protected function resolveArguments(\ReflectionMethod $method, array $parameters): array + { + foreach ($method->getParameters() as $parameter) { + $name = $parameter->getName(); + if ( + \array_key_exists($name, $parameters) && + $parameters[$name] === null && + $parameter->isDefaultValueAvailable() + ) { + /** @psalm-suppress MixedAssignment */ + $parameters[$name] = $parameter->getDefaultValue(); + } + } + + // getting the set of arguments should be sent to requested method + return $this->resolver->resolveArguments($method, $parameters); + } + + /** + * @throws \Throwable + */ + private function invoke(?object $object, string $class, \ReflectionMethod $method, array $arguments): mixed + { + try { + $args = $this->resolveArguments($method, $arguments); + } catch (ArgumentResolvingException | InvalidArgumentException $e) { + throw new ControllerException( + \sprintf( + 'Missing/invalid parameter %s of `%s`->`%s`', + $e->getParameter(), + $class, + $method->getName(), + ), + ControllerException::BAD_ARGUMENT, + $e, + ); + } catch (ContainerExceptionInterface $e) { + throw new ControllerException( + $e->getMessage(), + ControllerException::ERROR, + $e, + ); + } + + return $method->invokeArgs($object ?? $this->container->get($class), $args); + } +} diff --git a/src/Hmvc/src/Core.php b/src/Hmvc/src/Core/Core.php similarity index 65% rename from src/Hmvc/src/Core.php rename to src/Hmvc/src/Core/Core.php index c6d848b14..d87053277 100644 --- a/src/Hmvc/src/Core.php +++ b/src/Hmvc/src/Core/Core.php @@ -6,6 +6,8 @@ /** * Simple domain core to invoke controller actions. + * + * @deprecated use {@see \Spiral\Interceptors\Handler\ReflectionHandler} instead */ final class Core extends AbstractCore { diff --git a/src/Hmvc/src/CoreInterceptorInterface.php b/src/Hmvc/src/Core/CoreInterceptorInterface.php similarity index 83% rename from src/Hmvc/src/CoreInterceptorInterface.php rename to src/Hmvc/src/Core/CoreInterceptorInterface.php index a0776cf54..b222d9226 100644 --- a/src/Hmvc/src/CoreInterceptorInterface.php +++ b/src/Hmvc/src/Core/CoreInterceptorInterface.php @@ -5,9 +5,12 @@ namespace Spiral\Core; use Spiral\Core\Exception\ControllerException; +use Spiral\Interceptors\InterceptorInterface; /** * Provides the ability to intercept and wrap the call to the domain core with all the call context. + * + * @deprecated Use {@see InterceptorInterface} instead. */ interface CoreInterceptorInterface { diff --git a/src/Hmvc/src/CoreInterface.php b/src/Hmvc/src/Core/CoreInterface.php similarity index 87% rename from src/Hmvc/src/CoreInterface.php rename to src/Hmvc/src/Core/CoreInterface.php index 2fab3f757..512e3cdfd 100644 --- a/src/Hmvc/src/CoreInterface.php +++ b/src/Hmvc/src/Core/CoreInterface.php @@ -5,9 +5,12 @@ namespace Spiral\Core; use Spiral\Core\Exception\ControllerException; +use Spiral\Interceptors\HandlerInterface; /** * General application enterpoint class. + * + * @deprecated Use {@see HandlerInterface} instead. */ interface CoreInterface { diff --git a/src/Hmvc/src/Event/InterceptorCalling.php b/src/Hmvc/src/Core/Event/InterceptorCalling.php similarity index 59% rename from src/Hmvc/src/Event/InterceptorCalling.php rename to src/Hmvc/src/Core/Event/InterceptorCalling.php index 60becba73..9757bd4a7 100644 --- a/src/Hmvc/src/Event/InterceptorCalling.php +++ b/src/Hmvc/src/Core/Event/InterceptorCalling.php @@ -5,14 +5,18 @@ namespace Spiral\Core\Event; use Spiral\Core\CoreInterceptorInterface; +use Spiral\Interceptors\InterceptorInterface; +/** + * @deprecated use {@see \Spiral\Interceptors\Event\InterceptorCalling} instead + */ final class InterceptorCalling { public function __construct( public readonly string $controller, public readonly string $action, public readonly array $parameters, - public readonly CoreInterceptorInterface $interceptor + public readonly CoreInterceptorInterface|InterceptorInterface $interceptor, ) { } } diff --git a/src/Hmvc/src/Core/Exception/ControllerException.php b/src/Hmvc/src/Core/Exception/ControllerException.php new file mode 100644 index 000000000..4aa218795 --- /dev/null +++ b/src/Hmvc/src/Core/Exception/ControllerException.php @@ -0,0 +1,28 @@ +pipeline = new InterceptorPipeline($dispatcher); } - public function addInterceptor(CoreInterceptorInterface $interceptor): void + public function addInterceptor(CoreInterceptorInterface|InterceptorInterface $interceptor): void { $this->pipeline->addInterceptor($interceptor); } @@ -29,4 +34,9 @@ public function callAction(string $controller, string $action, array $parameters { return $this->pipeline->withCore($this->core)->callAction($controller, $action, $parameters); } + + public function handle(CallContext $context): mixed + { + return $this->pipeline->withCore($this->core)->handle($context); + } } diff --git a/src/Hmvc/src/Core/InterceptorPipeline.php b/src/Hmvc/src/Core/InterceptorPipeline.php new file mode 100644 index 000000000..dc353349f --- /dev/null +++ b/src/Hmvc/src/Core/InterceptorPipeline.php @@ -0,0 +1,120 @@ + */ + private array $interceptors = []; + + private int $position = 0; + private ?CallContext $context = null; + + public function __construct( + private readonly ?EventDispatcherInterface $dispatcher = null + ) { + } + + public function addInterceptor(CoreInterceptorInterface|InterceptorInterface $interceptor): void + { + $this->interceptors[] = $interceptor; + } + + public function withCore(CoreInterface $core): self + { + $pipeline = clone $this; + $pipeline->core = $core; + $pipeline->handler = null; + return $pipeline; + } + + public function withHandler(HandlerInterface $handler): self + { + $pipeline = clone $this; + $pipeline->handler = $handler; + $pipeline->core = null; + return $pipeline; + } + + /** + * @throws \Throwable + */ + public function callAction(string $controller, string $action, array $parameters = []): mixed + { + if ($this->context === null) { + return $this->handle( + new CallContext(Target::fromPathArray([$controller, $action]), $parameters), + ); + } + + if ($this->context->getTarget()->getPath() === [$controller, $action]) { + return $this->handle($this->context->withArguments($parameters)); + } + + return $this->handle( + $this->context->withTarget( + Target::fromPathArray([$controller, $action]), + )->withArguments($parameters) + ); + } + + /** + * @throws \Throwable + */ + public function handle(CallContext $context): mixed + { + if ($this->core === null && $this->handler === null) { + throw new InterceptorException('Unable to invoke pipeline without last handler.'); + } + + $path = $context->getTarget()->getPath(); + + if (isset($this->interceptors[$this->position])) { + $interceptor = $this->interceptors[$this->position]; + $handler = $this->nextWithContext($context); + + $this->dispatcher?->dispatch( + new InterceptorCalling( + controller: $path[0] ?? '', + action: $path[1] ?? '', + parameters: $context->getArguments(), + interceptor: $interceptor, + ) + ); + + return $interceptor instanceof CoreInterceptorInterface + ? $interceptor->process($path[0] ?? '', $path[1] ?? '', $context->getArguments(), $handler) + : $interceptor->intercept($context, $handler); + } + + return $this->core === null + ? $this->handler->handle($context) + : $this->core->callAction($path[0] ?? '', $path[1] ?? '', $context->getArguments()); + } + + private function nextWithContext(CallContext $context): self + { + $pipeline = clone $this; + $pipeline->context = $context; + ++$pipeline->position; + return $pipeline; + } +} diff --git a/src/Hmvc/src/Exception/ControllerException.php b/src/Hmvc/src/Exception/ControllerException.php deleted file mode 100644 index 2b9e52727..000000000 --- a/src/Hmvc/src/Exception/ControllerException.php +++ /dev/null @@ -1,21 +0,0 @@ -interceptors[] = $interceptor; - } - - public function withCore(CoreInterface $core): self - { - $pipeline = clone $this; - $pipeline->core = $core; - - return $pipeline; - } - - /** - * @throws \Throwable - */ - public function callAction(string $controller, string $action, array $parameters = []): mixed - { - if ($this->core === null) { - throw new InterceptorException('Unable to invoke pipeline without assigned core'); - } - - $position = $this->position++; - if (isset($this->interceptors[$position])) { - $interceptor = $this->interceptors[$position]; - $this->dispatcher?->dispatch(new InterceptorCalling( - controller: $controller, - action: $action, - parameters: $parameters, - interceptor: $interceptor - )); - - return $interceptor->process($controller, $action, $parameters, $this); - } - - return $this->core->callAction($controller, $action, $parameters); - } -} diff --git a/src/Hmvc/src/Interceptors/Context/AttributedInterface.php b/src/Hmvc/src/Interceptors/Context/AttributedInterface.php new file mode 100644 index 000000000..bf0b09d27 --- /dev/null +++ b/src/Hmvc/src/Interceptors/Context/AttributedInterface.php @@ -0,0 +1,56 @@ + Attributes derived from the context. + */ + public function getAttributes(): array; + + /** + * Retrieve a single derived context attribute. + * + * Retrieves a single derived context attribute as described in {@see getAttributes()}. + * If the attribute has not been previously set, returns the default value as provided. + * + * This method obviates the need for a {@see hasAttribute()} method, as it allows + * specifying a default value to return if the attribute is not found. + * + * @param non-empty-string $name The attribute name. + * @param mixed $default Default value to return if the attribute does not exist. + */ + public function getAttribute(string $name, mixed $default = null): mixed; + + /** + * Return an instance with the specified attribute. + * + * This method allows setting a single context attribute as + * described in {@see getAttributes()}. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated attribute. + * + * @param non-empty-string $name The attribute name. + * @param mixed $value The value of the attribute. + */ + public function withAttribute(string $name, mixed $value): static; + + /** + * Return an instance that removes the specified context attribute. + * + * This method allows removing a single context attribute as described in {@see getAttributes()}. + * + * @param non-empty-string $name The attribute name. + */ + public function withoutAttribute(string $name): static; +} diff --git a/src/Hmvc/src/Interceptors/Context/AttributedTrait.php b/src/Hmvc/src/Interceptors/Context/AttributedTrait.php new file mode 100644 index 000000000..0f79a949f --- /dev/null +++ b/src/Hmvc/src/Interceptors/Context/AttributedTrait.php @@ -0,0 +1,50 @@ + */ + private array $attributes = []; + + /** + * @return array Attributes derived from the context. + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * @param non-empty-string $name + */ + public function getAttribute(string $name, mixed $default = null): mixed + { + return $this->attributes[$name] ?? $default; + } + + /** + * @param non-empty-string $name + */ + public function withAttribute(string $name, mixed $value): static + { + $clone = clone $this; + $clone->attributes[$name] = $value; + return $clone; + } + + /** + * @param non-empty-string $name + */ + public function withoutAttribute(string $name): static + { + $clone = clone $this; + unset($clone->attributes[$name]); + return $clone; + } +} diff --git a/src/Hmvc/src/Interceptors/Context/CallContext.php b/src/Hmvc/src/Interceptors/Context/CallContext.php new file mode 100644 index 000000000..36a3da034 --- /dev/null +++ b/src/Hmvc/src/Interceptors/Context/CallContext.php @@ -0,0 +1,45 @@ + $attributes + */ + public function __construct( + private TargetInterface $target, + private array $arguments = [], + array $attributes = [], + ) { + $this->attributes = $attributes; + } + + public function getTarget(): TargetInterface + { + return $this->target; + } + + public function getArguments(): array + { + return $this->arguments; + } + + public function withTarget(TargetInterface $target): static + { + $clone = clone $this; + $clone->target = $target; + return $clone; + } + + public function withArguments(array $arguments): static + { + $clone = clone $this; + $clone->arguments = $arguments; + return $clone; + } +} diff --git a/src/Hmvc/src/Interceptors/Context/CallContextInterface.php b/src/Hmvc/src/Interceptors/Context/CallContextInterface.php new file mode 100644 index 000000000..8f9aa5fc5 --- /dev/null +++ b/src/Hmvc/src/Interceptors/Context/CallContextInterface.php @@ -0,0 +1,16 @@ + + */ +final class Target implements TargetInterface +{ + /** + * @param list $path + * @param \ReflectionFunctionAbstract|null $reflection + * @param TController|null $object + */ + private function __construct( + private array $path, + private ?\ReflectionFunctionAbstract $reflection = null, + private readonly ?object $object = null, + private string $delimiter = '.', + ) { + } + + public function __toString(): string + { + return \implode($this->delimiter, $this->path); + } + + /** + * Create a target from a method reflection. + * + * @template T of object + * + * @param \ReflectionMethod $reflection + * @param class-string|T $classOrObject The original class name or object. + * It's required because the reflection may be referring to a parent class method. + * THe path will contain the original class name and the method name. + * + * @psalmif + * + * @return self + */ + public static function fromReflectionMethod( + \ReflectionFunctionAbstract $reflection, + string|object $classOrObject, + ): self { + /** @var self $result */ + $result = \is_object($classOrObject) + ? new self( + path: [$classOrObject::class, $reflection->getName()], + reflection: $reflection, + object: $classOrObject, + delimiter: $reflection->isStatic() ? '::' : '->', + ) + : new self( + path: [$classOrObject, $reflection->getName()], + reflection: $reflection, + delimiter: $reflection->isStatic() ? '::' : '->', + ); + return $result; + } + + /** + * Create a target from a function reflection. + * + * @param list $path + * + * @return self + */ + public static function fromReflectionFunction(\ReflectionFunction $reflection, array $path = []): self + { + /** @var self $result */ + $result = new self(path: $path, reflection: $reflection); + return $result; + } + + /** + * Create a target from a closure. + * + * @param list $path + * + * @return self + */ + public static function fromClosure(\Closure $closure, array $path = []): self + { + return self::fromReflectionFunction(new \ReflectionFunction($closure), $path); + } + + /** + * Create a target from a path string without reflection. + * + * @param non-empty-string $delimiter + * + * @return self + */ + public static function fromPathString(string $path, string $delimiter = '.'): self + { + return self::fromPathArray(\explode($delimiter, $path), $delimiter); + } + + /** + * Create a target from a path array without reflection. + * + * @param list $path + * @return self + */ + public static function fromPathArray(array $path, string $delimiter = '.'): self + { + /** @var self $result */ + $result = new self(path: $path, delimiter: $delimiter); + return $result; + } + + /** + * Create a target from a controller and action pair. + * If the action is a method of the controller, the reflection will be set. + * + * @template T of object + * + * @param non-empty-string|class-string|T $controller + * @param non-empty-string $action + * + * @return ($controller is class-string|T ? self : self) + */ + public static function fromPair(string|object $controller, string $action): self + { + /** @psalm-suppress ArgumentTypeCoercion */ + if (\is_object($controller) || \method_exists($controller, $action)) { + /** @var T|class-string $controller */ + return self::fromReflectionMethod(new \ReflectionMethod($controller, $action), $controller); + } + + return self::fromPathArray([$controller, $action]); + } + + public function getPath(): array + { + return $this->path; + } + + public function withPath(array $path, ?string $delimiter = null): static + { + $clone = clone $this; + $clone->path = $path; + $clone->delimiter = $delimiter ?? $clone->delimiter; + return $clone; + } + + public function getReflection(): ?\ReflectionFunctionAbstract + { + return $this->reflection; + } + + public function getObject(): ?object + { + return $this->object; + } +} diff --git a/src/Hmvc/src/Interceptors/Context/TargetInterface.php b/src/Hmvc/src/Interceptors/Context/TargetInterface.php new file mode 100644 index 000000000..fbf4b097b --- /dev/null +++ b/src/Hmvc/src/Interceptors/Context/TargetInterface.php @@ -0,0 +1,54 @@ +|list{class-string, non-empty-string} + */ + public function getPath(): array; + + /** + * @param list $path + * @param string|null $delimiter The delimiter to use when converting the path to a string. + */ + public function withPath(array $path, ?string $delimiter = null): static; + + /** + * Returns the reflection of the target. + * + * It may be {@see \ReflectionFunction} or {@see \ReflectionMethod}. + * + * NOTE: + * The method {@see \ReflectionMethod::getDeclaringClass()} may return a parent class, + * but not the class used when the target was created. + * Use {@see getObject()} or {@see Target::getPath()}[0] to get the original object or class name. + * + * @psalm-pure + */ + public function getReflection(): ?\ReflectionFunctionAbstract; + + /** + * Returns the object associated with the target. + * + * If the object is present, it always corresponds to the method reflection from {@see getReflection()}. + * + * @return TController|null + */ + public function getObject(): ?object; +} diff --git a/src/Hmvc/src/Interceptors/Event/InterceptorCalling.php b/src/Hmvc/src/Interceptors/Event/InterceptorCalling.php new file mode 100644 index 000000000..23652b489 --- /dev/null +++ b/src/Hmvc/src/Interceptors/Event/InterceptorCalling.php @@ -0,0 +1,17 @@ + */ + private array $interceptors = []; + + private int $position = 0; + + public function __construct( + private readonly ?EventDispatcherInterface $dispatcher = null + ) { + } + + public function addInterceptor(InterceptorInterface $interceptor): void + { + $this->interceptors[] = $interceptor; + } + + public function withHandler(HandlerInterface $handler): self + { + $pipeline = clone $this; + $pipeline->handler = $handler; + return $pipeline; + } + + /** + * @throws \Throwable + */ + public function handle(CallContext $context): mixed + { + if ($this->handler === null) { + throw new InterceptorException('Unable to invoke pipeline without last handler.'); + } + + if (isset($this->interceptors[$this->position])) { + $interceptor = $this->interceptors[$this->position]; + + $this->dispatcher?->dispatch(new InterceptorCalling(context: $context, interceptor: $interceptor)); + + return $interceptor->intercept($context, $this->next()); + } + + return $this->handler->handle($context); + } + + private function next(): self + { + $pipeline = clone $this; + ++$pipeline->position; + return $pipeline; + } +} diff --git a/src/Hmvc/src/Interceptors/Handler/ReflectionHandler.php b/src/Hmvc/src/Interceptors/Handler/ReflectionHandler.php new file mode 100644 index 000000000..ff65a0c6a --- /dev/null +++ b/src/Hmvc/src/Interceptors/Handler/ReflectionHandler.php @@ -0,0 +1,84 @@ +getTarget()->getReflection(); + $path = $context->getTarget()->getPath(); + if ($method === null) { + $this->resolveFromPath or throw new TargetCallException( + "Reflection not provided for target `{$context->getTarget()}`.", + TargetCallException::NOT_FOUND, + ); + + if (\count($path) !== 2) { + throw new TargetCallException( + "Invalid target path to resolve reflection for `{$context->getTarget()}`." + . ' Expected two parts: class and method.', + TargetCallException::NOT_FOUND, + ); + } + + $method = ActionResolver::pathToReflection(\reset($path), \next($path)); + } + + if ($method instanceof \ReflectionFunction) { + return $method->invokeArgs( + $this->resolveArguments($method, $context) + ); + } + + if (!$method instanceof \ReflectionMethod) { + throw new TargetCallException("Action not found for target `{$context->getTarget()}`."); + } + + $controller = $context->getTarget()->getObject() ?? $this->container->get($path[0]); + + // Validate method and controller + ActionResolver::validateControllerMethod($method, $controller); + + // Run action + return $method->invokeArgs($controller, $this->resolveArguments($method, $context)); + } + + /** + * @throws ContainerExceptionInterface + */ + protected function resolveArguments(\ReflectionFunctionAbstract $method, CallContext $context): array + { + $resolver = $this->container->get(ResolverInterface::class); + \assert($resolver instanceof ResolverInterface); + + return $resolver->resolveArguments($method, $context->getArguments()); + } +} diff --git a/src/Hmvc/src/Interceptors/HandlerInterface.php b/src/Hmvc/src/Interceptors/HandlerInterface.php new file mode 100644 index 000000000..a77224327 --- /dev/null +++ b/src/Hmvc/src/Interceptors/HandlerInterface.php @@ -0,0 +1,12 @@ +`%s`', $controller, $action), + TargetCallException::BAD_ACTION, + $e + ); + } + + return $method; + } + + /** + * @throws TargetCallException + * @psalm-assert object|null $controller + */ + public static function validateControllerMethod(\ReflectionMethod $method, mixed $controller = null): void + { + if ($method->isStatic() || !$method->isPublic()) { + throw new TargetCallException( + \sprintf( + 'Invalid action `%s`->`%s`', + $method->getDeclaringClass()->getName(), + $method->getName(), + ), + TargetCallException::BAD_ACTION + ); + } + + if ($controller === null) { + return; + } + + if (!\is_object($controller) || !$method->getDeclaringClass()->isInstance($controller)) { + throw new TargetCallException( + \sprintf( + 'Invalid controller. Expected instance of `%s`, got `%s`.', + $method->getDeclaringClass()->getName(), + \get_debug_type($controller), + ), + TargetCallException::INVALID_CONTROLLER, + ); + } + } +} diff --git a/src/Hmvc/tests/CoreTest.php b/src/Hmvc/tests/Core/CoreTest.php similarity index 63% rename from src/Hmvc/tests/CoreTest.php rename to src/Hmvc/tests/Core/CoreTest.php index 37863db8e..d45c15302 100644 --- a/src/Hmvc/tests/CoreTest.php +++ b/src/Hmvc/tests/Core/CoreTest.php @@ -4,18 +4,26 @@ namespace Spiral\Tests\Core; -use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; use Spiral\Core\Container; use Spiral\Core\Exception\ControllerException; +use Spiral\Core\ResolverInterface; +use Spiral\Interceptors\Context\CallContext; +use Spiral\Interceptors\Context\Target; +use Spiral\Interceptors\Handler\ReflectionHandler; +use Spiral\Testing\Attribute\TestScope; +use Spiral\Testing\TestCase; use Spiral\Tests\Core\Fixtures\CleanController; use Spiral\Tests\Core\Fixtures\DummyController; use Spiral\Tests\Core\Fixtures\SampleCore; +use Spiral\Tests\Core\Fixtures\TestService; -class CoreTest extends TestCase +#[TestScope('http')] +final class CoreTest extends TestCase { public function testCallAction(): void { - $core = new SampleCore(new Container()); + $core = new SampleCore($this->getContainer()); $this->assertSame('Hello, Antony.', $core->callAction( DummyController::class, 'index', @@ -25,7 +33,7 @@ public function testCallAction(): void public function testCallActionDefaultParameter(): void { - $core = new SampleCore(new Container()); + $core = new SampleCore($this->getContainer()); $this->assertSame('Hello, Dave.', $core->callAction( DummyController::class, 'index' @@ -34,7 +42,7 @@ public function testCallActionDefaultParameter(): void public function testCallActionDefaultAction(): void { - $core = new SampleCore(new Container()); + $core = new SampleCore($this->getContainer()); $this->assertSame('Hello, Dave.', $core->callAction( DummyController::class, 'index' @@ -43,7 +51,7 @@ public function testCallActionDefaultAction(): void public function testCallActionDefaultActionWithParameter(): void { - $core = new SampleCore(new Container()); + $core = new SampleCore($this->getContainer()); $this->assertSame('Hello, Antony.', $core->callAction( DummyController::class, 'index', @@ -55,7 +63,7 @@ public function testCallActionMissingParameter(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); + $core = new SampleCore($this->getContainer()); $core->callAction(DummyController::class, 'required'); } @@ -63,7 +71,7 @@ public function testCallActionInvalidParameter(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); + $core = new SampleCore($this->getContainer()); $core->callAction(DummyController::class, 'required', ['id' => null]); } @@ -71,7 +79,7 @@ public function testCallWrongController(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); + $core = new SampleCore($this->getContainer()); $core->callAction(BadController::class, 'index', ['name' => 'Antony']); } @@ -79,7 +87,7 @@ public function testCallBadAction(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); + $core = new SampleCore($this->getContainer()); $core->callAction(DummyController::class, 'missing', [ 'name' => 'Antony', ]); @@ -89,7 +97,7 @@ public function testStaticAction(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); + $core = new SampleCore($this->getContainer()); $core->callAction(DummyController::class, 'inner'); } @@ -97,7 +105,7 @@ public function testInheritedAction(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); + $core = new SampleCore($this->getContainer()); $core->callAction(DummyController::class, 'execute'); } @@ -105,7 +113,7 @@ public function testInheritedActionCall(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); + $core = new SampleCore($this->getContainer()); $core->callAction(DummyController::class, 'call'); } @@ -113,7 +121,7 @@ public function testCallNotController(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); + $core = new SampleCore($this->getContainer()); $core->callAction(SampleCore::class, 'index', [ 'name' => 'Antony', ]); @@ -121,8 +129,7 @@ public function testCallNotController(): void public function testCleanController(): void { - $core = new SampleCore(new Container()); - + $core = new SampleCore($this->getContainer()); $this->assertSame('900', $core->callAction( CleanController::class, 'test', @@ -134,8 +141,7 @@ public function testCleanControllerError(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); - + $core = new SampleCore($this->getContainer()); $this->assertSame('900', $core->callAction( CleanController::class, 'test', @@ -147,8 +153,7 @@ public function testCleanControllerError2(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); - + $core = new SampleCore($this->getContainer()); $this->assertSame('900', $core->callAction( CleanController::class, 'test', @@ -160,8 +165,7 @@ public function testCleanControllerError3(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); - + $core = new SampleCore($this->getContainer()); $this->assertSame('900', $core->callAction( CleanController::class, 'invalid', @@ -173,8 +177,7 @@ public function testCleanControllerError4(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); - + $core = new SampleCore($this->getContainer()); $this->assertSame('900', $core->callAction( CleanController::class, 'another', @@ -186,12 +189,46 @@ public function testMissingDependency(): void { $this->expectException(ControllerException::class); - $core = new SampleCore(new Container()); - + $core = new SampleCore($this->getContainer()); $this->assertSame('900', $core->callAction( CleanController::class, 'missing', [] )); } + + public function testCallActionReflectionMethodFromExtendedAbstractClass(): void + { + $handler = new SampleCore($this->getContainer()); + + $result = $handler->callAction(TestService::class, 'parentMethod', ['HELLO']); + + self::assertSame('hello', $result); + } + + public function testHandleReflectionMethodFromExtendedAbstractClass(): void + { + $handler = new SampleCore($this->getContainer()); + // Call Context + $ctx = (new CallContext(Target::fromPair(TestService::class, 'parentMethod'))) + ->withArguments(['HELLO']); + + $result = $handler->handle($ctx); + + self::assertSame('hello', $result); + } + + public function testHandleReflectionMethodWithObject(): void + { + $c = new Container(); + $handler = new SampleCore($c); + // Call Context + $service = new \Spiral\Tests\Interceptors\Unit\Stub\TestService(); + $ctx = (new CallContext(Target::fromPair($service, 'parentMethod')->withPath(['foo', 'bar']))) + ->withArguments(['HELLO']); + + $result = $handler->handle($ctx); + + self::assertSame('hello', $result); + } } diff --git a/src/Hmvc/tests/DemoInterceptor.php b/src/Hmvc/tests/Core/DemoInterceptor.php similarity index 100% rename from src/Hmvc/tests/DemoInterceptor.php rename to src/Hmvc/tests/Core/DemoInterceptor.php diff --git a/src/Hmvc/tests/Core/Fixtures/AbstractTestService.php b/src/Hmvc/tests/Core/Fixtures/AbstractTestService.php new file mode 100644 index 000000000..2b32fbb85 --- /dev/null +++ b/src/Hmvc/tests/Core/Fixtures/AbstractTestService.php @@ -0,0 +1,13 @@ +counter; + } + + public static function toUpperCase(string $value): string + { + return \strtoupper($value); + } + + protected function toLowerCase(string $value): string + { + return \strtolower($value); + } +} diff --git a/src/Hmvc/tests/InterceptableCoreTest.php b/src/Hmvc/tests/Core/InterceptableCoreTest.php similarity index 78% rename from src/Hmvc/tests/InterceptableCoreTest.php rename to src/Hmvc/tests/Core/InterceptableCoreTest.php index 03102977f..371661645 100644 --- a/src/Hmvc/tests/InterceptableCoreTest.php +++ b/src/Hmvc/tests/Core/InterceptableCoreTest.php @@ -4,20 +4,20 @@ namespace Spiral\Tests\Core; -use PHPUnit\Framework\TestCase; -use Spiral\Core\Container; use Spiral\Core\Exception\InterceptorException; use Spiral\Core\InterceptableCore; use Spiral\Core\InterceptorPipeline; +use Spiral\Testing\Attribute\TestScope; +use Spiral\Testing\TestCase; use Spiral\Tests\Core\Fixtures\DummyController; use Spiral\Tests\Core\Fixtures\SampleCore; -class InterceptableCoreTest extends TestCase +#[TestScope('http')] +final class InterceptableCoreTest extends TestCase { public function testNoInterceptors(): void { - $core = new SampleCore(new Container()); - $int = new InterceptableCore($core); + $int = new InterceptableCore(new SampleCore($this->getContainer())); $this->assertSame('Hello, Antony.', $int->callAction( DummyController::class, @@ -28,8 +28,7 @@ public function testNoInterceptors(): void public function testNoInterceptors2(): void { - $core = new SampleCore(new Container()); - $int = new InterceptableCore($core); + $int = new InterceptableCore(new SampleCore($this->getContainer())); $int->addInterceptor(new DemoInterceptor()); $this->assertSame('?Hello, Antony.!', $int->callAction( @@ -41,8 +40,7 @@ public function testNoInterceptors2(): void public function testNoInterceptors22(): void { - $core = new SampleCore(new Container()); - $int = new InterceptableCore($core); + $int = new InterceptableCore(new SampleCore($this->getContainer())); $int->addInterceptor(new DemoInterceptor()); $int->addInterceptor(new DemoInterceptor()); diff --git a/src/Hmvc/tests/InterceptorPipelineTest.php b/src/Hmvc/tests/Core/InterceptorPipelineTest.php similarity index 79% rename from src/Hmvc/tests/InterceptorPipelineTest.php rename to src/Hmvc/tests/Core/InterceptorPipelineTest.php index b8bd84431..baa152966 100644 --- a/src/Hmvc/tests/InterceptorPipelineTest.php +++ b/src/Hmvc/tests/Core/InterceptorPipelineTest.php @@ -10,6 +10,8 @@ use Spiral\Core\CoreInterface; use Spiral\Core\Event\InterceptorCalling; use Spiral\Core\InterceptorPipeline; +use Spiral\Interceptors\Context\CallContext; +use Spiral\Interceptors\Context\Target; final class InterceptorPipelineTest extends TestCase { @@ -26,7 +28,13 @@ public function process(string $controller, string $action, array $parameters, C $dispatcher ->expects(self::once()) ->method('dispatch') - ->with(new InterceptorCalling('test', 'test2', [], $interceptor)); + ->with(new InterceptorCalling( + 'test', + 'test2', + [], + $interceptor, + new CallContext(Target::fromPathArray(['test', 'test2'])) + )); $pipeline = new InterceptorPipeline($dispatcher); $pipeline->addInterceptor($interceptor); diff --git a/src/Hmvc/tests/Core/Unit/InterceptorPipelineTest.php b/src/Hmvc/tests/Core/Unit/InterceptorPipelineTest.php new file mode 100644 index 000000000..d994b52d4 --- /dev/null +++ b/src/Hmvc/tests/Core/Unit/InterceptorPipelineTest.php @@ -0,0 +1,142 @@ +createPipeline(); + + self::expectExceptionMessage('Unable to invoke pipeline without last handler.'); + + $pipeline->callAction('controller', 'action'); + } + + public function testHandleWithoutHandler(): void + { + $pipeline = $this->createPipeline(); + + self::expectExceptionMessage('Unable to invoke pipeline without last handler.'); + + $pipeline->handle(new CallContext(Target::fromPathArray(['controller', 'action']))); + } + + public function testCrossCompatibility(): void + { + $handler = self::createMock(CoreInterface::class); + $handler->expects(self::once()) + ->method('callAction') + ->with('controller', 'action') + ->willReturn('result'); + + $pipeline = $this->createPipeline([ + new AddAttributeInterceptor('key', 'value'), + new LegacyStatefulInterceptor(), + new AddAttributeInterceptor('foo', 'bar'), + new LegacyStatefulInterceptor(), + $state = new StatefulInterceptor(), + ], $handler); + + $result = $pipeline->callAction('controller', 'action'); + // Attributes won't be lost after legacy interceptor + self::assertSame(['key' => 'value', 'foo' => 'bar'], $state->context->getAttributes()); + self::assertSame('result', $result); + } + + public function testLegacyChangesContextPath(): void + { + $handler = self::createMock(CoreInterface::class); + $handler->expects(self::once()) + ->method('callAction') + ->with('foo', 'bar') + ->willReturn('result'); + + $pipeline = $this->createPipeline([ + new LegacyChangerInterceptor(controller: 'newController', action: 'newAction'), + $state = new StatefulInterceptor(), + new LegacyChangerInterceptor(controller: 'foo', action: 'bar'), + ], $handler); + + $result = $pipeline->callAction('controller', 'action'); + // Attributes won't be lost after legacy interceptor + self::assertSame(['newController', 'newAction'], $state->context->getTarget()->getPath()); + self::assertSame('result', $result); + } + + public function testAttributesCompatibilityAttributes(): void + { + $pipeline = $this->createPipeline([ + new AddAttributeInterceptor('key', 'value'), + new LegacyStatefulInterceptor(), + $state = new StatefulInterceptor(), + new ExceptionInterceptor(), + ], self::createMock(CoreInterface::class)); + + try { + $pipeline->callAction('controller', 'action'); + } catch (\RuntimeException) { + // Attributes won't be lost after legacy interceptor + self::assertSame(['key' => 'value'], $state->context->getAttributes()); + } + } + + /** + * Multiple call of same the handler inside the pipeline must invoke the same interceptor. + */ + public function testCallHandlerTwice(): void + { + $mock = self::createMock(InterceptorInterface::class); + $mock->expects(self::exactly(2)) + ->method('intercept') + ->willReturn('foo', 'bar'); + + $pipeline = $this->createPipeline([ + new MultipleCallNextInterceptor(2), + $mock, + new ExceptionInterceptor(), + ], self::createMock(HandlerInterface::class)); + + $result = $pipeline->callAction('controller', 'action'); + self::assertSame(['foo', 'bar'], $result); + } + + /** + * @param array $interceptors + */ + private function createPipeline( + array $interceptors = [], + CoreInterface|HandlerInterface|null $lastHandler = null, + EventDispatcherInterface|null $dispatcher = null, + ): InterceptorPipeline + { + $pipeline = new InterceptorPipeline($dispatcher); + + $lastHandler instanceof CoreInterface and $pipeline = $pipeline->withCore($lastHandler); + $lastHandler instanceof HandlerInterface and $pipeline = $pipeline->withHandler($lastHandler); + + foreach ($interceptors as $interceptor) { + $pipeline->addInterceptor($interceptor); + } + + return $pipeline; + } +} diff --git a/src/Hmvc/tests/Core/Unit/Stub/AddAttributeInterceptor.php b/src/Hmvc/tests/Core/Unit/Stub/AddAttributeInterceptor.php new file mode 100644 index 000000000..d6c1ea808 --- /dev/null +++ b/src/Hmvc/tests/Core/Unit/Stub/AddAttributeInterceptor.php @@ -0,0 +1,23 @@ +handle($context->withAttribute($this->attribute, $this->value)); + } +} diff --git a/src/Hmvc/tests/Core/Unit/Stub/ExceptionInterceptor.php b/src/Hmvc/tests/Core/Unit/Stub/ExceptionInterceptor.php new file mode 100644 index 000000000..2c0718e3f --- /dev/null +++ b/src/Hmvc/tests/Core/Unit/Stub/ExceptionInterceptor.php @@ -0,0 +1,17 @@ +callAction( + $this->controller ?? $controller, + $this->action ?? $action, + $this->parameters ?? $parameters, + ); + } +} diff --git a/src/Hmvc/tests/Core/Unit/Stub/Legacy/LegacyStatefulInterceptor.php b/src/Hmvc/tests/Core/Unit/Stub/Legacy/LegacyStatefulInterceptor.php new file mode 100644 index 000000000..2a0a4005b --- /dev/null +++ b/src/Hmvc/tests/Core/Unit/Stub/Legacy/LegacyStatefulInterceptor.php @@ -0,0 +1,26 @@ +controller = $controller; + $this->action = $action; + $this->parameters = $parameters; + $this->next = $core; + return $this->result = $core->callAction($controller, $action, $parameters); + } +} diff --git a/src/Hmvc/tests/Core/Unit/Stub/MultipleCallNextInterceptor.php b/src/Hmvc/tests/Core/Unit/Stub/MultipleCallNextInterceptor.php new file mode 100644 index 000000000..c70bd1ff4 --- /dev/null +++ b/src/Hmvc/tests/Core/Unit/Stub/MultipleCallNextInterceptor.php @@ -0,0 +1,29 @@ +result = []; + for ($i = 0; $i < $this->counter; ++$i) { + $this->result[] = $handler->handle($context); + } + + return $this->result; + } +} diff --git a/src/Hmvc/tests/Core/Unit/Stub/StatefulInterceptor.php b/src/Hmvc/tests/Core/Unit/Stub/StatefulInterceptor.php new file mode 100644 index 000000000..42c1029aa --- /dev/null +++ b/src/Hmvc/tests/Core/Unit/Stub/StatefulInterceptor.php @@ -0,0 +1,23 @@ +context = $context; + $this->next = $handler; + return $this->result = $handler->handle($context); + } +} diff --git a/src/Hmvc/tests/Interceptors/Unit/Context/AttributedTraitTest.php b/src/Hmvc/tests/Interceptors/Unit/Context/AttributedTraitTest.php new file mode 100644 index 000000000..e2d5dbe80 --- /dev/null +++ b/src/Hmvc/tests/Interceptors/Unit/Context/AttributedTraitTest.php @@ -0,0 +1,62 @@ +withAttribute('key', 'value'); + + self::assertSame('value', $dto->getAttribute('key')); + self::assertNull($dto->getAttribute('non-exist-key')); + // default value + self::assertSame(42, $dto->getAttribute('non-exist-key', 42)); + } + + public function testWithAttribute(): void + { + $dto = new AttributedStub(); + + $new = $dto->withAttribute('key', 'value'); + + self::assertSame('value', $new->getAttribute('key')); + // Immutability + self::assertNotSame($dto, $new); + self::assertNotSame('value', $dto->getAttribute('key')); + } + + public function testWithAttributes(): void + { + $dto = new AttributedStub(); + + $new = $dto + ->withAttribute('key', 'value') + ->withAttribute('key2', 'value2'); + + self::assertSame([ + 'key' => 'value', + 'key2' => 'value2', + ], $new->getAttributes()); + } + + public function testWithoutAttributes(): void + { + $dto = (new AttributedStub()) + ->withAttribute('key', 'value') + ->withAttribute('key2', 'value2'); + + $new = $dto->withoutAttribute('key'); + + self::assertNull($new->getAttribute('key')); + self::assertSame([ + 'key2' => 'value2', + ], $new->getAttributes()); + } +} diff --git a/src/Hmvc/tests/Interceptors/Unit/Context/TargetTest.php b/src/Hmvc/tests/Interceptors/Unit/Context/TargetTest.php new file mode 100644 index 000000000..f11c390bb --- /dev/null +++ b/src/Hmvc/tests/Interceptors/Unit/Context/TargetTest.php @@ -0,0 +1,160 @@ +getReflection()); + self::assertSame('print_r-path', (string)$target); + self::assertNull($target->getObject()); + } + + public function testCreateFromClosure(): void + { + $target = Target::fromClosure(\print_r(...), ['print_r-path']); + + self::assertNotNull($target->getReflection()); + self::assertSame('print_r-path', (string)$target); + self::assertNull($target->getObject()); + } + + public function testCreateFromClosureWithContext(): void + { + $target = Target::fromClosure($this->{__FUNCTION__}(...), ['print_r-path']); + + self::assertNotNull($target->getReflection()); + self::assertSame('print_r-path', (string)$target); + self::assertNull($target->getObject()); + } + + public function testCreateFromReflectionMethodClassName(): void + { + $reflection = new \ReflectionMethod($this, __FUNCTION__); + + $target = Target::fromReflectionMethod($reflection, __CLASS__); + + self::assertSame($reflection, $target->getReflection()); + self::assertSame(__CLASS__ . '->' . __FUNCTION__, (string)$target); + self::assertNull($target->getObject()); + } + + public function testCreateFromReflectionMethodObject(): void + { + $reflection = new \ReflectionMethod($this, __FUNCTION__); + + $target = Target::fromReflectionMethod($reflection, $this); + + self::assertSame($reflection, $target->getReflection()); + self::assertSame(__CLASS__ . '->' . __FUNCTION__, (string)$target); + self::assertNotNull($target->getObject()); + } + + public function testCreateFromPathStringWithPath(): void + { + $str = 'foo.bar.baz'; + $target = Target::fromPathString($str); + $target2 = $target->withPath(['bar', 'baz']); + + // Immutability + self::assertNotSame($target, $target2); + self::assertSame(['bar', 'baz'], $target2->getPath()); + self::assertSame('bar.baz', (string)$target2); + // First target is not changed + self::assertSame(['foo', 'bar', 'baz'], $target->getPath()); + self::assertSame($str, (string)$target); + } + + public static function providePathChunks(): iterable + { + yield [['Foo', 'Bar', 'baz'], '.']; + yield [['Foo', 'Bar', 'baz', 'fiz.baz'], '/']; + yield [['Foo'], ' ']; + yield [['Foo', '', ''], '-']; + } + + #[DataProvider('providePathChunks')] + public function testCreateFromPathString(array $chunks, string $separator): void + { + $str = \implode($separator, $chunks); + $target = Target::fromPathString($str, $separator); + + self::assertSame($chunks, $target->getPath()); + self::assertSame($str, (string)$target); + } + + #[DataProvider('providePathChunks')] + public function testCreateFromPathArray(array $chunks, string $separator): void + { + $str = \implode($separator, $chunks); + $target = Target::fromPathArray($chunks, $separator); + + self::assertSame($chunks, $target->getPath()); + self::assertSame($str, (string)$target); + } + + public static function providePairs(): iterable + { + yield 'static method' => [TestService::class, 'toUpperCase', true]; + yield 'public method' => [TestService::class, 'increment', true]; + yield 'protected method' => [TestService::class, 'toLowerCase', true]; + yield 'not existing' => [TestService::class, 'noExistingMethod', false]; + yield 'parent method' => [TestService::class, 'parentMethod', true]; + yield 'not a class' => ['Spiral\Tests\Interceptors\Unit\Stub\FooBarBaz', 'noExistingMethod', false]; + } + + #[DataProvider('providePairs')] + public function testCreateFromPair(string $controller, string $action, bool $hasReflection): void + { + $target = Target::fromPair($controller, $action); + + self::assertSame([$controller, $action], $target->getPath()); + $reflection = $target->getReflection(); + self::assertSame($hasReflection, $reflection !== null); + self::assertNull($target->getObject()); + if ($hasReflection) { + self::assertInstanceOf(\ReflectionMethod::class, $reflection); + self::assertSame($action, $reflection->getName()); + } + } + + public function testCreateFromObject(): void + { + $service = new TestService(); + $target = Target::fromPair($service, 'parentMethod'); + + self::assertSame([TestService::class, 'parentMethod'], $target->getPath()); + $reflection = $target->getReflection(); + self::assertInstanceOf(\ReflectionMethod::class, $reflection); + self::assertSame('parentMethod', $reflection->getName()); + self::assertSame($service, $target->getObject()); + } + + public function testCreateFromPathStringDefaultSeparator(): void + { + $str = 'foo.bar.baz'; + $target = Target::fromPathString($str); + + self::assertSame(['foo', 'bar', 'baz'], $target->getPath()); + self::assertSame($str, (string)$target); + } + + public function testPrivateConstructor(): void + { + $this->expectException(\Error::class); + + new Target(); + } +} diff --git a/src/Hmvc/tests/Interceptors/Unit/Handler/InterceptorPipelineTest.php b/src/Hmvc/tests/Interceptors/Unit/Handler/InterceptorPipelineTest.php new file mode 100644 index 000000000..5ee31109a --- /dev/null +++ b/src/Hmvc/tests/Interceptors/Unit/Handler/InterceptorPipelineTest.php @@ -0,0 +1,127 @@ +createPathContext(['test', 'test2']); + $interceptor = new class implements InterceptorInterface { + public function intercept(CallContextInterface $context, HandlerInterface $handler): mixed + { + return null; + } + }; + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher + ->expects(self::once()) + ->method('dispatch') + ->with( + new \Spiral\Interceptors\Event\InterceptorCalling( + $context, + $interceptor, + ) + ); + $pipeline = $this->createPipeline(interceptors: [$interceptor], dispatcher: $dispatcher); + + $pipeline->withHandler( + new class implements HandlerInterface { + public function handle(CallContext $context): mixed + { + return null; + } + } + )->handle($context, []); + } + + public function testCallActionWithoutCore(): void + { + $pipeline = $this->createPipeline(); + + self::expectExceptionMessage('Unable to invoke pipeline without last handler.'); + + $pipeline->handle($this->createPathContext(['controller', 'action'])); + } + + public function testHandleWithoutHandler(): void + { + $pipeline = $this->createPipeline(); + + self::expectExceptionMessage('Unable to invoke pipeline without last handler.'); + + $pipeline->handle(new CallContext(Target::fromPathArray(['controller', 'action']))); + } + + public function testHandleWithHandler(): void + { + $ctx = new CallContext(Target::fromPathArray(['controller', 'action'])); + $mock = self::createMock(HandlerInterface::class); + $mock->expects(self::exactly(2)) + ->method('handle') + ->with($ctx) + ->willReturn('test1', 'test2'); + $pipeline = $this->createPipeline([new MultipleCallNextInterceptor(2)], $mock); + + $result = $pipeline->handle($ctx); + + self::assertSame(['test1', 'test2'], $result); + } + + /** + * Multiple call of same the handler inside the pipeline must invoke the same interceptor. + */ + public function testCallHandlerTwice(): void + { + $mock = self::createMock(InterceptorInterface::class); + $mock->expects(self::exactly(2)) + ->method('intercept') + ->willReturn('foo', 'bar'); + + $pipeline = $this->createPipeline([ + new MultipleCallNextInterceptor(2), + $mock, + new ExceptionInterceptor(), + ], self::createMock(HandlerInterface::class)); + + $result = $pipeline->handle($this->createPathContext(['controller', 'action'])); + self::assertSame(['foo', 'bar'], $result); + } + + /** + * @param array $interceptors + */ + private function createPipeline( + array $interceptors = [], + HandlerInterface|null $lastHandler = null, + EventDispatcherInterface|null $dispatcher = null, + ): InterceptorPipeline { + $pipeline = new InterceptorPipeline($dispatcher); + + $lastHandler instanceof HandlerInterface and $pipeline = $pipeline->withHandler($lastHandler); + + foreach ($interceptors as $interceptor) { + $pipeline->addInterceptor($interceptor); + } + + return $pipeline; + } + + public function createPathContext(array $path = []): CallContext + { + return new CallContext(Target::fromPathArray($path)); + } +} diff --git a/src/Hmvc/tests/Interceptors/Unit/Handler/ReflectionHandlerTest.php b/src/Hmvc/tests/Interceptors/Unit/Handler/ReflectionHandlerTest.php new file mode 100644 index 000000000..cf00c25e9 --- /dev/null +++ b/src/Hmvc/tests/Interceptors/Unit/Handler/ReflectionHandlerTest.php @@ -0,0 +1,140 @@ +withArguments(['HELLO']); + + $result = $handler->handle($ctx); + + self::assertSame('hello', $result); + } + + public function testHandleReflectionFunction(): void + { + $c = new Container(); + $container = self::createMock(ContainerInterface::class); + $container + ->expects(self::once()) + ->method('get') + ->with(ResolverInterface::class) + ->willReturn($c); + $handler = new ReflectionHandler($container, false); + // Call Context + $ctx = new CallContext(Target::fromReflectionFunction(new \ReflectionFunction('strtoupper'))); + $ctx = $ctx->withArguments(['hello']); + + $result = $handler->handle($ctx); + + self::assertSame('HELLO', $result); + } + + public function testHandleReflectionMethodWithObject(): void + { + $c = new Container(); + $container = self::createMock(ContainerInterface::class); + $container + ->expects(self::once()) + ->method('get') + ->with(ResolverInterface::class) + ->willReturn($c); + $handler = new ReflectionHandler($container, false); + // Call Context + $service = new TestService(); + $ctx = (new CallContext(Target::fromPair($service, 'parentMethod'))) + ->withArguments(['HELLO']); + + $result = $handler->handle($ctx); + + self::assertSame('hello', $result); + } + + public function testWithoutResolvingFromPathAndReflection(): void + { + $container = self::createMock(ContainerInterface::class); + + $handler = new ReflectionHandler($container, false); + + self::expectException(TargetCallException::class); + self::expectExceptionMessageMatches('/Reflection not provided for target/'); + + $handler->handle(new CallContext(Target::fromPathString('foo'))); + } + + public function testWithoutReflectionWithResolvingFromPathWithIncorrectPath(): void + { + $handler = $this->createHandler(); + + self::expectException(TargetCallException::class); + self::expectExceptionMessageMatches('/Invalid target path to resolve reflection/'); + + $handler->handle(new CallContext(Target::fromPathArray(['foo', 'bar', 'baz']))); + } + + public function testWithoutReflectionWithResolvingFromPathWithWrongPath(): void + { + $handler = $this->createHandler(); + + self::expectException(TargetCallException::class); + self::expectExceptionMessageMatches('/Invalid action/'); + + $handler->handle(new CallContext(Target::fromPathArray([TestService::class, 'nonExistingMethod']))); + } + + public function testWithoutReflectionWithResolvingFromPath(): void + { + $handler = $this->createHandler([ + TestService::class => $service = new TestService(), + ]); + + self::assertSame(0, $service->counter); + + $handler->handle(new CallContext(Target::fromPathArray([TestService::class, 'increment']))); + self::assertSame(1, $service->counter); + } + + public function testUsingResolver(): void + { + $handler = $this->createHandler(); + $ctx = new CallContext( + Target::fromReflectionFunction(new \ReflectionFunction(fn (string $value):string => \strtoupper($value))) + ); + $ctx = $ctx->withArguments(['word' => 'world!', 'value' => 'hello']); + + $result = $handler->handle($ctx); + + self::assertSame('HELLO', $result); + } + + public function createHandler(array $definitions = [], bool $resolveFromPath = true): ReflectionHandler + { + $container = new Container(); + foreach ($definitions as $id => $definition) { + $container->bind($id, $definition); + } + + return new ReflectionHandler( + $container, + $resolveFromPath, + ); + } +} diff --git a/src/Hmvc/tests/Interceptors/Unit/Internal/ActionResolverTest.php b/src/Hmvc/tests/Interceptors/Unit/Internal/ActionResolverTest.php new file mode 100644 index 000000000..762b8edeb --- /dev/null +++ b/src/Hmvc/tests/Interceptors/Unit/Internal/ActionResolverTest.php @@ -0,0 +1,61 @@ +handle($context->withAttribute($this->attribute, $this->value)); + } +} diff --git a/src/Hmvc/tests/Interceptors/Unit/Stub/AttributedStub.php b/src/Hmvc/tests/Interceptors/Unit/Stub/AttributedStub.php new file mode 100644 index 000000000..b8fcac9b9 --- /dev/null +++ b/src/Hmvc/tests/Interceptors/Unit/Stub/AttributedStub.php @@ -0,0 +1,13 @@ +result = []; + for ($i = 0; $i < $this->counter; ++$i) { + $this->result[] = $handler->handle($context); + } + + return $this->result; + } +} diff --git a/src/Hmvc/tests/Interceptors/Unit/Stub/StatefulInterceptor.php b/src/Hmvc/tests/Interceptors/Unit/Stub/StatefulInterceptor.php new file mode 100644 index 000000000..5e75a1010 --- /dev/null +++ b/src/Hmvc/tests/Interceptors/Unit/Stub/StatefulInterceptor.php @@ -0,0 +1,23 @@ +context = $context; + $this->next = $handler; + return $this->result = $handler->handle($context); + } +} diff --git a/src/Hmvc/tests/Interceptors/Unit/Stub/TestService.php b/src/Hmvc/tests/Interceptors/Unit/Stub/TestService.php new file mode 100644 index 000000000..b3e9327e0 --- /dev/null +++ b/src/Hmvc/tests/Interceptors/Unit/Stub/TestService.php @@ -0,0 +1,25 @@ +counter; + } + + public static function toUpperCase(string $value): string + { + return \strtoupper($value); + } + + protected function toLowerCase(string $value): string + { + return \strtolower($value); + } +} diff --git a/src/Http/src/CurrentRequest.php b/src/Http/src/CurrentRequest.php new file mode 100644 index 000000000..4feee69ca --- /dev/null +++ b/src/Http/src/CurrentRequest.php @@ -0,0 +1,29 @@ +request = $request; + } + + public function get(): ?ServerRequestInterface + { + return $this->request; + } +} diff --git a/src/Http/src/Http.php b/src/Http/src/Http.php index 9fe1bafe4..8ce37f96b 100644 --- a/src/Http/src/Http.php +++ b/src/Http/src/Http.php @@ -31,7 +31,8 @@ public function __construct( private readonly Pipeline $pipeline, private readonly ResponseFactoryInterface $responseFactory, private readonly ContainerInterface $container, - ?TracerFactoryInterface $tracerFactory = null + ?TracerFactoryInterface $tracerFactory = null, + private readonly ?EventDispatcherInterface $dispatcher = null, ) { foreach ($this->config->getMiddleware() as $middleware) { $this->pipeline->pushMiddleware($this->container->get($middleware)); @@ -60,12 +61,10 @@ public function setHandler(callable|RequestHandlerInterface $handler): self */ public function handle(ServerRequestInterface $request): ResponseInterface { - $callback = function (SpanInterface $span) use ($request): ResponseInterface { - $dispatcher = $this->container->has(EventDispatcherInterface::class) - ? $this->container->get(EventDispatcherInterface::class) - : null; + $callback = function (SpanInterface $span, CurrentRequest $currentRequest) use ($request): ResponseInterface { + $currentRequest->set($request); - $dispatcher?->dispatch(new RequestReceived($request)); + $this->dispatcher?->dispatch(new RequestReceived($request)); if ($this->handler === null) { throw new HttpException('Unable to run HttpCore, no handler is set.'); @@ -84,7 +83,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface ) ->setStatus($response->getStatusCode() < 500 ? 'OK' : 'ERROR'); - $dispatcher?->dispatch(new RequestHandled($request, $response)); + $this->dispatcher?->dispatch(new RequestHandled($request, $response)); return $response; }; diff --git a/src/Http/src/Pipeline.php b/src/Http/src/Pipeline.php index e9d4b98de..79dc91ae1 100644 --- a/src/Http/src/Pipeline.php +++ b/src/Http/src/Pipeline.php @@ -9,6 +9,8 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Spiral\Core\Attribute\Proxy; +use Spiral\Core\ContainerScope; use Spiral\Core\ScopeInterface; use Spiral\Http\Event\MiddlewareProcessing; use Spiral\Http\Exception\PipelineException; @@ -29,7 +31,7 @@ final class Pipeline implements RequestHandlerInterface, MiddlewareInterface private ?RequestHandlerInterface $handler = null; public function __construct( - private readonly ScopeInterface $scope, + #[Proxy] private readonly ScopeInterface $scope, private readonly ?EventDispatcherInterface $dispatcher = null, ?TracerInterface $tracer = null ) { @@ -61,40 +63,50 @@ public function handle(Request $request): Response throw new PipelineException('Unable to run pipeline, no handler given.'); } - $position = $this->position++; - if (isset($this->middleware[$position])) { + // todo: find a better solution in the Spiral v4.0 + /** @var CurrentRequest|null $currentRequest */ + $currentRequest = ContainerScope::getContainer()?->get(CurrentRequest::class); + + $previousRequest = $currentRequest?->get(); + $currentRequest?->set($request); + try { + $position = $this->position++; + if (!isset($this->middleware[$position])) { + return $this->handler->handle($request); + } + $middleware = $this->middleware[$position]; $this->dispatcher?->dispatch(new MiddlewareProcessing($request, $middleware)); + $callback = function (SpanInterface $span) use ($request, $middleware): Response { + $response = $middleware->process($request, $this); + + $span + ->setAttribute( + 'http.status_code', + $response->getStatusCode() + ) + ->setAttribute( + 'http.response_content_length', + $response->getHeaderLine('Content-Length') ?: $response->getBody()->getSize() + ) + ->setStatus($response->getStatusCode() < 500 ? 'OK' : 'ERROR'); + + return $response; + }; + return $this->tracer->trace( name: \sprintf('Middleware processing [%s]', $middleware::class), - callback: function (SpanInterface $span) use ($request, $middleware): Response { - $response = $middleware->process($request, $this); - - $span - ->setAttribute( - 'http.status_code', - $response->getStatusCode() - ) - ->setAttribute( - 'http.response_content_length', - $response->getHeaderLine('Content-Length') ?: $response->getBody()->getSize() - ) - ->setStatus($response->getStatusCode() < 500 ? 'OK' : 'ERROR'); - - return $response; - }, - scoped: true, + callback: $callback, attributes: [ 'http.middleware' => $middleware::class, - ] + ], + scoped: true ); + } finally { + if ($previousRequest !== null) { + $currentRequest?->set($previousRequest); + } } - - $handler = $this->handler; - return $this->scope->runScope( - [Request::class => $request], - static fn (): Response => $handler->handle($request) - ); } } diff --git a/src/Http/src/Request/InputManager.php b/src/Http/src/Request/InputManager.php index 6e3629934..66f2dd3fc 100644 --- a/src/Http/src/Request/InputManager.php +++ b/src/Http/src/Request/InputManager.php @@ -9,6 +9,8 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\UploadedFileInterface; use Psr\Http\Message\UriInterface; +use Spiral\Core\Attribute\Proxy; +use Spiral\Core\Attribute\Scope; use Spiral\Core\Attribute\Singleton; use Spiral\Core\Exception\ScopeException; use Spiral\Http\Config\HttpConfig; @@ -48,6 +50,7 @@ * @method mixed attribute(string $name, mixed $default = null) */ #[Singleton] +#[Scope('http.request')] final class InputManager { /** @@ -115,7 +118,7 @@ final class InputManager public function __construct( /** @invisible */ - private readonly ContainerInterface $container, + #[Proxy] private readonly ContainerInterface $container, /** @invisible */ HttpConfig $config = new HttpConfig() ) { diff --git a/src/Http/tests/HttpTest.php b/src/Http/tests/HttpTest.php index 6fd322a11..755d74c97 100644 --- a/src/Http/tests/HttpTest.php +++ b/src/Http/tests/HttpTest.php @@ -7,9 +7,8 @@ use Mockery as m; use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Http\Message\ServerRequestInterface; use Spiral\Core\Container; -use Spiral\Core\ContainerScope; +use Spiral\Core\Options; use Spiral\Http\CallableHandler; use Spiral\Http\Config\HttpConfig; use Spiral\Http\Event\RequestHandled; @@ -23,7 +22,7 @@ use Spiral\Tests\Http\Diactoros\ResponseFactory; use Nyholm\Psr7\ServerRequest; -class HttpTest extends TestCase +final class HttpTest extends TestCase { use m\Adapter\Phpunit\MockeryPHPUnitIntegration; @@ -31,7 +30,9 @@ class HttpTest extends TestCase public function setUp(): void { - $this->container = new Container(); + $options = new Options(); + $options->checkScope = false; + $this->container = new Container(options: $options); $this->container->bind(TracerInterface::class, new NullTracer($this->container)); } @@ -223,20 +224,6 @@ public function testMiddlewareTraitReversed(): void $this->assertSame('hello?', (string)$response->getBody()); } - public function testScope(): void - { - $core = $this->getCore(); - - $core->setHandler(function () { - $this->assertTrue(ContainerScope::getContainer()->has(ServerRequestInterface::class)); - - return 'OK'; - }); - - $response = $core->handle(new ServerRequest('GET', '')); - $this->assertSame('OK', (string)$response->getBody()); - } - public function testPassException(): void { $this->expectException(\RuntimeException::class); @@ -293,7 +280,7 @@ public function testPassingTracerIntoScope(): void $tracerFactory->shouldReceive('make') ->once() ->with(['foo' => ['bar']]) - ->andReturn($tracer = new NullTracer()); + ->andReturn(new NullTracer($this->container)); $response = $http->handle($request); $this->assertSame('hello world', (string)$response->getBody()); @@ -307,7 +294,10 @@ protected function getCore(array $middleware = []): Http $config, new Pipeline($this->container), new ResponseFactory($config), - $this->container + $this->container, + dispatcher: $this->container->has(EventDispatcherInterface::class) + ? $this->container->get(EventDispatcherInterface::class) + : null, ); } diff --git a/src/Http/tests/PipelineTest.php b/src/Http/tests/PipelineTest.php index 1dc350ae4..492488954 100644 --- a/src/Http/tests/PipelineTest.php +++ b/src/Http/tests/PipelineTest.php @@ -4,28 +4,29 @@ namespace Spiral\Tests\Http; -use PHPUnit\Framework\TestCase; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use Spiral\Core\Container; +use Spiral\Core\ContainerScope; +use Spiral\Core\ScopeInterface; use Spiral\Http\CallableHandler; use Spiral\Http\Config\HttpConfig; +use Spiral\Http\CurrentRequest; use Spiral\Http\Event\MiddlewareProcessing; use Spiral\Http\Exception\PipelineException; use Spiral\Http\Pipeline; use Spiral\Telemetry\NullTracer; -use Spiral\Telemetry\NullTracerFactory; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Http\Diactoros\ResponseFactory; use Nyholm\Psr7\ServerRequest; -class PipelineTest extends TestCase +final class PipelineTest extends TestCase { public function testTarget(): void { - $pipeline = new Pipeline(new Container()); + $pipeline = new Pipeline($this->container); $handler = new CallableHandler(function () { return 'response'; @@ -40,7 +41,7 @@ public function testTarget(): void public function testHandle(): void { - $pipeline = new Pipeline(new Container()); + $pipeline = new Pipeline($this->container); $handler = new CallableHandler(function () { return 'response'; @@ -57,7 +58,7 @@ public function testHandleException(): void { $this->expectException(PipelineException::class); - $pipeline = new Pipeline(new Container()); + $pipeline = new Pipeline($this->container); $pipeline->handle(new ServerRequest('GET', '')); } @@ -80,16 +81,51 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface ->method('dispatch') ->with(new MiddlewareProcessing($request, $middleware)); - $container = new Container(); - - $pipeline = new Pipeline( - $container, - $dispatcher, - new NullTracer($container) - ); + $pipeline = new Pipeline($this->container, $dispatcher, new NullTracer($this->container)); $pipeline->pushMiddleware($middleware); $pipeline->withHandler($handler)->handle($request); } + + public function testRequestResetThroughPipeline(): void + { + $this->container->getBinder('http') + ->bindSingleton(CurrentRequest::class, new CurrentRequest()); + $this->container->getBinder('http') + ->bind(ServerRequestInterface::class, static fn(CurrentRequest $cr) => $cr->get()); + + $middleware = new class implements MiddlewareInterface { + public function process( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + ): ResponseInterface { + $cRequest = ContainerScope::getContainer()->get(ServerRequestInterface::class); + PipelineTest::assertSame($cRequest, $request); + + $response = $handler->handle($request->withAttribute('foo', 'bar')); + + $cRequest = ContainerScope::getContainer()->get(ServerRequestInterface::class); + PipelineTest::assertSame($cRequest, $request); + return $response; + } + }; + + $this->container->runScope( + new \Spiral\Core\Scope(name: 'http'), + function (ScopeInterface $c) use ($middleware) { + $request = new ServerRequest('GET', ''); + $handler = new CallableHandler(function () { + return 'response'; + }, new ResponseFactory(new HttpConfig(['headers' => []]))); + + $pipeline = new Pipeline($c, null, new NullTracer($c)); + + $pipeline->pushMiddleware($middleware); + $pipeline->pushMiddleware($middleware); + + $pipeline->withHandler($handler)->handle($request); + } + ); + } } diff --git a/src/Http/tests/TestCase.php b/src/Http/tests/TestCase.php new file mode 100644 index 000000000..6c8b983fa --- /dev/null +++ b/src/Http/tests/TestCase.php @@ -0,0 +1,23 @@ +checkScope = false; + $this->container = new Container(options: $options); + $this->container->bind(TracerInterface::class, new NullTracer($this->container)); + } +} diff --git a/src/Queue/src/Bootloader/QueueBootloader.php b/src/Queue/src/Bootloader/QueueBootloader.php index 4014e8a6f..d78c904a2 100644 --- a/src/Queue/src/Bootloader/QueueBootloader.php +++ b/src/Queue/src/Bootloader/QueueBootloader.php @@ -10,7 +10,7 @@ use Spiral\Boot\Bootloader\Bootloader; use Spiral\Config\ConfiguratorInterface; use Spiral\Config\Patch\Append; -use Spiral\Core\{BinderInterface, FactoryInterface, InterceptableCore}; +use Spiral\Core\{BinderInterface, FactoryInterface, InterceptableCore, InterceptorPipeline}; use Spiral\Core\Container\Autowire; use Spiral\Core\CoreInterceptorInterface; use Spiral\Queue\{JobHandlerLocatorListener, @@ -20,13 +20,17 @@ QueueRegistry, SerializerLocatorListener, SerializerRegistryInterface}; +use Spiral\Interceptors\InterceptorInterface; use Spiral\Queue\Config\QueueConfig; use Spiral\Queue\ContainerRegistry; use Spiral\Queue\Core\QueueInjector; use Spiral\Queue\Driver\{NullDriver, SyncDriver}; use Spiral\Queue\Failed\{FailedJobHandlerInterface, LogFailedJobHandler}; use Spiral\Queue\HandlerRegistryInterface; -use Spiral\Queue\Interceptor\Consume\{Core as ConsumeCore, ErrorHandlerInterceptor, Handler, RetryPolicyInterceptor}; +use Spiral\Queue\Interceptor\Consume\Core as ConsumeCore; +use Spiral\Queue\Interceptor\Consume\ErrorHandlerInterceptor; +use Spiral\Queue\Interceptor\Consume\Handler; +use Spiral\Queue\Interceptor\Consume\RetryPolicyInterceptor; use Spiral\Telemetry\Bootloader\TelemetryBootloader; use Spiral\Telemetry\TracerFactoryInterface; use Spiral\Tokenizer\Bootloader\TokenizerListenerBootloader; @@ -139,7 +143,7 @@ protected function initHandler( TracerFactoryInterface $tracerFactory, ?EventDispatcherInterface $dispatcher = null, ): Handler { - $core = new InterceptableCore($core, $dispatcher); + $pipeline = (new InterceptorPipeline($dispatcher))->withHandler($core); foreach ($config->getConsumeInterceptors() as $interceptor) { if (\is_string($interceptor)) { @@ -148,11 +152,11 @@ protected function initHandler( $interceptor = $interceptor->resolve($factory); } - \assert($interceptor instanceof CoreInterceptorInterface); - $core->addInterceptor($interceptor); + \assert($interceptor instanceof CoreInterceptorInterface || $interceptor instanceof InterceptorInterface); + $pipeline->addInterceptor($interceptor); } - return new Handler($core, $tracerFactory); + return new Handler($pipeline, $tracerFactory); } private function initQueueConfig(EnvironmentInterface $env): void diff --git a/src/Queue/src/Config/QueueConfig.php b/src/Queue/src/Config/QueueConfig.php index 4a0659176..30b930da1 100644 --- a/src/Queue/src/Config/QueueConfig.php +++ b/src/Queue/src/Config/QueueConfig.php @@ -5,11 +5,17 @@ namespace Spiral\Queue\Config; use Spiral\Core\Container\Autowire; -use Spiral\Core\CoreInterceptorInterface; +use Spiral\Core\CoreInterceptorInterface as LegacyInterceptor; use Spiral\Core\InjectableConfig; +use Spiral\Interceptors\InterceptorInterface; use Spiral\Queue\Exception\InvalidArgumentException; use Spiral\Serializer\SerializerInterface; +/** + * @psalm-type TLegacyInterceptors = array|LegacyInterceptor|Autowire> + * @psalm-type TNewInterceptors = array|InterceptorInterface|Autowire> + * @psalm-type TInterceptors = TNewInterceptors|TLegacyInterceptors + */ final class QueueConfig extends InjectableConfig { public const CONFIG = 'queue'; @@ -41,7 +47,7 @@ public function getAliases(): array /** * Get consumer interceptors * - * @return array|CoreInterceptorInterface|Autowire> + * @return TInterceptors */ public function getConsumeInterceptors(): array { @@ -51,7 +57,7 @@ public function getConsumeInterceptors(): array /** * Get pusher interceptors * - * @return array|CoreInterceptorInterface|Autowire> + * @return TInterceptors */ public function getPushInterceptors(): array { diff --git a/src/Queue/src/Interceptor/Consume/Core.php b/src/Queue/src/Interceptor/Consume/Core.php index 670a3def0..ce0c12418 100644 --- a/src/Queue/src/Interceptor/Consume/Core.php +++ b/src/Queue/src/Interceptor/Consume/Core.php @@ -6,6 +6,8 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Spiral\Core\CoreInterface; +use Spiral\Interceptors\Context\CallContext; +use Spiral\Interceptors\HandlerInterface; use Spiral\Queue\Event\JobProcessed; use Spiral\Queue\Event\JobProcessing; use Spiral\Queue\HandlerRegistryInterface; @@ -20,7 +22,7 @@ * headers: array * } */ -final class Core implements CoreInterface +final class Core implements CoreInterface, HandlerInterface { public function __construct( private readonly HandlerRegistryInterface $registry, @@ -30,6 +32,7 @@ public function __construct( /** * @param-assert TParameters $parameters + * @deprecated */ public function callAction(string $controller, string $action, array $parameters = []): mixed { @@ -49,6 +52,15 @@ public function callAction(string $controller, string $action, array $parameters return null; } + public function handle(CallContext $context): mixed + { + $args = $context->getArguments(); + $controller = $context->getTarget()->getPath()[0]; + $action = $context->getTarget()->getPath()[1]; + + return $this->callAction($controller, $action, $args); + } + /** * @param class-string $event * @param-assert TParameters $parameters diff --git a/src/Queue/src/Interceptor/Consume/ErrorHandlerInterceptor.php b/src/Queue/src/Interceptor/Consume/ErrorHandlerInterceptor.php index 0443d1e7a..967a92b49 100644 --- a/src/Queue/src/Interceptor/Consume/ErrorHandlerInterceptor.php +++ b/src/Queue/src/Interceptor/Consume/ErrorHandlerInterceptor.php @@ -4,12 +4,15 @@ namespace Spiral\Queue\Interceptor\Consume; -use Spiral\Core\CoreInterceptorInterface; +use Spiral\Core\CoreInterceptorInterface as LegacyInterceptor; use Spiral\Core\CoreInterface; +use Spiral\Interceptors\Context\CallContextInterface; +use Spiral\Interceptors\HandlerInterface; +use Spiral\Interceptors\InterceptorInterface; use Spiral\Queue\Exception\StateException; use Spiral\Queue\Failed\FailedJobHandlerInterface; -final class ErrorHandlerInterceptor implements CoreInterceptorInterface +final class ErrorHandlerInterceptor implements LegacyInterceptor, InterceptorInterface { public function __construct( private readonly FailedJobHandlerInterface $handler @@ -35,4 +38,24 @@ public function process(string $name, string $action, array $parameters, CoreInt throw $e; } } + + public function intercept(CallContextInterface $context, HandlerInterface $handler): mixed + { + try { + return $handler->handle($context); + } catch (\Throwable $e) { + $args = $context->getArguments(); + if (!$e instanceof StateException) { + $this->handler->handle( + $args['driver'], + $args['queue'], + $context->getTarget()->getPath()[0], + $args['payload'], + $e, + ); + } + + throw $e; + } + } } diff --git a/src/Queue/src/Interceptor/Consume/Handler.php b/src/Queue/src/Interceptor/Consume/Handler.php index 92c74a4db..b09a94a3c 100644 --- a/src/Queue/src/Interceptor/Consume/Handler.php +++ b/src/Queue/src/Interceptor/Consume/Handler.php @@ -6,6 +6,9 @@ use Spiral\Core\Container; use Spiral\Core\CoreInterface; +use Spiral\Interceptors\Context\CallContext; +use Spiral\Interceptors\Context\Target; +use Spiral\Interceptors\HandlerInterface; use Spiral\Telemetry\NullTracerFactory; use Spiral\Telemetry\TraceKind; use Spiral\Telemetry\TracerFactoryInterface; @@ -17,12 +20,14 @@ final class Handler { private readonly TracerFactoryInterface $tracerFactory; + private readonly bool $isLegacy; public function __construct( - private readonly CoreInterface $core, + private readonly HandlerInterface|CoreInterface $core, ?TracerFactoryInterface $tracerFactory = null, ) { $this->tracerFactory = $tracerFactory ?? new NullTracerFactory(new Container()); + $this->isLegacy = !$core instanceof HandlerInterface; } public function handle( @@ -35,15 +40,19 @@ public function handle( ): mixed { $tracer = $this->tracerFactory->make($headers); + $arguments = [ + 'driver' => $driver, + 'queue' => $queue, + 'id' => $id, + 'payload' => $payload, + 'headers' => $headers, + ]; + return $tracer->trace( name: \sprintf('Job handling [%s:%s]', $name, $id), - callback: fn (): mixed => $this->core->callAction($name, 'handle', [ - 'driver' => $driver, - 'queue' => $queue, - 'id' => $id, - 'payload' => $payload, - 'headers' => $headers, - ]), + callback: $this->isLegacy + ? fn (): mixed => $this->core->callAction($name, 'handle', $arguments) + : fn (): mixed => $this->core->handle(new CallContext(Target::fromPair($name, 'handle'), $arguments)), attributes: [ 'queue.driver' => $driver, 'queue.name' => $queue, diff --git a/src/Queue/src/Interceptor/Push/Core.php b/src/Queue/src/Interceptor/Push/Core.php index 63f2bbe67..dada2541b 100644 --- a/src/Queue/src/Interceptor/Push/Core.php +++ b/src/Queue/src/Interceptor/Push/Core.php @@ -6,6 +6,8 @@ use Spiral\Core\ContainerScope; use Spiral\Core\CoreInterface; +use Spiral\Interceptors\Context\CallContext; +use Spiral\Interceptors\HandlerInterface; use Spiral\Queue\Options; use Spiral\Queue\OptionsInterface; use Spiral\Queue\QueueInterface; @@ -16,7 +18,7 @@ * @internal * @psalm-type TParameters = array{options: ?OptionsInterface, payload: mixed} */ -final class Core implements CoreInterface +final class Core implements CoreInterface, HandlerInterface { public function __construct( private readonly QueueInterface $connection, @@ -25,6 +27,7 @@ public function __construct( /** * @param-assert TParameters $parameters + * @deprecated */ public function callAction( string $controller, @@ -56,6 +59,15 @@ public function callAction( ); } + public function handle(CallContext $context): mixed + { + $args = $context->getArguments(); + $controller = $context->getTarget()->getPath()[0]; + $action = $context->getTarget()->getPath()[1]; + + return $this->callAction($controller, $action, $args); + } + private function getTracer(): TracerInterface { try { diff --git a/src/Queue/src/JobHandler.php b/src/Queue/src/JobHandler.php index 29d10f9ef..a89bced8a 100644 --- a/src/Queue/src/JobHandler.php +++ b/src/Queue/src/JobHandler.php @@ -4,6 +4,7 @@ namespace Spiral\Queue; +use Spiral\Core\Attribute\Proxy; use Spiral\Core\InvokerInterface; use Spiral\Queue\Exception\JobException; @@ -18,7 +19,7 @@ abstract class JobHandler implements HandlerInterface protected const HANDLE_FUNCTION = 'invoke'; public function __construct( - protected InvokerInterface $invoker, + #[Proxy] protected InvokerInterface $invoker, ) { } diff --git a/src/Queue/src/Queue.php b/src/Queue/src/Queue.php index ee7583d9b..2978e7ff6 100644 --- a/src/Queue/src/Queue.php +++ b/src/Queue/src/Queue.php @@ -5,6 +5,9 @@ namespace Spiral\Queue; use Spiral\Core\CoreInterface; +use Spiral\Interceptors\Context\CallContext; +use Spiral\Interceptors\Context\Target; +use Spiral\Interceptors\HandlerInterface as InterceptorHandler; /** * This class is used to push jobs into the queue and pass them through the interceptor chain @@ -15,16 +18,23 @@ */ final class Queue implements QueueInterface { + private readonly bool $isLegacy; + public function __construct( - private readonly CoreInterface $core, + private readonly CoreInterface|InterceptorHandler $core, ) { + $this->isLegacy = !$core instanceof HandlerInterface; } public function push(string $name, mixed $payload = [], mixed $options = null): string { - return $this->core->callAction($name, 'push', [ + $arguments = [ 'payload' => $payload, 'options' => $options, - ]); + ]; + + return $this->isLegacy + ? $this->core->callAction($name, 'push', $arguments) + : $this->core->handle(new CallContext(Target::fromPair($name, 'push'), $arguments)); } } diff --git a/src/Queue/src/QueueManager.php b/src/Queue/src/QueueManager.php index 5a686f675..97b1be5b1 100644 --- a/src/Queue/src/QueueManager.php +++ b/src/Queue/src/QueueManager.php @@ -7,10 +7,11 @@ use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Spiral\Core\Container\Autowire; -use Spiral\Core\CoreInterceptorInterface; +use Spiral\Core\CoreInterceptorInterface as LegacyInterceptor; use Spiral\Core\Exception\Container\ContainerException; use Spiral\Core\FactoryInterface; -use Spiral\Core\InterceptableCore; +use Spiral\Core\InterceptorPipeline; +use Spiral\Interceptors\InterceptorInterface; use Spiral\Queue\Config\QueueConfig; use Spiral\Queue\Interceptor\Push\Core as PushCore; @@ -50,22 +51,18 @@ private function resolveConnection(string $name): QueueInterface try { $driver = $this->factory->make($config['driver'], $config); - - $core = new InterceptableCore( - new PushCore($driver), - $this->dispatcher - ); + $pipeline = (new InterceptorPipeline($this->dispatcher))->withHandler(new PushCore($driver)); foreach ($this->config->getPushInterceptors() as $interceptor) { if (\is_string($interceptor) || $interceptor instanceof Autowire) { $interceptor = $this->container->get($interceptor); } - \assert($interceptor instanceof CoreInterceptorInterface); - $core->addInterceptor($interceptor); + \assert($interceptor instanceof LegacyInterceptor || $interceptor instanceof InterceptorInterface); + $pipeline->addInterceptor($interceptor); } - return new Queue($core); + return new Queue($pipeline); } catch (ContainerException $e) { throw new Exception\NotSupportedDriverException( \sprintf( diff --git a/src/Router/src/CoreHandler.php b/src/Router/src/CoreHandler.php index 7eac6c1d4..ba000af2b 100644 --- a/src/Router/src/CoreHandler.php +++ b/src/Router/src/CoreHandler.php @@ -10,14 +10,20 @@ use Psr\Http\Server\RequestHandlerInterface; use Spiral\Core\CoreInterface; use Spiral\Core\Exception\ControllerException; +use Spiral\Core\Scope; use Spiral\Core\ScopeInterface; use Spiral\Http\Exception\ClientException; use Spiral\Http\Exception\ClientException\BadRequestException; use Spiral\Http\Exception\ClientException\ForbiddenException; use Spiral\Http\Exception\ClientException\NotFoundException; +use Spiral\Http\Exception\ClientException\ServerErrorException; use Spiral\Http\Exception\ClientException\UnauthorizedException; use Spiral\Http\Stream\GeneratorStream; use Spiral\Http\Traits\JsonTrait; +use Spiral\Interceptors\Context\CallContext; +use Spiral\Interceptors\Context\Target; +use Spiral\Interceptors\Exception\TargetCallException; +use Spiral\Interceptors\HandlerInterface; use Spiral\Router\Exception\HandlerException; use Spiral\Telemetry\NullTracer; use Spiral\Telemetry\TracerInterface; @@ -37,13 +43,16 @@ final class CoreHandler implements RequestHandlerInterface /** @readonly */ private ?array $parameters = null; + private bool $isLegacyPipeline; + public function __construct( - private readonly CoreInterface $core, + private readonly HandlerInterface|CoreInterface $core, private readonly ScopeInterface $scope, private readonly ResponseFactoryInterface $responseFactory, ?TracerInterface $tracer = null ) { $this->tracer = $tracer ?? new NullTracer($scope); + $this->isLegacyPipeline = !$core instanceof HandlerInterface; } /** @@ -95,18 +104,29 @@ public function handle(Request $request): Response : $this->action; // run the core withing PSR-7 Request/Response scope + /** + * @psalm-suppress InvalidArgument + * TODO: Can we bind all controller classes at the bootstrap stage? + */ $result = $this->scope->runScope( - [ - Request::class => $request, - Response::class => $response, - ], + new Scope( + name: 'http.request', + bindings: [Request::class => $request, Response::class => $response, $controller => $controller], + ), fn (): mixed => $this->tracer->trace( name: 'Controller [' . $controller . ':' . $action . ']', - callback: fn (): mixed => $this->core->callAction( - controller: $controller, - action: $action, - parameters: $parameters, - ), + callback: $this->isLegacyPipeline + ? fn (): mixed => $this->core->callAction( + controller: $controller, + action: $action, + parameters: $parameters, + ) + : fn (): mixed => $this->core->handle( + new CallContext( + Target::fromPair($controller, $action), + $parameters, + ), + ), attributes: [ 'route.controller' => $this->controller, 'route.action' => $action, @@ -114,7 +134,7 @@ public function handle(Request $request): Response ] ) ); - } catch (ControllerException $e) { + } catch (TargetCallException $e) { \ob_get_clean(); throw $this->mapException($e); } catch (\Throwable $e) { @@ -169,13 +189,14 @@ private function wrapResponse(Response $response, mixed $result = null, string $ /** * Converts core specific ControllerException into HTTP ClientException. */ - private function mapException(ControllerException $exception): ClientException + private function mapException(TargetCallException $exception): ClientException { return match ($exception->getCode()) { - ControllerException::BAD_ACTION, - ControllerException::NOT_FOUND => new NotFoundException('Not found', $exception), - ControllerException::FORBIDDEN => new ForbiddenException('Forbidden', $exception), - ControllerException::UNAUTHORIZED => new UnauthorizedException('Unauthorized', $exception), + TargetCallException::BAD_ACTION, + TargetCallException::NOT_FOUND => new NotFoundException('Not found', $exception), + TargetCallException::FORBIDDEN => new ForbiddenException('Forbidden', $exception), + TargetCallException::UNAUTHORIZED => new UnauthorizedException('Unauthorized', $exception), + TargetCallException::INVALID_CONTROLLER => new ServerErrorException('Server error', $exception), default => new BadRequestException('Bad request', $exception), }; } diff --git a/src/Router/src/Loader/Configurator/ImportConfigurator.php b/src/Router/src/Loader/Configurator/ImportConfigurator.php index 61d53577c..8f062638b 100644 --- a/src/Router/src/Loader/Configurator/ImportConfigurator.php +++ b/src/Router/src/Loader/Configurator/ImportConfigurator.php @@ -6,6 +6,7 @@ use Psr\Http\Server\MiddlewareInterface; use Spiral\Core\CoreInterface; +use Spiral\Interceptors\HandlerInterface; use Spiral\Router\RouteCollection; final class ImportConfigurator @@ -77,7 +78,7 @@ public function namePrefix(string $prefix): self return $this; } - public function core(CoreInterface $core): self + public function core(HandlerInterface|CoreInterface $core): self { foreach ($this->routes->all() as $configurator) { $configurator->core($core); diff --git a/src/Router/src/Loader/Configurator/RouteConfigurator.php b/src/Router/src/Loader/Configurator/RouteConfigurator.php index 12e38d4a1..b6ed22adc 100644 --- a/src/Router/src/Loader/Configurator/RouteConfigurator.php +++ b/src/Router/src/Loader/Configurator/RouteConfigurator.php @@ -7,6 +7,7 @@ use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Spiral\Core\CoreInterface; +use Spiral\Interceptors\HandlerInterface; use Spiral\Router\Exception\TargetException; use Spiral\Router\RouteCollection; use Spiral\Router\Target\Action; @@ -21,7 +22,7 @@ final class RouteConfigurator private ?string $group = null; private ?array $methods = null; private string $prefix = ''; - private ?CoreInterface $core = null; + private HandlerInterface|CoreInterface|null $core = null; private ?array $middleware = null; /** @var null|string|callable|RequestHandlerInterface|TargetInterface */ @@ -133,7 +134,7 @@ public function prefix(string $prefix): self return $this; } - public function core(CoreInterface $core): self + public function core(HandlerInterface|CoreInterface $core): self { $this->core = $core; diff --git a/src/Router/src/RouteGroup.php b/src/Router/src/RouteGroup.php index bee98bf3d..0ef03843c 100644 --- a/src/Router/src/RouteGroup.php +++ b/src/Router/src/RouteGroup.php @@ -9,6 +9,7 @@ use Spiral\Core\Container\Autowire; use Spiral\Core\CoreInterface; use Spiral\Core\FactoryInterface; +use Spiral\Interceptors\HandlerInterface; use Spiral\Router\Target\AbstractTarget; /** @@ -27,7 +28,7 @@ final class RouteGroup /** @var array */ private array $middleware = []; - private Autowire|CoreInterface|string|null $core = null; + private Autowire|HandlerInterface|CoreInterface|string|null $core = null; public function __construct( /** @deprecated since v3.3.0 */ @@ -67,7 +68,7 @@ public function setNamePrefix(string $prefix): self return $this; } - public function setCore(Autowire|CoreInterface|string $core): self + public function setCore(Autowire|CoreInterface|HandlerInterface|string $core): self { $this->core = $core; @@ -93,7 +94,7 @@ public function register(RouterInterface $router, FactoryInterface $factory): vo { foreach ($this->routes as $name => $route) { if ($this->core !== null) { - if (!$this->core instanceof CoreInterface) { + if (!$this->core instanceof CoreInterface && !$this->core instanceof HandlerInterface) { $this->core = $factory->make($this->core); } diff --git a/src/Router/src/Target/AbstractTarget.php b/src/Router/src/Target/AbstractTarget.php index 52b0fdb81..8d2231a22 100644 --- a/src/Router/src/Target/AbstractTarget.php +++ b/src/Router/src/Target/AbstractTarget.php @@ -10,6 +10,7 @@ use Psr\Http\Server\RequestHandlerInterface as Handler; use Spiral\Core\CoreInterface; use Spiral\Core\ScopeInterface; +use Spiral\Interceptors\HandlerInterface; use Spiral\Router\CoreHandler; use Spiral\Router\Exception\TargetException; use Spiral\Router\TargetInterface; @@ -24,7 +25,7 @@ abstract class AbstractTarget implements TargetInterface // Automatically prepend HTTP verb to all action names. public const RESTFUL = 1; - private ?CoreInterface $core = null; + private HandlerInterface|CoreInterface|null $pipeline = null; private ?CoreHandler $handler = null; private bool $verbActions; @@ -49,11 +50,24 @@ public function getConstrains(): array /** * @mutation-free + * @deprecated Use {@see withHandler()} instead. */ - public function withCore(CoreInterface $core): TargetInterface + public function withCore(HandlerInterface|CoreInterface $core): TargetInterface { $target = clone $this; - $target->core = $core; + $target->pipeline = $core; + $target->handler = null; + + return $target; + } + + /** + * @mutation-free + */ + public function withHandler(HandlerInterface $handler): TargetInterface + { + $target = clone $this; + $target->pipeline = $handler; $target->handler = null; return $target; @@ -77,7 +91,7 @@ protected function coreHandler(ContainerInterface $container): CoreHandler try { // construct on demand $this->handler = new CoreHandler( - $this->core ?? $container->get(CoreInterface::class), + $this->pipeline ?? $container->get(HandlerInterface::class), $container->get(ScopeInterface::class), $container->get(ResponseFactoryInterface::class), $container->get(TracerInterface::class) diff --git a/src/Router/tests/BaseTestCase.php b/src/Router/tests/BaseTestCase.php index 2c8ced74e..02083e3b8 100644 --- a/src/Router/tests/BaseTestCase.php +++ b/src/Router/tests/BaseTestCase.php @@ -12,7 +12,10 @@ use Spiral\Core\Container; use Spiral\Core\Container\Autowire; use Spiral\Core\CoreInterface; +use Spiral\Core\Options; use Spiral\Http\Config\HttpConfig; +use Spiral\Http\CurrentRequest; +use Spiral\Interceptors\HandlerInterface; use Spiral\Router\GroupRegistry; use Spiral\Router\Loader\Configurator\RoutingConfigurator; use Spiral\Router\Loader\DelegatingLoader; @@ -73,7 +76,9 @@ public static function middlewaresDataProvider(): \Traversable private function initContainer(): void { - $this->container = new Container(); + $options = new Options(); + $options->checkScope = false; + $this->container = new Container(options: $options); $this->container->bind(TracerInterface::class, new NullTracer($this->container)); $this->container->bind(ResponseFactoryInterface::class, new ResponseFactory(new HttpConfig(['headers' => []]))); $this->container->bind(UriFactoryInterface::class, new UriFactory()); @@ -87,9 +92,13 @@ private function initContainer(): void ) ); + $this->container->bind(HandlerInterface::class, Core::class); $this->container->bind(CoreInterface::class, Core::class); $this->container->bindSingleton(GroupRegistry::class, GroupRegistry::class); $this->container->bindSingleton(RoutingConfigurator::class, RoutingConfigurator::class); + $this->container + ->getBinder('http') + ->bindSingleton(CurrentRequest::class, CurrentRequest::class); } private function initRouter(): void diff --git a/src/Router/tests/PipelineFactoryTest.php b/src/Router/tests/PipelineFactoryTest.php index a4125ba02..f090717e3 100644 --- a/src/Router/tests/PipelineFactoryTest.php +++ b/src/Router/tests/PipelineFactoryTest.php @@ -15,6 +15,7 @@ use Spiral\Core\Container\Autowire; use Spiral\Core\FactoryInterface; use Spiral\Core\ScopeInterface; +use Spiral\Http\CurrentRequest; use Spiral\Http\Pipeline; use Spiral\Router\Exception\RouteException; use Spiral\Router\PipelineFactory; @@ -22,17 +23,15 @@ final class PipelineFactoryTest extends \PHPUnit\Framework\TestCase { - use m\Adapter\Phpunit\MockeryPHPUnitIntegration; - - private ContainerInterface&m\MockInterface $container; - private FactoryInterface&m\MockInterface $factory; + private ContainerInterface $container; + private FactoryInterface $factory; private PipelineFactory $pipeline; protected function setUp(): void { parent::setUp(); - $this->container = m::mock(ContainerInterface::class); + $this->container = $this->createMock(ContainerInterface::class); $this->factory = m::mock(FactoryInterface::class); $this->pipeline = new PipelineFactory($this->container, $this->factory); @@ -41,7 +40,7 @@ protected function setUp(): void public function testCreatesFromArrayWithPipeline(): void { $newPipeline = new Pipeline( - scope: m::mock(ScopeInterface::class) + scope: $this->createMock(ScopeInterface::class), ); $this->assertSame( @@ -53,13 +52,14 @@ public function testCreatesFromArrayWithPipeline(): void public function testCreates(): void { $container = new Container(); + $container->bindSingleton(CurrentRequest::class, new CurrentRequest()); $this->factory ->shouldReceive('make') ->once() ->with(Pipeline::class) ->andReturn($p = new Pipeline( - $scope = m::mock(ScopeInterface::class), + $this->createMock(ScopeInterface::class), tracer: new NullTracer($container) )); @@ -67,17 +67,18 @@ public function testCreates(): void ->shouldReceive('make') ->once() ->with('bar', []) - ->andReturn($middleware5 = m::mock(MiddlewareInterface::class)); + ->andReturn($middleware5 = $this->createMock(MiddlewareInterface::class)); - $this->container->shouldReceive('get') - ->once() + $this->container + ->expects($this->once()) + ->method('get') ->with('foo') - ->andReturn($middleware4 = m::mock(MiddlewareInterface::class)); + ->willReturn($middleware4 = $this->createMock(MiddlewareInterface::class)); $this->assertSame($p, $this->pipeline->createWithMiddleware([ 'foo', - $middleware1 = m::mock(MiddlewareInterface::class), - $middleware2 = m::mock(MiddlewareInterface::class), + $middleware1 = $this->createMock(MiddlewareInterface::class), + $middleware2 = $this->createMock(MiddlewareInterface::class), new Autowire('bar'), ])); @@ -85,22 +86,25 @@ public function testCreates(): void return $handler->handle($request); }; - $middleware1->shouldReceive('process')->once()->andReturnUsing($handle); - $middleware2->shouldReceive('process')->once()->andReturnUsing($handle); - $middleware4->shouldReceive('process')->once()->andReturnUsing($handle); - $middleware5->shouldReceive('process')->once()->andReturnUsing($handle); - - $scope->shouldReceive('runScope') - ->once() - ->andReturn($response = m::mock(ResponseInterface::class)); + $middleware1->expects($this->once())->method('process')->willReturnCallback($handle); + $middleware2->expects($this->once())->method('process')->willReturnCallback($handle); + $middleware4->expects($this->once())->method('process')->willReturnCallback($handle); + $middleware5->expects($this->once())->method('process')->willReturnCallback($handle); + $response = $this->createMock(ResponseInterface::class); $response - ->shouldReceive('getHeaderLine')->with('Content-Length')->andReturn('test') - ->shouldReceive('getStatusCode')->andReturn(200); + ->expects($this->exactly(4))->method('getHeaderLine')->with('Content-Length')->willReturn('test'); + $response->expects($this->exactly(8))->method('getStatusCode')->willReturn(200); + + $requestHandler = $this->createMock(RequestHandlerInterface::class); + $requestHandler + ->expects($this->once()) + ->method('handle') + ->willReturn($response); $p - ->withHandler(m::mock(RequestHandlerInterface::class)) - ->handle(m::mock(ServerRequestInterface::class)); + ->withHandler($requestHandler) + ->handle($this->createMock(ServerRequestInterface::class)); } #[DataProvider('invalidTypeDataProvider')] diff --git a/src/Router/tests/RouteGroupTest.php b/src/Router/tests/RouteGroupTest.php index c0e8679ac..c4bb000e2 100644 --- a/src/Router/tests/RouteGroupTest.php +++ b/src/Router/tests/RouteGroupTest.php @@ -45,7 +45,7 @@ public function testCoreString(): void $this->assertSame('controller', $this->getProperty($t, 'controller')); $this->assertSame('method', $this->getProperty($t, 'action')); - $this->assertInstanceOf(RoutesTestCore::class, $this->getActionProperty($t, 'core')); + $this->assertInstanceOf(RoutesTestCore::class, $this->getActionProperty($t, 'pipeline')); } public function testCoreObject(): void @@ -63,7 +63,7 @@ public function testCoreObject(): void $this->assertSame('controller', $this->getProperty($t, 'controller')); $this->assertSame('method', $this->getProperty($t, 'action')); - $this->assertInstanceOf(RoutesTestCore::class, $this->getActionProperty($t, 'core')); + $this->assertInstanceOf(RoutesTestCore::class, $this->getActionProperty($t, 'pipeline')); } public function testGroupHasRoute(): void diff --git a/src/Scaffolder/src/Command/AbstractCommand.php b/src/Scaffolder/src/Command/AbstractCommand.php index e1447507c..bf28071ef 100644 --- a/src/Scaffolder/src/Command/AbstractCommand.php +++ b/src/Scaffolder/src/Command/AbstractCommand.php @@ -7,6 +7,7 @@ use Psr\Container\ContainerInterface; use Spiral\Boot\DirectoriesInterface; use Spiral\Console\Command; +use Spiral\Core\Attribute\Proxy; use Spiral\Core\FactoryInterface; use Spiral\Files\FilesInterface; use Spiral\Reactor\Writer; @@ -19,7 +20,7 @@ abstract class AbstractCommand extends Command public function __construct( protected ScaffolderConfig $config, protected FilesInterface $files, - ContainerInterface $container, + #[Proxy] ContainerInterface $container, private readonly FactoryInterface $factory, private readonly DirectoriesInterface $dirs, ) { diff --git a/src/Scaffolder/src/Declaration/BootloaderDeclaration.php b/src/Scaffolder/src/Declaration/BootloaderDeclaration.php index 57d188a6e..136ed39fc 100644 --- a/src/Scaffolder/src/Declaration/BootloaderDeclaration.php +++ b/src/Scaffolder/src/Declaration/BootloaderDeclaration.php @@ -9,7 +9,7 @@ use Spiral\Boot\BootloadManager\Methods; use Spiral\Boot\KernelInterface; use Spiral\Bootloader\DomainBootloader; -use Spiral\Core\CoreInterface; +use Spiral\Interceptors\HandlerInterface; use Spiral\Scaffolder\Config\ScaffolderConfig; class BootloaderDeclaration extends AbstractDeclaration implements HasInstructions @@ -45,9 +45,9 @@ public function declare(): void if ($this->isDomain) { $this->class->addConstant('INTERCEPTORS', [])->setProtected(); - $this->namespace->addUse(CoreInterface::class); + $this->namespace->addUse(HandlerInterface::class); $this->class->getConstant('SINGLETONS')->setValue([ - new Literal('CoreInterface::class => [self::class, \'domainCore\']'), + new Literal('HandlerInterface::class => [self::class, \'domainCore\']'), ]); } diff --git a/src/Scaffolder/tests/Command/BootloaderTest.php b/src/Scaffolder/tests/Command/BootloaderTest.php index 83e00cbad..9014d70b9 100644 --- a/src/Scaffolder/tests/Command/BootloaderTest.php +++ b/src/Scaffolder/tests/Command/BootloaderTest.php @@ -7,6 +7,7 @@ use ReflectionClass; use ReflectionException; use Spiral\Core\CoreInterface; +use Spiral\Interceptors\HandlerInterface; use Throwable; final class BootloaderTest extends AbstractCommandTestCase @@ -97,7 +98,7 @@ public function testScaffoldForDomainBootloader(): void $this->assertTrue($reflection->hasConstant('SINGLETONS')); $this->assertEquals([ - CoreInterface::class => ['Spiral\Tests\Scaffolder\App\Bootloader\SampleDomainBootloader', 'domainCore'], + HandlerInterface::class => ['Spiral\Tests\Scaffolder\App\Bootloader\SampleDomainBootloader', 'domainCore'], ], $reflection->getConstant('SINGLETONS')); } @@ -112,7 +113,7 @@ public function testShowInstructionAfterInstallation(): void $output = $result->getOutput()->fetch(); - $this->assertSame( + $this->assertStringEqualsStringIgnoringLineEndings( <<getOutput()->fetch(); - $this->assertSame( + $this->assertStringEqualsStringIgnoringLineEndings( <<getOutput()->fetch(); - $this->assertSame( + $this->assertStringEqualsStringIgnoringLineEndings( <<getOutput()->fetch(); - $this->assertSame( + $this->assertStringEqualsStringIgnoringLineEndings( <<getOutput()->fetch(); - $this->assertSame( + $this->assertStringEqualsStringIgnoringLineEndings( <<getOutput()->fetch(); - $this->assertSame( + $this->assertStringEqualsStringIgnoringLineEndings( <<getOutput()->fetch(); - $this->assertSame( + $this->assertStringEqualsStringIgnoringLineEndings( << [ //No directory ] - ]), new Container()); + ]), $this->container); $factory->initSession('sig', 'sessionid'); } @@ -49,7 +47,7 @@ public function testAlreadyStarted(): void 'handlers' => [ //No directory ] - ]), new Container()); + ]), $this->container); $factory->initSession('sig', 'sessionid'); } @@ -64,9 +62,9 @@ public function testMultipleSessions(): void 'secure' => false, 'handler' => null, 'handlers' => [] - ]), $c = new Container()); + ]), $this->container); - $c->bind(SessionInterface::class, Session::class); + $this->container->bind(SessionInterface::class, Session::class); $session = $factory->initSession('sig'); $session->resume(); diff --git a/src/Session/tests/SessionTest.php b/src/Session/tests/SessionTest.php index a043259fa..0c12ce335 100644 --- a/src/Session/tests/SessionTest.php +++ b/src/Session/tests/SessionTest.php @@ -4,7 +4,6 @@ namespace Spiral\Tests\Session; -use PHPUnit\Framework\TestCase; use Spiral\Core\Container; use Spiral\Files\Files; use Spiral\Files\FilesInterface; @@ -16,21 +15,17 @@ use Spiral\Session\SessionInterface; use Spiral\Session\SessionSection; -class SessionTest extends TestCase +final class SessionTest extends TestCase { - - /** - * @var SessionFactory - */ - private $factory; + private SessionFactory $factory; public function setUp(): void { - $container = new Container(); - $container->bind(FilesInterface::class, Files::class); + parent::setUp(); - $container->bind(SessionInterface::class, Session::class); - $container->bind(SessionSectionInterface::class, SessionSection::class); + $this->container->bind(FilesInterface::class, Files::class); + $this->container->bind(SessionInterface::class, Session::class); + $this->container->bind(SessionSectionInterface::class, SessionSection::class); $this->factory = new SessionFactory(new SessionConfig([ 'lifetime' => 86400, @@ -39,7 +34,7 @@ public function setUp(): void 'handler' => new Container\Autowire(FileHandler::class, [ 'directory' => sys_get_temp_dir() ]), - ]), $container); + ]), $this->container); } public function tearDown(): void diff --git a/src/Session/tests/TestCase.php b/src/Session/tests/TestCase.php new file mode 100644 index 000000000..354860354 --- /dev/null +++ b/src/Session/tests/TestCase.php @@ -0,0 +1,20 @@ +checkScope = false; + $this->container = new Container(options: $options); + } +} diff --git a/src/Telemetry/src/AbstractTracer.php b/src/Telemetry/src/AbstractTracer.php index ade2fb35a..547e3ddab 100644 --- a/src/Telemetry/src/AbstractTracer.php +++ b/src/Telemetry/src/AbstractTracer.php @@ -4,6 +4,7 @@ namespace Spiral\Telemetry; +use Spiral\Core\Attribute\Proxy; use Spiral\Core\Container; use Spiral\Core\InvokerInterface; use Spiral\Core\ScopeInterface; @@ -16,7 +17,7 @@ abstract class AbstractTracer implements TracerInterface { public function __construct( - private readonly ?ScopeInterface $scope = new Container(), + #[Proxy] private readonly ?ScopeInterface $scope = new Container(), ) { } @@ -25,6 +26,7 @@ public function __construct( */ final protected function runScope(Span $span, callable $callback): mixed { + // TODO: Can we remove this scope? return $this->scope->runScope([ SpanInterface::class => $span, TracerInterface::class => $this, diff --git a/src/Telemetry/src/LogTracerFactory.php b/src/Telemetry/src/LogTracerFactory.php index cb1c0ab07..0efbaaa4d 100644 --- a/src/Telemetry/src/LogTracerFactory.php +++ b/src/Telemetry/src/LogTracerFactory.php @@ -6,6 +6,7 @@ use Psr\Log\LoggerInterface; use Ramsey\Uuid\UuidFactory; +use Spiral\Core\Attribute\Proxy; use Spiral\Core\ScopeInterface; use Spiral\Logger\LogsInterface; @@ -21,7 +22,7 @@ final class LogTracerFactory implements TracerFactoryInterface private readonly LoggerInterface $logger; public function __construct( - private readonly ScopeInterface $scope, + #[Proxy] private readonly ScopeInterface $scope, private readonly ClockInterface $clock, LogsInterface $logs, string $channel = self::LOG_CHANNEL diff --git a/src/Telemetry/src/NullTracerFactory.php b/src/Telemetry/src/NullTracerFactory.php index ff47882ef..26fe07ced 100644 --- a/src/Telemetry/src/NullTracerFactory.php +++ b/src/Telemetry/src/NullTracerFactory.php @@ -4,6 +4,7 @@ namespace Spiral\Telemetry; +use Spiral\Core\Attribute\Proxy; use Spiral\Core\Container; use Spiral\Core\ScopeInterface; @@ -15,7 +16,7 @@ final class NullTracerFactory implements TracerFactoryInterface { public function __construct( - private readonly ?ScopeInterface $scope = new Container(), + #[Proxy] private readonly ?ScopeInterface $scope = new Container(), ) { } diff --git a/tests/Framework/AuthHttp/Middleware/AuthMiddlewareTest.php b/tests/Framework/AuthHttp/Middleware/AuthMiddlewareTest.php index 581e810d9..3ec7144ed 100644 --- a/tests/Framework/AuthHttp/Middleware/AuthMiddlewareTest.php +++ b/tests/Framework/AuthHttp/Middleware/AuthMiddlewareTest.php @@ -8,6 +8,8 @@ use Spiral\Auth\TokenStorageInterface; use Spiral\Auth\TokenStorageScope; use Spiral\Core\Container\Autowire; +use Spiral\Framework\Spiral; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\HttpTestCase; final class AuthMiddlewareTest extends HttpTestCase @@ -19,6 +21,7 @@ public function setUp(): void $this->enableMiddlewares(); } + #[TestScope(Spiral::Http)] public function testTokenStorageInterfaceShouldBeBound(): void { $storage = $this->createMock(TokenStorageInterface::class); @@ -26,6 +29,7 @@ public function testTokenStorageInterfaceShouldBeBound(): void AuthMiddleware::class, new Autowire(AuthMiddleware::class, ['tokenStorage' => $storage]) ); + $this->setHttpHandler(function () use ($storage): void { $scope = $this->getContainer()->get(TokenStorageScope::class); $ref = new \ReflectionMethod($scope, 'getTokenStorage'); @@ -34,14 +38,6 @@ public function testTokenStorageInterfaceShouldBeBound(): void $this->assertSame($storage, $ref->invoke($scope)); }); - $scope = $this->getContainer()->get(TokenStorageScope::class); - $ref = new \ReflectionMethod($scope, 'getTokenStorage'); - $this->assertNotInstanceOf($storage::class, $ref->invoke($scope)); - - $this->getHttp()->get('/'); - - $scope = $this->getContainer()->get(TokenStorageScope::class); - $ref = new \ReflectionMethod($scope, 'getTokenStorage'); - $this->assertNotInstanceOf($storage::class, $ref->invoke($scope)); + $this->fakeHttp()->get('/'); } } diff --git a/tests/Framework/BaseTestCase.php b/tests/Framework/BaseTestCase.php index 7f878d3a5..95dfb5255 100644 --- a/tests/Framework/BaseTestCase.php +++ b/tests/Framework/BaseTestCase.php @@ -22,12 +22,13 @@ public function rootDirectory(): string public function createAppInstance(Container $container = new Container()): TestApp { return TestApp::create( - $this->defineDirectories($this->rootDirectory()), - false + directories: $this->defineDirectories($this->rootDirectory()), + handleErrors: false, + container: $container, )->disableBootloader(...$this->disabledBootloaders); } - public function withDisabledBootloaders(string ... $bootloader): self + public function withDisabledBootloaders(string ...$bootloader): self { $this->disabledBootloaders = $bootloader; diff --git a/tests/Framework/Bootloader/DomainBootloaderTest.php b/tests/Framework/Bootloader/DomainBootloaderTest.php index 334e205b9..abd96d952 100644 --- a/tests/Framework/Bootloader/DomainBootloaderTest.php +++ b/tests/Framework/Bootloader/DomainBootloaderTest.php @@ -11,6 +11,7 @@ use Spiral\Core\Container\Autowire; use Spiral\Core\Core; use Spiral\Core\InterceptableCore; +use Spiral\Core\InterceptorPipeline; use Spiral\Tests\Framework\BaseTestCase; final class DomainBootloaderTest extends BaseTestCase @@ -40,14 +41,15 @@ protected static function defineInterceptors(): array } }; - /** @var InterceptableCore $core */ - $core = (new \ReflectionMethod($bootloader, 'domainCore')) + /** @var InterceptorPipeline $pipeline */ + $pipeline = (new \ReflectionMethod($bootloader, 'domainCore')) ->invoke($bootloader, $this->getContainer()->get(Core::class), $this->getContainer()); - $pipeline = (new \ReflectionProperty($core, 'pipeline'))->getValue($core); + + $interceptors = (new \ReflectionProperty($pipeline, 'interceptors'))->getValue($pipeline); $this->assertEquals( [new One(), new Two(), new Three()], - (new \ReflectionProperty($pipeline, 'interceptors'))->getValue($pipeline) + $interceptors, ); } } diff --git a/tests/Framework/Bootloader/Http/CookiesBootloaderTest.php b/tests/Framework/Bootloader/Http/CookiesBootloaderTest.php index 0754a2142..4e6544cc4 100644 --- a/tests/Framework/Bootloader/Http/CookiesBootloaderTest.php +++ b/tests/Framework/Bootloader/Http/CookiesBootloaderTest.php @@ -6,35 +6,50 @@ use Psr\Http\Message\ServerRequestInterface; use Spiral\Bootloader\Http\CookiesBootloader; +use Spiral\Bootloader\Http\Exception\ContextualObjectNotFoundException; +use Spiral\Bootloader\Http\Exception\InvalidRequestScopeException; use Spiral\Config\ConfigManager; use Spiral\Config\LoaderInterface; use Spiral\Cookies\Config\CookiesConfig; use Spiral\Cookies\CookieQueue; +use Spiral\Framework\Spiral; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\BaseTestCase; final class CookiesBootloaderTest extends BaseTestCase { + #[TestScope(Spiral::Http)] public function testCookieQueueBinding(): void { $request = $this->mockContainer(ServerRequestInterface::class); $request->shouldReceive('getAttribute') - ->once() - ->with(CookieQueue::ATTRIBUTE, null) - ->andReturn(new CookieQueue()); + ->with(CookieQueue::ATTRIBUTE) + ->andReturn(new CookieQueue(), new CookieQueue(), new CookieQueue(), new CookieQueue()); $this->assertContainerBound(CookieQueue::class); + // Makes 3 calls to the container + $this->assertContainerBoundNotAsSingleton(CookieQueue::class, CookieQueue::class); } - public function testCookieQueueBindingShouldThrowAndExceptionWhenAttributeIsEmpty(): void + #[TestScope(Spiral::Http)] + public function testCookieQueueBindingWithoutCookieQueueInRequest(): void { - $this->expectExceptionMessage('Unable to resolve CookieQueue, invalid request scope'); $request = $this->mockContainer(ServerRequestInterface::class); $request->shouldReceive('getAttribute') - ->once() - ->with(CookieQueue::ATTRIBUTE, null) - ->andReturnNull(); + ->with(CookieQueue::ATTRIBUTE) + ->andReturn(null); - $this->assertContainerBound(CookieQueue::class); + $this->expectException(ContextualObjectNotFoundException::class); + + $this->getContainer()->get(CookieQueue::class); + } + + #[TestScope(Spiral::Http)] + public function testCookieQueueBindingWithoutRequest(): void + { + $this->expectException(InvalidRequestScopeException::class); + + $this->getContainer()->get(CookieQueue::class); } public function testConfig(): void @@ -51,7 +66,7 @@ public function testWhitelistCookie(): void $configs = new ConfigManager($this->createMock(LoaderInterface::class)); $configs->setDefaults(CookiesConfig::CONFIG, ['excluded' => []]); - $bootloader = new CookiesBootloader($configs); + $bootloader = new CookiesBootloader($configs, $this->getContainer()); $bootloader->whitelistCookie('foo'); $this->assertSame(['foo'], $configs->getConfig(CookiesConfig::CONFIG)['excluded']); diff --git a/tests/Framework/Bootloader/Http/HttpAuthBootloaderTest.php b/tests/Framework/Bootloader/Http/HttpAuthBootloaderTest.php index cbd1e86b0..f5dc61f9e 100644 --- a/tests/Framework/Bootloader/Http/HttpAuthBootloaderTest.php +++ b/tests/Framework/Bootloader/Http/HttpAuthBootloaderTest.php @@ -4,6 +4,10 @@ namespace Framework\Bootloader\Http; +use Psr\Http\Message\ServerRequestInterface; +use Spiral\Auth\ActorProviderInterface; +use Spiral\Auth\AuthContext; +use Spiral\Auth\AuthContextInterface; use Spiral\Auth\Config\AuthConfig; use Spiral\Auth\Session\TokenStorage; use Spiral\Auth\Session\TokenStorage as SessionTokenStorage; @@ -13,6 +17,9 @@ use Spiral\Bootloader\Auth\HttpAuthBootloader; use Spiral\Config\LoaderInterface; use Spiral\Config\ConfigManager; +use Spiral\Framework\Spiral; +use Spiral\Http\CurrentRequest; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\BaseTestCase; final class HttpAuthBootloaderTest extends BaseTestCase @@ -27,6 +34,26 @@ public function testTokenStorageInterfaceBinding(): void $this->assertContainerBoundAsSingleton(TokenStorageInterface::class, TokenStorage::class); } + public function testProxyAuthContextInterfaceBinding(): void + { + $this->assertContainerBound(AuthContextInterface::class, AuthContextInterface::class); + } + + #[TestScope(Spiral::Http)] + public function testAuthContextInterfaceBinding(): void + { + $request = $this->createMock(ServerRequestInterface::class); + $request + ->method('getAttribute') + ->willReturn(new AuthContext($this->createMock(ActorProviderInterface::class))); + + $currentRequest = new CurrentRequest(); + $currentRequest->set($request); + $this->getContainer()->bindSingleton(CurrentRequest::class, $currentRequest); + + $this->assertContainerBound(AuthContextInterface::class, AuthContext::class); + } + public function testConfig(): void { $this->assertConfigHasFragments(AuthConfig::CONFIG, [ @@ -43,7 +70,7 @@ public function testAddTokenStorage(): void $configs = new ConfigManager($this->createMock(LoaderInterface::class)); $configs->setDefaults(AuthConfig::CONFIG, ['storages' => []]); - $bootloader = new HttpAuthBootloader($configs); + $bootloader = new HttpAuthBootloader($configs, $this->getContainer()); $bootloader->addTokenStorage('foo', 'bar'); $this->assertSame(['foo' => 'bar'], $configs->getConfig(AuthConfig::CONFIG)['storages']); @@ -54,7 +81,7 @@ public function testAddTransport(): void $configs = new ConfigManager($this->createMock(LoaderInterface::class)); $configs->setDefaults(AuthConfig::CONFIG, ['transports' => []]); - $bootloader = new HttpAuthBootloader($configs); + $bootloader = new HttpAuthBootloader($configs, $this->getContainer()); $bootloader->addTransport('foo', 'bar'); $this->assertSame(['foo' => 'bar'], $configs->getConfig(AuthConfig::CONFIG)['transports']); diff --git a/tests/Framework/Bootloader/Http/HttpBootloaderTest.php b/tests/Framework/Bootloader/Http/HttpBootloaderTest.php index 7ffd62396..7b258e802 100644 --- a/tests/Framework/Bootloader/Http/HttpBootloaderTest.php +++ b/tests/Framework/Bootloader/Http/HttpBootloaderTest.php @@ -12,13 +12,17 @@ use Spiral\Bootloader\Http\HttpBootloader; use Spiral\Config\ConfigManager; use Spiral\Config\LoaderInterface; +use Spiral\Core\Container; use Spiral\Core\Container\Autowire; +use Spiral\Framework\Spiral; use Spiral\Http\Config\HttpConfig; use Spiral\Http\Http; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\BaseTestCase; final class HttpBootloaderTest extends BaseTestCase { + #[TestScope(Spiral::Http)] public function testHttpBinding(): void { $this->assertContainerBoundAsSingleton(Http::class, Http::class); @@ -34,7 +38,7 @@ public function testAddInputBag(): void $configs = new ConfigManager($this->createMock(LoaderInterface::class)); $configs->setDefaults(HttpConfig::CONFIG, ['inputBags' => []]); - $bootloader = new HttpBootloader($configs); + $bootloader = new HttpBootloader($configs, new Container()); $bootloader->addInputBag('test', ['class' => 'foo', 'source' => 'bar']); $this->assertSame([ @@ -48,7 +52,7 @@ public function testAddMiddleware(mixed $middleware): void $configs = new ConfigManager($this->createMock(LoaderInterface::class)); $configs->setDefaults(HttpConfig::CONFIG, ['middleware' => []]); - $bootloader = new HttpBootloader($configs); + $bootloader = new HttpBootloader($configs, new Container()); $bootloader->addMiddleware($middleware); $this->assertSame([$middleware], $configs->getConfig(HttpConfig::CONFIG)['middleware']); diff --git a/tests/Framework/Bootloader/Http/PaginationBootloaderTest.php b/tests/Framework/Bootloader/Http/PaginationBootloaderTest.php index a3b51f2d2..5147677a8 100644 --- a/tests/Framework/Bootloader/Http/PaginationBootloaderTest.php +++ b/tests/Framework/Bootloader/Http/PaginationBootloaderTest.php @@ -4,14 +4,33 @@ namespace Framework\Bootloader\Http; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; +use Spiral\Framework\Spiral; use Spiral\Http\PaginationFactory; use Spiral\Pagination\PaginationProviderInterface; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\BaseTestCase; final class PaginationBootloaderTest extends BaseTestCase { + #[TestScope(Spiral::HttpRequest)] public function testPaginationProviderInterfaceBinding(): void { $this->assertContainerBoundAsSingleton(PaginationProviderInterface::class, PaginationFactory::class); } + + #[WithoutErrorHandler] + public function testPaginationProviderInterfaceBindingInRootScope(): void + { + \set_error_handler(static function (int $errno, string $error): void { + self::assertSame(\sprintf( + 'Using `%s` outside of the `http.request` scope is deprecated and will be impossible in version 4.0.', + PaginationProviderInterface::class + ), $error); + }); + + $this->assertContainerBoundAsSingleton(PaginationProviderInterface::class, PaginationProviderInterface::class); + + \restore_error_handler(); + } } diff --git a/tests/Framework/Bootloader/Http/SessionBootloaderTest.php b/tests/Framework/Bootloader/Http/SessionBootloaderTest.php index 1630fb8e0..5a1f6719c 100644 --- a/tests/Framework/Bootloader/Http/SessionBootloaderTest.php +++ b/tests/Framework/Bootloader/Http/SessionBootloaderTest.php @@ -4,14 +4,22 @@ namespace Framework\Bootloader\Http; +use Spiral\Framework\Spiral; use Spiral\Session\SessionFactory; use Spiral\Session\SessionFactoryInterface; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\BaseTestCase; final class SessionBootloaderTest extends BaseTestCase { + #[TestScope(Spiral::Http)] public function testSessionFactoryInterfaceBinding(): void { $this->assertContainerBoundAsSingleton(SessionFactoryInterface::class, SessionFactory::class); } + + public function testSessionFactoryInterfaceBindingInRootScope(): void + { + $this->assertContainerBoundAsSingleton(SessionFactoryInterface::class, SessionFactoryInterface::class); + } } diff --git a/tests/Framework/Bootloader/Router/RouterBootloaderTest.php b/tests/Framework/Bootloader/Router/RouterBootloaderTest.php index 2d31be257..bf70b1e54 100644 --- a/tests/Framework/Bootloader/Router/RouterBootloaderTest.php +++ b/tests/Framework/Bootloader/Router/RouterBootloaderTest.php @@ -7,6 +7,7 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Spiral\Core\CoreInterface; +use Spiral\Framework\Spiral; use Spiral\Router\GroupRegistry; use Spiral\Router\Loader\Configurator\RoutingConfigurator; use Spiral\Router\Loader\DelegatingLoader; @@ -18,6 +19,7 @@ use Spiral\Router\RouteInterface; use Spiral\Router\Router; use Spiral\Router\RouterInterface; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\BaseTestCase; final class RouterBootloaderTest extends BaseTestCase @@ -62,6 +64,7 @@ public function testRoutePatternRegistryBinding(): void $this->assertContainerBoundAsSingleton(RoutePatternRegistryInterface::class, DefaultPatternRegistry::class); } + #[TestScope(Spiral::HttpRequest)] public function testRouteInterfaceBinding(): void { $request = $this->mockContainer(ServerRequestInterface::class); @@ -73,6 +76,7 @@ public function testRouteInterfaceBinding(): void $this->assertContainerBoundAsSingleton(RouteInterface::class, RouteInterface::class); } + #[TestScope(Spiral::HttpRequest)] public function testRouteInterfaceShouldThrowAnExceptionWhenRequestDoesNotContainIt(): void { $this->expectExceptionMessage('Unable to resolve Route, invalid request scope'); diff --git a/tests/Framework/Bootloader/Security/FiltersBootloaderTest.php b/tests/Framework/Bootloader/Security/FiltersBootloaderTest.php index fb9d1f3d7..a9daf92cb 100644 --- a/tests/Framework/Bootloader/Security/FiltersBootloaderTest.php +++ b/tests/Framework/Bootloader/Security/FiltersBootloaderTest.php @@ -15,6 +15,8 @@ use Spiral\Filters\Model\Interceptor\PopulateDataFromEntityInterceptor; use Spiral\Filters\Model\Interceptor\ValidateFilterInterceptor; use Spiral\Filters\InputInterface; +use Spiral\Framework\Spiral; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\BaseTestCase; final class FiltersBootloaderTest extends BaseTestCase @@ -24,6 +26,7 @@ public function testFilterProviderInterfaceBinding(): void $this->assertContainerBoundAsSingleton(FilterProviderInterface::class, FilterProvider::class); } + #[TestScope(Spiral::HttpRequest)] public function testInputInterfaceBinding(): void { $this->assertContainerBoundAsSingleton(InputInterface::class, InputScope::class); diff --git a/tests/Framework/Console/Confirmation/ApplicationInProductionCommandTest.php b/tests/Framework/Console/Confirmation/ApplicationInProductionCommandTest.php new file mode 100644 index 000000000..a9250bb54 --- /dev/null +++ b/tests/Framework/Console/Confirmation/ApplicationInProductionCommandTest.php @@ -0,0 +1,30 @@ +assertConsoleCommandOutputContainsStrings('app-in-production', [], [ + 'Application is in production.', + ]); + } + + #[Env('APP_ENV', 'testing')] + public function testRunCommandInTestingEnv(): void + { + $this->assertConsoleCommandOutputContainsStrings('app-in-production', [], [ + 'Application is in testing.', + ]); + } +} diff --git a/tests/Framework/Filter/InputScopeTest.php b/tests/Framework/Filter/InputScopeTest.php index 0eea268c8..be19ada69 100644 --- a/tests/Framework/Filter/InputScopeTest.php +++ b/tests/Framework/Filter/InputScopeTest.php @@ -5,23 +5,25 @@ namespace Framework\Filter; use Nyholm\Psr7\ServerRequest; -use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\UriInterface; -use Spiral\Core\Container; -use Spiral\Filter\InputScope; -use Spiral\Http\Request\InputManager; - -final class InputScopeTest extends TestCase +use Spiral\Filters\InputInterface; +use Spiral\Framework\Spiral; +use Spiral\Http\Config\HttpConfig; +use Spiral\Http\Request\InputBag; +use Spiral\Testing\Attribute\TestScope; +use Spiral\Tests\Framework\BaseTestCase; + +#[TestScope(Spiral::HttpRequest)] +final class InputScopeTest extends BaseTestCase { - private InputScope $input; private ServerRequestInterface $request; protected function setUp(): void { parent::setUp(); - $container = new Container(); $request = new ServerRequest( method: 'POST', uri: 'https://site.com/users', @@ -37,7 +39,7 @@ protected function setUp(): void ] ); - $container->bind( + $this->getContainer()->bind( ServerRequestInterface::class, $this->request = $request ->withQueryParams(['foo' => 'bar']) @@ -45,23 +47,21 @@ protected function setUp(): void ->withParsedBody(['quux' => 'corge']) ->withAttribute('foz', 'baf'), ); - - $this->input = new InputScope(new InputManager($container)); } public function testGetsMethod(): void { - $this->assertSame('POST', $this->input->getValue('method')); + $this->assertSame('POST', $this->getContainer()->get(InputInterface::class)->getValue('method')); } public function testGetsPath(): void { - $this->assertSame('/users', $this->input->getValue('path')); + $this->assertSame('/users', $this->getContainer()->get(InputInterface::class)->getValue('path')); } public function testGetsUri(): void { - $uri = $this->input->getValue('uri'); + $uri = $this->getContainer()->get(InputInterface::class)->getValue('uri'); $this->assertInstanceOf(UriInterface::class, $uri); $this->assertSame('https://site.com/users', (string)$uri); @@ -69,45 +69,57 @@ public function testGetsUri(): void public function testGetsRequest(): void { - $this->assertSame($this->request, $this->input->getValue('request')); + $this->assertSame($this->request, $this->getContainer()->get(InputInterface::class)->getValue('request')); } public function testGetsBearerToken(): void { - $this->assertSame('123', $this->input->getValue('bearerToken')); + $this->assertSame('123', $this->getContainer()->get(InputInterface::class)->getValue('bearerToken')); } public function testIsSecure(): void { - $this->assertTrue($this->input->getValue('isSecure')); + $this->assertTrue($this->getContainer()->get(InputInterface::class)->getValue('isSecure')); } public function testIsAjax(): void { - $this->assertTrue($this->input->getValue('isAjax')); + $this->assertTrue($this->getContainer()->get(InputInterface::class)->getValue('isAjax')); } public function testIsXmlHttpRequest(): void { - $this->assertTrue($this->input->getValue('isXmlHttpRequest')); + $this->assertTrue($this->getContainer()->get(InputInterface::class)->getValue('isXmlHttpRequest')); } public function testIsJsonExpected(): void { - $this->assertTrue($this->input->getValue('isJsonExpected', true)); + $this->assertTrue($this->getContainer()->get(InputInterface::class)->getValue('isJsonExpected', true)); } public function testGetsRemoteAddress(): void { - $this->assertSame('123.123.123', $this->input->getValue('remoteAddress')); + $this->assertSame('123.123.123', $this->getContainer()->get(InputInterface::class)->getValue('remoteAddress')); } - /** - * @dataProvider InputBagsDataProvider - */ + #[DataProvider('InputBagsDataProvider')] public function testGetsInputBag(string $source, string $name, mixed $expected): void { - $this->assertSame($expected, $this->input->getValue($source, $name)); + $this->assertSame($expected, $this->getContainer()->get(InputInterface::class)->getValue($source, $name)); + } + + public function testGetValueFromCustomInputBag(): void + { + $this->getContainer() + ->bind( + HttpConfig::class, + new HttpConfig(['inputBags' => ['test' => ['class' => InputBag::class, 'source' => 'getParsedBody']]]) + ); + + $this->assertSame( + 'corge', + $this->getContainer()->get(InputInterface::class)->getValue('test', 'quux') + ); } public static function InputBagsDataProvider(): \Traversable diff --git a/tests/Framework/Filter/Model/CastingErrorMessagesTest.php b/tests/Framework/Filter/Model/CastingErrorMessagesTest.php index c90633d98..6c8d00a7c 100644 --- a/tests/Framework/Filter/Model/CastingErrorMessagesTest.php +++ b/tests/Framework/Filter/Model/CastingErrorMessagesTest.php @@ -6,10 +6,13 @@ use Spiral\App\Request\CastingErrorMessages; use Spiral\Filters\Exception\ValidationException; +use Spiral\Framework\Spiral; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\Filter\FilterTestCase; final class CastingErrorMessagesTest extends FilterTestCase { + #[TestScope(Spiral::HttpRequest)] public function testValidationMessages(): void { try { diff --git a/tests/Framework/Filter/Model/FilterWithSettersTest.php b/tests/Framework/Filter/Model/FilterWithSettersTest.php index 79ba75fae..e80088b4b 100644 --- a/tests/Framework/Filter/Model/FilterWithSettersTest.php +++ b/tests/Framework/Filter/Model/FilterWithSettersTest.php @@ -7,8 +7,11 @@ use Spiral\App\Request\FilterWithSetters; use Spiral\App\Request\PostFilter; use Spiral\Filters\Exception\ValidationException; +use Spiral\Framework\Spiral; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\Filter\FilterTestCase; +#[TestScope(Spiral::HttpRequest)] final class FilterWithSettersTest extends FilterTestCase { public function testSetters(): void diff --git a/tests/Framework/Filter/Model/MethodAttributeTest.php b/tests/Framework/Filter/Model/MethodAttributeTest.php index 17c5bf70f..425b41ad4 100644 --- a/tests/Framework/Filter/Model/MethodAttributeTest.php +++ b/tests/Framework/Filter/Model/MethodAttributeTest.php @@ -5,10 +5,13 @@ namespace Framework\Filter\Model; use Spiral\App\Request\TestRequest; +use Spiral\Framework\Spiral; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\Filter\FilterTestCase; final class MethodAttributeTest extends FilterTestCase { + #[TestScope(Spiral::HttpRequest)] public function testGetMethodValue(): void { $filter = $this->getFilter(TestRequest::class, method: 'GET'); diff --git a/tests/Framework/Filter/Model/NestedArrayFiltersTest.php b/tests/Framework/Filter/Model/NestedArrayFiltersTest.php index a899ca797..98569ddfe 100644 --- a/tests/Framework/Filter/Model/NestedArrayFiltersTest.php +++ b/tests/Framework/Filter/Model/NestedArrayFiltersTest.php @@ -8,8 +8,11 @@ use Spiral\App\Request\AddressFilter; use Spiral\App\Request\MultipleAddressesFilter; use Spiral\Filters\Exception\ValidationException; +use Spiral\Framework\Spiral; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\Filter\FilterTestCase; +#[TestScope(Spiral::HttpRequest)] final class NestedArrayFiltersTest extends FilterTestCase { public function testGetsNestedFilter(): void diff --git a/tests/Framework/Filter/Model/NestedFilterTest.php b/tests/Framework/Filter/Model/NestedFilterTest.php index 6aaa03086..62160c94e 100644 --- a/tests/Framework/Filter/Model/NestedFilterTest.php +++ b/tests/Framework/Filter/Model/NestedFilterTest.php @@ -14,8 +14,11 @@ use Spiral\App\Request\WithNullableNestedFilter; use Spiral\App\Request\WithNullableRequiredNestedFilter; use Spiral\Filters\Exception\ValidationException; +use Spiral\Framework\Spiral; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\Filter\FilterTestCase; +#[TestScope(Spiral::HttpRequest)] final class NestedFilterTest extends FilterTestCase { public function testGetsNestedFilter(): void diff --git a/tests/Framework/Http/AuthSessionTest.php b/tests/Framework/Http/AuthSessionTest.php index abb964f4e..ee330a607 100644 --- a/tests/Framework/Http/AuthSessionTest.php +++ b/tests/Framework/Http/AuthSessionTest.php @@ -4,8 +4,15 @@ namespace Spiral\Tests\Framework\Http; +use Spiral\Auth\AuthContextInterface; +use Spiral\Bootloader\Http\Exception\ContextualObjectNotFoundException; +use Spiral\Bootloader\Http\Exception\InvalidRequestScopeException; +use Spiral\Framework\Spiral; +use Spiral\Http\Config\HttpConfig; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\HttpTestCase; +#[TestScope(Spiral::Http)] final class AuthSessionTest extends HttpTestCase { public const ENV = [ @@ -14,57 +21,70 @@ final class AuthSessionTest extends HttpTestCase public function testNoToken(): void { - $this->getHttp()->get(uri: '/auth/token') - ->assertBodySame('none'); + $this->fakeHttp()->get(uri: '/auth/token')->assertBodySame('none'); } public function testLogin(): void { - $result = $this->getHttp()->get(uri: '/auth/login') + $result = $this->fakeHttp()->get(uri: '/auth/login') ->assertBodySame('OK') ->assertCookieExists('token') ->assertCookieExists('sid'); - $this->getHttp()->get(uri: '/auth/token', cookies: $result->getCookies()) - ->assertBodyNotSame('none'); + $this->fakeHttp()->get(uri: '/auth/token', cookies: $result->getCookies())->assertBodyNotSame('none'); } public function testLogout(): void { - $result = $this->getHttp()->get(uri: '/auth/login') + $result = $this->fakeHttp()->get(uri: '/auth/login') ->assertBodySame('OK') ->assertCookieExists('token') ->assertCookieExists('sid'); - $this->getHttp()->get(uri: '/auth/token', cookies: $result->getCookies()) - ->assertBodyNotSame('none'); - - $this->getHttp()->get(uri: '/auth/logout', cookies: $result->getCookies()) - ->assertBodySame('closed'); - - $this->getHttp()->get(uri: '/auth/token', cookies: $result->getCookies()) - ->assertBodySame('none'); + $this->fakeHttp()->get(uri: '/auth/token', cookies: $result->getCookies())->assertBodyNotSame('none'); + $this->fakeHttp()->get(uri: '/auth/token', cookies: $result->getCookies())->assertBodyNotSame('none'); + $this->fakeHttp()->get(uri: '/auth/logout', cookies: $result->getCookies())->assertBodySame('closed'); + $this->fakeHttp()->get(uri: '/auth/token', cookies: $result->getCookies())->assertBodySame('none'); } public function testLoginScope(): void { - $result = $this->getHttp()->get('/auth/login2') + $result = $this->fakeHttp()->get('/auth/login2') ->assertBodySame('OK') ->assertCookieExists('token') ->assertCookieExists('sid'); - $this->getHttp()->get('/auth/token2', cookies: $result->getCookies()) - ->assertBodyNotSame('none'); + $this->fakeHttp()->get('/auth/token2', cookies: $result->getCookies())->assertBodyNotSame('none'); } public function testLoginPayload(): void { - $result = $this->getHttp()->get('/auth/login2') + $result = $this->fakeHttp()->get('/auth/login2') ->assertBodySame('OK') ->assertCookieExists('token') ->assertCookieExists('sid'); - $this->getHttp()->get('/auth/token3', cookies: $result->getCookies()) - ->assertBodySame('{"userID":1}'); + $this->fakeHttp()->get('/auth/token3', cookies: $result->getCookies())->assertBodySame('{"userID":1}'); + } + + public function testInvalidSessionContextException(): void + { + $this->getContainer()->bind(HttpConfig::class, new HttpConfig([ + 'middleware' => [], + ])); + + $this->setHttpHandler(function (): void { + $this->expectException(ContextualObjectNotFoundException::class); + $this->getContainer()->get(AuthContextInterface::class); + }); + + $this->fakeHttp()->get('/'); + } + + public function testCookieQueueBindingWithoutRequest(): void + { + $this->expectException(InvalidRequestScopeException::class); + + $this->getContainer()->get(AuthContextInterface::class); } } diff --git a/tests/Framework/Http/ControllerTest.php b/tests/Framework/Http/ControllerTest.php index e3ba2d24c..a9d78c2fb 100644 --- a/tests/Framework/Http/ControllerTest.php +++ b/tests/Framework/Http/ControllerTest.php @@ -5,35 +5,34 @@ namespace Spiral\Tests\Framework\Http; use Nyholm\Psr7\Factory\Psr17Factory; +use Spiral\Framework\Spiral; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\HttpTestCase; +#[TestScope(Spiral::Http)] final class ControllerTest extends HttpTestCase { public function testIndexAction(): void { - $this->getHttp()->get('/index') - ->assertBodySame('Hello, Dave.'); - - $this->getHttp()->get('/index/Antony') - ->assertBodySame('Hello, Antony.'); + $this->fakeHttp()->get('/index')->assertBodySame('Hello, Dave.'); + $this->fakeHttp()->get('/index/Antony')->assertBodySame('Hello, Antony.'); } public function testRouteJson(): void { - $this->getHttp()->get('/route') - ->assertBodySame('{"action":"route","name":"Dave"}'); + $this->fakeHttp()->get('/route')->assertBodySame('{"action":"route","name":"Dave"}'); } public function test404(): void { - $this->getHttp()->get('/undefined')->assertNotFound(); + $this->fakeHttp()->get('/undefined')->assertNotFound(); } public function testPayloadAction(): void { $factory = new Psr17Factory(); - $this->getHttp()->post( + $this->fakeHttp()->post( uri: '/payload', data: $factory->createStream('{"a":"b"}'), headers: ['Content-Type' => 'application/json;charset=UTF-8;'] @@ -45,7 +44,7 @@ public function testPayloadWithCustomJsonHeader(): void { $factory = new Psr17Factory(); - $this->getHttp()->post( + $this->fakeHttp()->post( uri: '/payload', data: $factory->createStream('{"a":"b"}'), headers: ['Content-Type' => 'application/vnd.api+json;charset=UTF-8;'] @@ -57,7 +56,7 @@ public function testPayloadActionBad(): void { $factory = new Psr17Factory(); - $this->getHttp()->post( + $this->fakeHttp()->post( uri: '/payload', data: $factory->createStream('{"a":"b"'), headers: ['Content-Type' => 'application/json;charset=UTF-8;'] @@ -67,7 +66,6 @@ public function testPayloadActionBad(): void public function test500(): void { - $this->getHttp()->get('/error') - ->assertStatus(500); + $this->fakeHttp()->get('/error')->assertStatus(500); } } diff --git a/tests/Framework/Http/CookiesTest.php b/tests/Framework/Http/CookiesTest.php index 59b67a80e..ec56143e1 100644 --- a/tests/Framework/Http/CookiesTest.php +++ b/tests/Framework/Http/CookiesTest.php @@ -4,10 +4,15 @@ namespace Spiral\Tests\Framework\Http; +use Psr\Http\Message\ServerRequestInterface; use Spiral\Cookies\Cookie; use Spiral\Cookies\CookieManager; +use Spiral\Cookies\CookieQueue; +use Spiral\Core\ContainerScope; use Spiral\Core\Exception\ScopeException; use Spiral\Encrypter\EncrypterInterface; +use Spiral\Framework\Spiral; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\HttpTestCase; final class CookiesTest extends HttpTestCase @@ -35,50 +40,86 @@ public function testOutsideOfScopeFail(): void $this->cookies()->get('name'); } + #[TestScope([Spiral::Http, Spiral::HttpRequest])] + public function testCookieQueueInScope(): void + { + $this->setHttpHandler(static function (ServerRequestInterface $request) { + self::assertInstanceOf( + CookieQueue::class, + ContainerScope::getContainer()->get(ServerRequestInterface::class)->getAttribute(CookieQueue::ATTRIBUTE) + ); + + self::assertSame( + ContainerScope::getContainer() + ->get(ServerRequestInterface::class) + ->getAttribute(CookieQueue::ATTRIBUTE), + $request->getAttribute(CookieQueue::ATTRIBUTE) + ); + + self::assertSame( + ContainerScope::getContainer() + ->get(ServerRequestInterface::class) + ->getAttribute(CookieQueue::ATTRIBUTE), + ContainerScope::getContainer()->get(CookieQueue::class) + ); + }); + + $this->fakeHttp()->get('/')->assertOk(); + } + + #[TestScope([Spiral::Http, Spiral::HttpRequest])] public function testHasCookie(): void { - $this->setHttpHandler(function () { + $this->setHttpHandler(function (ServerRequestInterface $request) { return (int)$this->cookies()->has('a'); }); - $this->getHttp()->get('/') - ->assertOk() - ->assertBodySame('0'); + $this->fakeHttp()->get('/')->assertOk()->assertBodySame('0'); } + #[TestScope([Spiral::Http, Spiral::HttpRequest])] public function testHasCookie2(): void { - $this->setHttpHandler(fn(): int => (int)$this->cookies()->has('a')); + $this->setHttpHandler(function (ServerRequestInterface $request) { + return (int)$this->cookies()->has('a'); + }); - $this->getHttp()->get('/', cookies: [ - 'a' => $this->getContainer()->get(EncrypterInterface::class)->encrypt('hello'), - ]) + $this + ->fakeHttp() + ->get( + uri: '/', + cookies: ['a' => $this->getContainer()->get(EncrypterInterface::class)->encrypt('hello')] + ) ->assertOk() ->assertBodySame('1'); } + #[TestScope([Spiral::Http, Spiral::HttpRequest])] public function testGetCookie2(): void { - $this->setHttpHandler(fn(): string => $this->cookies()->get('a')); + $this->setHttpHandler(function (ServerRequestInterface $request) { + return $this->cookies()->get('a'); + }); - $this->getHttp()->get('/', cookies: [ - 'a' => $this->getContainer()->get(EncrypterInterface::class)->encrypt('hello'), - ]) + $this + ->fakeHttp() + ->get( + uri: '/', + cookies: ['a' => $this->getContainer()->get(EncrypterInterface::class)->encrypt('hello')] + ) ->assertOk() ->assertBodySame('hello'); } + #[TestScope([Spiral::Http, Spiral::HttpRequest])] public function testSetCookie(): void { - $this->setHttpHandler(function (): string { + $this->setHttpHandler(function (ServerRequestInterface $request) { $this->cookies()->set('a', 'value'); - return 'ok'; }); - $result = $this->getHttp()->get('/') - ->assertOk() - ->assertBodySame('ok'); + $result = $this->fakeHttp()->get('/')->assertOk()->assertBodySame('ok'); $cookies = $result->getCookies(); @@ -88,9 +129,10 @@ public function testSetCookie(): void ); } + #[TestScope([Spiral::Http, Spiral::HttpRequest])] public function testSetCookie2(): void { - $this->setHttpHandler(function (): string { + $this->setHttpHandler(function (ServerRequestInterface $request): string { $this->cookies()->schedule(Cookie::create('a', 'value')); $this->assertSame([], $this->cookies()->getAll()); $this->assertCount(1, $this->cookies()->getScheduled()); @@ -98,9 +140,7 @@ public function testSetCookie2(): void return 'ok'; }); - $result = $this->getHttp()->get('/') - ->assertOk() - ->assertBodySame('ok'); + $result = $this->fakeHttp()->get('/')->assertOk()->assertBodySame('ok'); $cookies = $result->getCookies(); @@ -110,18 +150,15 @@ public function testSetCookie2(): void ); } + #[TestScope([Spiral::Http, Spiral::HttpRequest])] public function testDeleteCookie(): void { - $this->setHttpHandler(function (): string { + $this->setHttpHandler(function (ServerRequestInterface $request): string { $this->cookies()->delete('cookie'); - return 'ok'; }); - $this->getHttp()->get('/') - ->assertOk() - ->assertBodySame('ok') - ->assertCookieSame('cookie', ''); + $this->fakeHttp()->get('/')->assertOk()->assertBodySame('ok')->assertCookieSame('cookie', ''); } private function cookies(): CookieManager diff --git a/tests/Framework/Http/CurrentRequestTest.php b/tests/Framework/Http/CurrentRequestTest.php new file mode 100644 index 000000000..49d760a9e --- /dev/null +++ b/tests/Framework/Http/CurrentRequestTest.php @@ -0,0 +1,106 @@ +setHttpHandler(function (ServerRequestInterface $request): void { + $this->assertSame($request, $this->getContainer()->get(CurrentRequest::class)->get()); + }); + + $this->fakeHttp()->get('/')->assertOk(); + } + + public function testAttributesAddedInMiddlewareAreAccessibleInHandler(): void + { + $middleware1 = $this->createMiddleware('first', 'first-value'); + $middleware2 = $this->createMiddleware('second', 'second-value'); + + $this->getContainer()->bind(HttpConfig::class, new HttpConfig([ + 'middleware' => [ + $middleware1::class, + $middleware2::class, + ], + 'basePath' => '/', + 'headers' => [] + ])); + + $this->setHttpHandler(function (ServerRequestInterface $request): void { + $this->assertSame($request, $this->getContainer()->get(CurrentRequest::class)->get()); + }); + + $this->fakeHttp()->get('/')->assertOk(); + } + + public function testAttributesAddedInMiddlewareAreAccessibleInNextMiddleware(): void + { + $middleware1 = $this->createMiddleware('first', 5); + $middleware2 = new class($this->getContainer()) implements MiddlewareInterface { + public function __construct( + private readonly ContainerInterface $container, + ) { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + $initial = $this->container->get(CurrentRequest::class)->get(); + + return $handler->handle($request->withAttribute('second', $initial->getAttribute('first') + 5)); + } + }; + $this->getContainer()->bind($middleware2::class, $middleware2); + + $this->getContainer()->bind(HttpConfig::class, new HttpConfig([ + 'middleware' => [ + $middleware1::class, + $middleware2::class, + ], + 'basePath' => '/', + 'headers' => [] + ])); + + $this->setHttpHandler(function (ServerRequestInterface $request): void { + $current = $this->getContainer()->get(CurrentRequest::class)->get(); + + $this->assertSame($request, $current); + $this->assertSame(5, $current->getAttribute('first')); + $this->assertSame(10, $current->getAttribute('second')); + }); + + $this->fakeHttp()->get('/')->assertOk(); + } + + private function createMiddleware(string $attribute, mixed $value): MiddlewareInterface + { + $middleware = new class($attribute, $value) implements MiddlewareInterface { + public function __construct( + private readonly string $attribute, + private readonly mixed $value, + ) { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + return $handler->handle($request->withAttribute($this->attribute, $this->value)); + } + }; + + $this->getContainer()->bind($middleware::class, $middleware); + + return $middleware; + } +} diff --git a/tests/Framework/Http/FilterTest.php b/tests/Framework/Http/FilterTest.php index f8130d3eb..0c287b768 100644 --- a/tests/Framework/Http/FilterTest.php +++ b/tests/Framework/Http/FilterTest.php @@ -4,32 +4,40 @@ namespace Spiral\Tests\Framework\Http; +use Spiral\Framework\Spiral; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\HttpTestCase; +#[TestScope(Spiral::Http)] final class FilterTest extends HttpTestCase { public function testValid(): void { - $this->getHttp() + $this + ->fakeHttp() ->post('/filter', data: ['name' => 'hello']) ->assertBodySame('{"name":"hello","sectionValue":null}'); } public function testDotNotation(): void { - $this->getHttp() + $this + ->fakeHttp() ->post('/filter', data: ['name' => 'hello', 'section' => ['value' => 'abc'],]) ->assertBodySame('{"name":"hello","sectionValue":"abc"}'); } public function testBadRequest(): void { - $this->getHttp()->get('/filter2')->assertStatus(500); + $this->fakeHttp() + ->get('/filter2') + ->assertStatus(500); } public function testInputTest(): void { - $this->getHttp() + $this + ->fakeHttp() ->get('/input', query: ['section' => ['value' => 'abc'],]) ->assertBodySame('value: abc'); } diff --git a/tests/Framework/Http/PaginationTest.php b/tests/Framework/Http/PaginationTest.php index e8172062e..23eb63143 100644 --- a/tests/Framework/Http/PaginationTest.php +++ b/tests/Framework/Http/PaginationTest.php @@ -5,16 +5,20 @@ namespace Spiral\Tests\Framework\Http; use Spiral\Core\Exception\ScopeException; +use Spiral\Framework\Spiral; use Spiral\Http\PaginationFactory; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\HttpTestCase; +#[TestScope(Spiral::Http)] final class PaginationTest extends HttpTestCase { public function testPaginate(): void { - $this->getHttp()->get('/paginate')->assertBodySame('1'); + $this->fakeHttp()->get('/paginate')->assertBodySame('1'); } + #[TestScope(Spiral::HttpRequest)] public function testPaginateError(): void { $this->expectException(ScopeException::class); @@ -24,6 +28,6 @@ public function testPaginateError(): void public function testPaginate2(): void { - $this->getHttp()->get('/paginate', query: ['page' => 2])->assertBodySame('2'); + $this->fakeHttp()->get('/paginate', query: ['page' => 2])->assertBodySame('2'); } } diff --git a/tests/Framework/Http/SessionTest.php b/tests/Framework/Http/SessionTest.php index 97cce4af2..595ed40d7 100644 --- a/tests/Framework/Http/SessionTest.php +++ b/tests/Framework/Http/SessionTest.php @@ -4,9 +4,15 @@ namespace Spiral\Tests\Framework\Http; +use Spiral\Bootloader\Http\Exception\ContextualObjectNotFoundException; +use Spiral\Bootloader\Http\Exception\InvalidRequestScopeException; +use Spiral\Framework\Spiral; +use Spiral\Http\Config\HttpConfig; use Spiral\Session\SessionInterface; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\HttpTestCase; +#[TestScope(Spiral::Http)] final class SessionTest extends HttpTestCase { public function setUp(): void @@ -14,56 +20,55 @@ public function setUp(): void parent::setUp(); $this->enableMiddlewares(); - - $this->setHttpHandler(function () { - return ++$this->session()->getSection('cli')->value; - }); } public function testSetSid(): void { - $this->getHttp()->get('/') - ->assertOk() - ->assertBodySame('1') - ->assertCookieExists('sid'); + $this->setHttpHandler(fn (): int => ++$this->session()->getSection('cli')->value); + + $this->fakeHttp()->get('/')->assertOk()->assertBodySame('1')->assertCookieExists('sid'); } public function testSessionResume(): void { - $result = $this->getHttp()->get('/') - ->assertOk() - ->assertBodySame('1') - ->assertCookieExists('sid'); + $this->setHttpHandler(fn (): int => ++$this->session()->getSection('cli')->value); - $this->getHttp()->get('/', cookies: [ - 'sid' => $result->getCookies()['sid'], - ])->assertOk()->assertBodySame('2'); + $result = $this->fakeHttp()->get('/')->assertOk()->assertBodySame('1')->assertCookieExists('sid'); - $this->getHttp()->get('/', cookies: [ - 'sid' => $result->getCookies()['sid'], - ])->assertOk()->assertBodySame('3'); + $this + ->fakeHttp() + ->get(uri: '/', cookies: ['sid' => $result->getCookies()['sid']]) + ->assertOk() + ->assertBodySame('2'); + + $this + ->fakeHttp() + ->get(uri: '/', cookies: ['sid' => $result->getCookies()['sid']]) + ->assertOk() + ->assertBodySame('3'); } public function testSessionRegenerateId(): void { - $result = $this->getHttp()->get('/') - ->assertOk() - ->assertBodySame('1') - ->assertCookieExists('sid'); + $this->setHttpHandler(fn (): int => ++$this->session()->getSection('cli')->value); - $this->getHttp()->get('/', cookies: [ - 'sid' => $result->getCookies()['sid'], - ])->assertOk()->assertBodySame('2'); + $result = $this->fakeHttp()->get('/')->assertOk()->assertBodySame('1')->assertCookieExists('sid'); - $this->setHttpHandler(function () { + $this + ->fakeHttp() + ->get(uri: '/', cookies: ['sid' => $result->getCookies()['sid']]) + ->assertOk() + ->assertBodySame('2'); + + $this->setHttpHandler(function (): int { $this->session()->regenerateID(false); return ++$this->session()->getSection('cli')->value; }); - $newResult = $this->getHttp()->get('/', cookies: [ - 'sid' => $result->getCookies()['sid'], - ]) + $newResult = $this + ->fakeHttp() + ->get(uri: '/', cookies: ['sid' => $result->getCookies()['sid']]) ->assertOk() ->assertBodySame('3') ->assertCookieExists('sid'); @@ -73,14 +78,18 @@ public function testSessionRegenerateId(): void public function testDestroySession(): void { - $result = $this->getHttp()->get('/') - ->assertOk() - ->assertBodySame('1') - ->assertCookieExists('sid'); + $this->setHttpHandler(fn (): int => ++$this->session()->getSection('cli')->value); - $this->getHttp()->get('/', cookies: [ - 'sid' => $result->getCookies()['sid'], - ])->assertOk()->assertBodySame('2'); + $result = $this->fakeHttp()->get('/')->assertOk()->assertBodySame('1')->assertCookieExists('sid'); + + $this + ->fakeHttp() + ->get( + uri: '/', + cookies: ['sid' => $result->getCookies()['sid']] + ) + ->assertOk() + ->assertBodySame('2'); $this->setHttpHandler(function () { $this->session()->destroy(); @@ -89,9 +98,33 @@ public function testDestroySession(): void return ++$this->session()->getSection('cli')->value; }); - $this->getHttp()->get('/', cookies: [ - 'sid' => $result->getCookies()['sid'], - ])->assertOk()->assertBodySame('1'); + $this + ->fakeHttp() + ->get(uri: '/', cookies: ['sid' => $result->getCookies()['sid']]) + ->assertOk() + ->assertBodySame('1'); + } + + public function testInvalidSessionContextException(): void + { + $this->getContainer()->bind(HttpConfig::class, new HttpConfig([ + 'middleware' => [], + ])); + + $this->setHttpHandler(function (): void { + $this->expectException(ContextualObjectNotFoundException::class); + + $this->session(); + }); + + $this->fakeHttp()->get(uri: '/')->assertOk(); + } + + public function testSessionBindingWithoutRequest(): void + { + $this->expectException(InvalidRequestScopeException::class); + + $this->session(); } private function session(): SessionInterface diff --git a/tests/Framework/HttpTestCase.php b/tests/Framework/HttpTestCase.php index e038e98fd..9381f2e5f 100644 --- a/tests/Framework/HttpTestCase.php +++ b/tests/Framework/HttpTestCase.php @@ -8,28 +8,16 @@ use Spiral\Cookies\Middleware\CookiesMiddleware; use Spiral\Csrf\Middleware\CsrfMiddleware; use Spiral\Http\Config\HttpConfig; +use Spiral\Http\Http; use Spiral\Session\Middleware\SessionMiddleware; -use Spiral\Testing\Http\FakeHttp; abstract class HttpTestCase extends BaseTestCase { public const ENCRYPTER_KEY = 'def00000b325585e24ff3bd2d2cd273aa1d2274cb6851a9f2c514c2e2a83806f2661937f8b9cbe217e37943f5f9ccb6b5f91151606774869883e5557a941dfd879cbf5be'; - private FakeHttp $http; - public function setUp(): void + public function setHttpHandler(\Closure $handler): void { - parent::setUp(); - $this->http = $this->fakeHttp(); - } - - public function getHttp(): FakeHttp - { - return $this->http; - } - - public function setHttpHandler(\Closure $handler) - { - $this->http->getHttp()->setHandler($handler); + $this->getContainer()->get(Http::class)->setHandler($handler); } protected function enableMiddlewares(): void diff --git a/tests/Framework/Interceptor/PipelineInterceptorTest.php b/tests/Framework/Interceptor/PipelineInterceptorTest.php index 8a95e15ea..68eadd3ae 100644 --- a/tests/Framework/Interceptor/PipelineInterceptorTest.php +++ b/tests/Framework/Interceptor/PipelineInterceptorTest.php @@ -4,47 +4,44 @@ namespace Spiral\Tests\Framework\Interceptor; +use Spiral\Framework\Spiral; +use Spiral\Testing\Attribute\TestScope; use Spiral\Tests\Framework\HttpTestCase; +#[TestScope(Spiral::Http)] final class PipelineInterceptorTest extends HttpTestCase { public function testWithoutPipeline(): void { - $this->getHttp()->get('/intercepted/without') - ->assertBodySame('["without","three","two","one"]'); + $this->fakeHttp()->get('/intercepted/without')->assertBodySame('["without","three","two","one"]'); } public function testWith(): void { - $this->getHttp()->get('/intercepted/with') - ->assertBodySame('["with","three","two","one"]'); + $this->fakeHttp()->get('/intercepted/with')->assertBodySame('["with","three","two","one"]'); } public function testMix(): void { //pipeline interceptors are injected into the middle - $this->getHttp()->get('/intercepted/mix') - ->assertBodySame('["mix","six","three","two","one","five","four"]'); + $this->fakeHttp()->get('/intercepted/mix')->assertBodySame('["mix","six","three","two","one","five","four"]'); } public function testDup(): void { //pipeline interceptors are added to the end - $this->getHttp()->get('/intercepted/dup') - ->assertBodySame('["dup","three","two","one","three","two","one"]'); + $this->fakeHttp()->get('/intercepted/dup')->assertBodySame('["dup","three","two","one","three","two","one"]'); } public function testSkipNext(): void { //interceptors after current pipeline are ignored - $this->getHttp()->get('/intercepted/skip') - ->assertBodySame('["skip","three","two","one","one"]'); + $this->fakeHttp()->get('/intercepted/skip')->assertBodySame('["skip","three","two","one","one"]'); } public function testSkipIfFirst(): void { //interceptors after current pipeline are ignored - $this->getHttp()->get('/intercepted/first') - ->assertBodySame('["first","three","two","one"]'); + $this->fakeHttp()->get('/intercepted/first')->assertBodySame('["first","three","two","one"]'); } } diff --git a/tests/Framework/Queue/QueueRegistryTest.php b/tests/Framework/Queue/QueueRegistryTest.php new file mode 100644 index 000000000..f01ddd0b5 --- /dev/null +++ b/tests/Framework/Queue/QueueRegistryTest.php @@ -0,0 +1,30 @@ + Task::class])] + #[DoesNotPerformAssertions] + public function testHandleJobWithDependencyInScope(): void + { + /** @var Handler $handler */ + $handler = $this->getContainer()->get(Handler::class); + + /** + * Method invoke in SampleJob requires TaskInterface and it's available only in queue scope. + */ + $handler->handle(SampleJob::class, '', '', '', ''); + } +} diff --git a/tests/Framework/Router/CoreHandlerTest.php b/tests/Framework/Router/CoreHandlerTest.php new file mode 100644 index 000000000..385f3f933 --- /dev/null +++ b/tests/Framework/Router/CoreHandlerTest.php @@ -0,0 +1,19 @@ +fakeHttp()->get('/scope/construct')->assertBodySame(Spiral::HttpRequest->value); + $this->fakeHttp()->get('/scope/method')->assertBodySame(Spiral::HttpRequest->value); + } +} diff --git a/tests/app/src/Bootloader/AppBootloader.php b/tests/app/src/Bootloader/AppBootloader.php index 09508fde4..069ce0dbb 100644 --- a/tests/app/src/Bootloader/AppBootloader.php +++ b/tests/app/src/Bootloader/AppBootloader.php @@ -9,13 +9,15 @@ use Spiral\Bootloader\Http\JsonPayloadsBootloader; use Spiral\Core\CoreInterface; use Spiral\Domain\GuardInterceptor; +use Spiral\Interceptors\HandlerInterface; use Spiral\Security\PermissionsInterface; use Spiral\Views\Bootloader\ViewsBootloader; class AppBootloader extends DomainBootloader { protected const SINGLETONS = [ - CoreInterface::class => [self::class, 'domainCore'] + HandlerInterface::class => [self::class, 'domainCore'], + CoreInterface::class => [self::class, 'domainCore'], ]; protected const INTERCEPTORS = [ diff --git a/tests/app/src/Bootloader/RoutesBootloader.php b/tests/app/src/Bootloader/RoutesBootloader.php index 0cdb43c31..7f662ab49 100644 --- a/tests/app/src/Bootloader/RoutesBootloader.php +++ b/tests/app/src/Bootloader/RoutesBootloader.php @@ -8,13 +8,14 @@ use Psr\Http\Server\MiddlewareInterface; use Spiral\App\Controller\AuthController; use Spiral\App\Controller\InterceptedController; +use Spiral\App\Controller\ScopeController; use Spiral\App\Controller\TestController; use Spiral\App\Interceptor; use Spiral\Auth\Middleware\AuthMiddleware; use Spiral\Bootloader\Http\RoutesBootloader as BaseRoutesBootloader; use Spiral\Cookies\Middleware\CookiesMiddleware; use Spiral\Core\Core; -use Spiral\Core\InterceptableCore; +use Spiral\Core\InterceptorPipeline; use Spiral\Csrf\Middleware\CsrfMiddleware; use Spiral\Debug\StateCollector\HttpCollector; use Spiral\Domain\PipelineInterceptor; @@ -59,6 +60,7 @@ protected function middlewareGroups(): array protected function defineRoutes(RoutingConfigurator $routes): void { $routes->add('auth', '/auth/')->controller(AuthController::class); + $routes->add('scope', '/scope/')->controller(ScopeController::class); $routes ->add('intercepted:without', '/intercepted/without') ->action(InterceptedController::class, 'without') @@ -178,14 +180,14 @@ protected function defineRoutes(RoutingConfigurator $routes): void ->middleware($this->middlewareGroups()['web']); } - private function getInterceptedCore(array $interceptors): InterceptableCore + private function getInterceptedCore(array $interceptors): InterceptorPipeline { - $core = new InterceptableCore($this->core); + $pipeline = (new InterceptorPipeline())->withCore($this->core); foreach ($interceptors as $interceptor) { - $core->addInterceptor(\is_object($interceptor) ? $interceptor : $this->container->get($interceptor)); + $pipeline->addInterceptor(\is_object($interceptor) ? $interceptor : $this->container->get($interceptor)); } - return $core; + return $pipeline; } } diff --git a/tests/app/src/Command/ApplicationInProductionCommand.php b/tests/app/src/Command/ApplicationInProductionCommand.php new file mode 100644 index 000000000..d6bd39b55 --- /dev/null +++ b/tests/app/src/Command/ApplicationInProductionCommand.php @@ -0,0 +1,25 @@ +confirmToProceed('Application in production.')) { + $this->writeln('Application is in production.'); + } + + $this->writeln('Application is in testing.'); + + return self::SUCCESS; + } +} diff --git a/tests/app/src/Controller/ScopeController.php b/tests/app/src/Controller/ScopeController.php new file mode 100644 index 000000000..575841301 --- /dev/null +++ b/tests/app/src/Controller/ScopeController.php @@ -0,0 +1,32 @@ +getScopeName($this->container); + } + + public function method(ContainerInterface $container): string + { + return $this->getScopeName($container); + } + + private function getScopeName(ContainerInterface $container): string + { + $scope = (new \ReflectionProperty($container, 'scope'))->getValue($container); + + return (new \ReflectionProperty($scope, 'scopeName'))->getValue($scope); + } +} diff --git a/tests/app/src/Controller/TestController.php b/tests/app/src/Controller/TestController.php index 3e2afb5bb..27af8e6dd 100644 --- a/tests/app/src/Controller/TestController.php +++ b/tests/app/src/Controller/TestController.php @@ -8,7 +8,7 @@ use Spiral\App\Request\BadRequest; use Spiral\App\Request\TestRequest; use Spiral\Filter\InputScope; -use Spiral\Http\PaginationFactory; +use Spiral\Pagination\PaginationProviderInterface; use Spiral\Pagination\Paginator; use Spiral\Router\RouteInterface; use Spiral\Translator\Traits\TranslatorTrait; @@ -22,7 +22,7 @@ public function index(string $name = 'Dave') return "Hello, {$name}."; } - public function paginate(PaginationFactory $paginationFactory) + public function paginate(PaginationProviderInterface $paginationFactory): int { /** @var Paginator $p */ $p = $paginationFactory->createPaginator('page'); diff --git a/tests/app/src/Job/SampleJob.php b/tests/app/src/Job/SampleJob.php new file mode 100644 index 000000000..956f09b04 --- /dev/null +++ b/tests/app/src/Job/SampleJob.php @@ -0,0 +1,14 @@ +container; - } - - public function getRegisteredDispatchers(): array - { - return $this->dispatchers; - } - - public function getRegisteredBootloaders(): array - { - return $this->bootloader->getClasses(); - } - /** * @param class-string<\Spiral\Boot\Bootloader\Bootloader> ...$bootloader */