diff --git a/src/Cookies/src/CookieQueue.php b/src/Cookies/src/CookieQueue.php index c6bfdaea5..85141f8d1 100644 --- a/src/Cookies/src/CookieQueue.php +++ b/src/Cookies/src/CookieQueue.php @@ -4,9 +4,14 @@ namespace Spiral\Cookies; +use Spiral\Cookies\Middleware\CookiesMiddleware; use Spiral\Core\Attribute\Scope; -#[Scope('http')] +/** + * @note The CookieQueue might be accessed in the http scope after the {@see CookiesMiddleware} has been executed, + * but don't store this class in stateful services, which are not isolated in the http-request scope. + */ +#[Scope('http-request')] final class CookieQueue { public const ATTRIBUTE = 'cookieQueue'; diff --git a/src/Core/src/Config/Proxy.php b/src/Core/src/Config/Proxy.php index 940b1f217..da90884ac 100644 --- a/src/Core/src/Config/Proxy.php +++ b/src/Core/src/Config/Proxy.php @@ -4,18 +4,27 @@ namespace Spiral\Core\Config; +use Psr\Container\ContainerInterface; + class Proxy extends Binding { /** - * @param class-string $interface + * @template T + * @param class-string $interface + * @param null|\Closure(ContainerInterface, \Stringable|string|null): T $fallbackFactory Factory that will be used + * to create an instance if the value is resolved from a proxy. */ public function __construct( protected readonly string $interface, public readonly bool $singleton = false, + public readonly ?\Closure $fallbackFactory = null, ) { - if (!\interface_exists($interface)) { - throw new \InvalidArgumentException(\sprintf('Interface `%s` does not exist.', $interface)); - } + \interface_exists($interface) or throw new \InvalidArgumentException( + "Interface `{$interface}` does not exist.", + ); + $this->singleton and $this->fallbackFactory !== null and throw new \InvalidArgumentException( + 'Singleton proxies must not have a fallback factory.', + ); } public function __toString(): string diff --git a/src/Core/src/Exception/Container/RecursiveProxyException.php b/src/Core/src/Exception/Container/RecursiveProxyException.php index 821975fca..3b813f575 100644 --- a/src/Core/src/Exception/Container/RecursiveProxyException.php +++ b/src/Core/src/Exception/Container/RecursiveProxyException.php @@ -10,4 +10,14 @@ */ class RecursiveProxyException extends ContainerException { + public function __construct( + public readonly string $alias, + public readonly ?string $bindingScope = null, + public readonly ?array $callingScope = null, + ) { + $message = "Recursive proxy detected for `$alias`."; + $bindingScope === null or $message .= "\nBinding scope: `$bindingScope`."; + $callingScope === null or $message .= "\nCalling scope: `" . \implode('.', $callingScope) . '`.'; + parent::__construct($message); + } } diff --git a/src/Core/src/Exception/Shared/InvalidContainerScopeException.php b/src/Core/src/Exception/Shared/InvalidContainerScopeException.php index 48952d3e7..464db497e 100644 --- a/src/Core/src/Exception/Shared/InvalidContainerScopeException.php +++ b/src/Core/src/Exception/Shared/InvalidContainerScopeException.php @@ -18,6 +18,7 @@ public function __construct( protected readonly string $id, Container|string|null $scopeOrContainer = null, protected readonly ?string $requiredScope = null, + \Throwable|null $previous = null, ) { $this->scope = \is_string($scopeOrContainer) ? $scopeOrContainer @@ -25,6 +26,6 @@ public function __construct( $req = $this->requiredScope !== null ? ", `$this->requiredScope` is required" : ''; - parent::__construct("Unable to resolve `$id` in the `$this->scope` scope{$req}."); + parent::__construct("Unable to resolve `$id` in the `$this->scope` scope{$req}.", previous: $previous); } } diff --git a/src/Core/src/Internal/Factory.php b/src/Core/src/Internal/Factory.php index eac3f5fe3..c5e05f06d 100644 --- a/src/Core/src/Internal/Factory.php +++ b/src/Core/src/Internal/Factory.php @@ -18,6 +18,7 @@ use Spiral\Core\Exception\Container\InjectionException; use Spiral\Core\Exception\Container\NotCallableException; use Spiral\Core\Exception\Container\NotFoundException; +use Spiral\Core\Exception\Container\RecursiveProxyException; use Spiral\Core\Exception\Resolver\ValidationException; use Spiral\Core\Exception\Resolver\WrongTypeException; use Spiral\Core\Exception\Scope\BadScopeException; @@ -25,6 +26,7 @@ use Spiral\Core\Internal\Common\DestructorTrait; use Spiral\Core\Internal\Common\Registry; use Spiral\Core\Internal\Factory\Ctx; +use Spiral\Core\Internal\Proxy\RetryContext; use Spiral\Core\InvokerInterface; use Spiral\Core\Options; use Spiral\Core\ResolverInterface; @@ -198,6 +200,15 @@ private function resolveAlias( private function resolveProxy(Config\Proxy $binding, string $alias, Stringable|string|null $context): mixed { + if ($context instanceof RetryContext) { + return $binding->fallbackFactory === null + ? throw new RecursiveProxyException( + $alias, + $this->scope->getScopeName(), + ) + : ($binding->fallbackFactory)($this->container, $context->context); + } + $result = Proxy::create(new \ReflectionClass($binding->getInterface()), $context, new Attribute\Proxy()); if ($binding->singleton) { @@ -316,6 +327,7 @@ private function resolveWithoutBinding( } catch (ContainerExceptionInterface $e) { $className = match (true) { $e instanceof NotFoundException => NotFoundException::class, + $e instanceof RecursiveProxyException => throw $e, default => ContainerException::class, }; throw new $className($this->tracer->combineTraceMessage(\sprintf( diff --git a/src/Core/src/Internal/Proxy/Resolver.php b/src/Core/src/Internal/Proxy/Resolver.php index 2e0699594..98af51d18 100644 --- a/src/Core/src/Internal/Proxy/Resolver.php +++ b/src/Core/src/Internal/Proxy/Resolver.php @@ -34,27 +34,36 @@ public static function resolve( throw new ContainerException( $scope === null ? "Unable to resolve `{$alias}` in a Proxy." - : "Unable to resolve `{$alias}` in a Proxy in `{$scope}` scope.", + : \sprintf('Unable to resolve `%s` in a Proxy in `%s` scope.', $alias, \implode('.', $scope)), previous: $e, ); } - if (Proxy::isProxy($result)) { - $scope = self::getScope($c); - throw new RecursiveProxyException( - $scope === null - ? "Recursive proxy detected for `{$alias}`." - : "Recursive proxy detected for `{$alias}` in `{$scope}` scope.", - ); + if (!Proxy::isProxy($result)) { + return $result; + } + + /** + * If we got a Proxy again, that we should retry with the new context + * to try to get the instance from the Proxy Fallback Factory. + * If there is no the Proxy Fallback Factory, {@see RecursiveProxyException} will be thrown. + */ + try { + $result = $c->get($alias, new RetryContext($context)); + } catch (RecursiveProxyException $e) { + throw new RecursiveProxyException($e->alias, $e->bindingScope, self::getScope($c)); } - return $result; + // If Container returned a Proxy after the retry, then we have a recursion. + return Proxy::isProxy($result) + ? throw new RecursiveProxyException($alias, null, self::getScope($c)) + : $result; } /** - * @return non-empty-string|null + * @return list|null */ - private static function getScope(ContainerInterface $c): ?string + private static function getScope(ContainerInterface $c): ?array { if (!$c instanceof Container) { if (!Proxy::isProxy($c)) { @@ -64,9 +73,9 @@ private static function getScope(ContainerInterface $c): ?string $c = null; } - return \implode('.', \array_reverse(\array_map( + return \array_reverse(\array_map( static fn (?string $name): string => $name ?? 'null', Introspector::scopeNames($c), - ))); + )); } } diff --git a/src/Core/src/Internal/Proxy/RetryContext.php b/src/Core/src/Internal/Proxy/RetryContext.php new file mode 100644 index 000000000..d99706c64 --- /dev/null +++ b/src/Core/src/Internal/Proxy/RetryContext.php @@ -0,0 +1,26 @@ +context; + } +} diff --git a/src/Core/tests/Scope/ProxyTest.php b/src/Core/tests/Scope/ProxyTest.php index fd2ddb43b..7c94f1c2d 100644 --- a/src/Core/tests/Scope/ProxyTest.php +++ b/src/Core/tests/Scope/ProxyTest.php @@ -7,6 +7,7 @@ use Psr\Container\ContainerInterface; use ReflectionParameter; use Spiral\Core\Attribute\Proxy; +use Spiral\Core\Config\Proxy as ProxyConfig; use Spiral\Core\Container; use Spiral\Core\Container\InjectorInterface; use Spiral\Core\ContainerScope; @@ -301,14 +302,40 @@ public function __toString(): string /** * Proxy gets a proxy of the same type. */ - public function testRecursiveProxy(): void + public function testRecursiveProxyNotSingleton(): void { $root = new Container(); - $root->bind(UserInterface::class, new \Spiral\Core\Config\Proxy(UserInterface::class)); + $root->bind(UserInterface::class, new ProxyConfig(UserInterface::class)); $this->expectException(RecursiveProxyException::class); $this->expectExceptionMessage( - 'Recursive proxy detected for `Spiral\Tests\Core\Scope\Stub\UserInterface` in `root.null` scope.', + <<runScope( + new Scope(), + fn(#[Proxy] UserInterface $user) => $user->getName(), + ); + } + + /** + * Proxy gets a proxy of the same type as a singleton. + */ + public function testRecursiveProxySingleton(): void + { + $root = new Container(); + $root->bind(UserInterface::class, new ProxyConfig(UserInterface::class, singleton: true)); + + $this->expectException(RecursiveProxyException::class); + $this->expectExceptionMessage( + <<runScope( @@ -336,6 +363,22 @@ static function (#[Proxy] ContainerInterface $proxy, ContainerInterface $scoped) ); } + public function testProxyFallbackFactory() + { + $root = new Container(); + $root->bind(UserInterface::class, new ProxyConfig( + interface: UserInterface::class, + fallbackFactory: static fn(): UserInterface => new User('Foo'), + )); + + $name = $root->runScope( + new Scope(), + fn(#[Proxy] UserInterface $user) => $user->getName(), + ); + + self::assertSame('Foo', $name); + } + /* // Proxy::$attachContainer=true tests diff --git a/src/Framework/Bootloader/Http/CookiesBootloader.php b/src/Framework/Bootloader/Http/CookiesBootloader.php index 0d3e1998d..f4f7d2b53 100644 --- a/src/Framework/Bootloader/Http/CookiesBootloader.php +++ b/src/Framework/Bootloader/Http/CookiesBootloader.php @@ -52,15 +52,15 @@ public function whitelistCookie(string $cookie): void $this->config->modify(CookiesConfig::CONFIG, new Append('excluded', null, $cookie)); } - private function cookieQueue(?ServerRequestInterface $request): CookieQueue + private function cookieQueue(ServerRequestInterface $request): CookieQueue { - if ($request === null) { - throw new InvalidRequestScopeException(CookieQueue::class); + try { + return $request->getAttribute(CookieQueue::ATTRIBUTE) ?? throw new ContextualObjectNotFoundException( + CookieQueue::class, + CookieQueue::ATTRIBUTE, + ); + } catch (InvalidRequestScopeException $e) { + throw new InvalidRequestScopeException(CookieQueue::class, previous: $e); } - - return $request->getAttribute(CookieQueue::ATTRIBUTE) ?? throw new ContextualObjectNotFoundException( - CookieQueue::class, - CookieQueue::ATTRIBUTE, - ); } } diff --git a/src/Framework/Bootloader/Http/Exception/InvalidRequestScopeException.php b/src/Framework/Bootloader/Http/Exception/InvalidRequestScopeException.php index 72adff19d..2f95ed2df 100644 --- a/src/Framework/Bootloader/Http/Exception/InvalidRequestScopeException.php +++ b/src/Framework/Bootloader/Http/Exception/InvalidRequestScopeException.php @@ -20,7 +20,8 @@ final class InvalidRequestScopeException extends InvalidContainerScopeException public function __construct( string $id, string|Container|null $scopeOrContainer = null, + \Throwable|null $previous = null, ) { - parent::__construct($id, $scopeOrContainer, Spiral::Http->value); + parent::__construct($id, $scopeOrContainer, Spiral::HttpRequest->value, $previous); } } diff --git a/src/Framework/Bootloader/Http/HttpBootloader.php b/src/Framework/Bootloader/Http/HttpBootloader.php index 4ce96cae1..903d9e55a 100644 --- a/src/Framework/Bootloader/Http/HttpBootloader.php +++ b/src/Framework/Bootloader/Http/HttpBootloader.php @@ -6,21 +6,22 @@ use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseFactoryInterface; -use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Message\ServerRequestInterface as RequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Spiral\Boot\Bootloader\Bootloader; +use Spiral\Bootloader\Http\Exception\InvalidRequestScopeException; 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; 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; @@ -52,10 +53,16 @@ public function defineSingletons(): array $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.', - ) + RequestInterface::class, + new \Spiral\Core\Config\Proxy( + interface: RequestInterface::class, + fallbackFactory: static fn (ContainerInterface $c): RequestInterface => $c + ->get(CurrentRequest::class) + ->get() ?? throw new InvalidRequestScopeException( + RequestInterface::class, + $c instanceof Container ? $c : null, + ), + ), ); /** diff --git a/src/Framework/Bootloader/Http/SessionBootloader.php b/src/Framework/Bootloader/Http/SessionBootloader.php index 4939d9ffe..ac693c7e2 100644 --- a/src/Framework/Bootloader/Http/SessionBootloader.php +++ b/src/Framework/Bootloader/Http/SessionBootloader.php @@ -4,6 +4,7 @@ namespace Spiral\Bootloader\Http; +use Psr\Container\ContainerInterface; use Psr\Http\Message\ServerRequestInterface; use Spiral\Boot\Bootloader\Bootloader; use Spiral\Boot\DirectoriesInterface; @@ -30,18 +31,13 @@ public function __construct( public function defineBindings(): array { - $this->binder - ->getBinder(Spiral::Http) + $this->binder->getBinder(Spiral::HttpRequest)->bind(SessionInterface::class, $this->resolveSession(...)); + $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, - ) + new Proxy(SessionInterface::class, false, $this->resolveSession(...)), ); - $this->binder->bind(SessionInterface::class, new Proxy(SessionInterface::class, false)); + $this->binder->bind(SessionInterface::class, new Proxy(SessionInterface::class, true), ); return []; } @@ -91,4 +87,18 @@ public function boot( $cookies->whitelistCookie($session['cookie']); } + + private function resolveSession(ContainerInterface $container): SessionInterface + { + try { + /** @var ServerRequestInterface $request */ + $request = $container->get(ServerRequestInterface::class); + return $request->getAttribute(SessionMiddleware::ATTRIBUTE) ?? throw new ContextualObjectNotFoundException( + SessionInterface::class, + SessionMiddleware::ATTRIBUTE, + ); + } catch (InvalidRequestScopeException $e) { + throw new InvalidRequestScopeException(SessionInterface::class, previous: $e); + } + } } diff --git a/tests/Framework/Http/SessionTest.php b/tests/Framework/Http/SessionTest.php index 595ed40d7..ca60319d7 100644 --- a/tests/Framework/Http/SessionTest.php +++ b/tests/Framework/Http/SessionTest.php @@ -112,9 +112,11 @@ public function testInvalidSessionContextException(): void ])); $this->setHttpHandler(function (): void { + $session = $this->session(); + $this->expectException(ContextualObjectNotFoundException::class); - $this->session(); + $session->getID(); }); $this->fakeHttp()->get(uri: '/')->assertOk(); @@ -122,9 +124,12 @@ public function testInvalidSessionContextException(): void public function testSessionBindingWithoutRequest(): void { + // Get a Proxy + $session = $this->session(); + $this->expectException(InvalidRequestScopeException::class); - $this->session(); + $session->getID(); } private function session(): SessionInterface