Skip to content

Commit

Permalink
Merge pull request #1160 from spiral/proxy-fallback-factory
Browse files Browse the repository at this point in the history
Fix ServerRequestInterface resolving in HTTP scope
  • Loading branch information
roxblnfk authored Oct 22, 2024
2 parents 80bd39f + b6fe429 commit eadb500
Show file tree
Hide file tree
Showing 13 changed files with 186 additions and 48 deletions.
7 changes: 6 additions & 1 deletion src/Cookies/src/CookieQueue.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
17 changes: 13 additions & 4 deletions src/Core/src/Config/Proxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> $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
Expand Down
10 changes: 10 additions & 0 deletions src/Core/src/Exception/Container/RecursiveProxyException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ 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
: Introspector::scopeName($scopeOrContainer);

$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);
}
}
12 changes: 12 additions & 0 deletions src/Core/src/Internal/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
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;
use Spiral\Core\FactoryInterface;
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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
35 changes: 22 additions & 13 deletions src/Core/src/Internal/Proxy/Resolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<non-empty-string|null>|null
*/
private static function getScope(ContainerInterface $c): ?string
private static function getScope(ContainerInterface $c): ?array
{
if (!$c instanceof Container) {
if (!Proxy::isProxy($c)) {
Expand All @@ -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),
)));
));
}
}
26 changes: 26 additions & 0 deletions src/Core/src/Internal/Proxy/RetryContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Spiral\Core\Internal\Proxy;

/**
* Used to wrap the resolving context to force Proxy Fallback Factory.
*
* @internal
*/
final class RetryContext implements \Stringable
{
/**
* @param \Stringable|string|null $context Original context.
*/
public function __construct(
public \Stringable|string|null $context = null,
) {
}

public function __toString(): string
{
return (string) $this->context;
}
}
49 changes: 46 additions & 3 deletions src/Core/tests/Scope/ProxyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.',
<<<MSG
Recursive proxy detected for `Spiral\Tests\Core\Scope\Stub\UserInterface`.
Binding scope: `root`.
Calling scope: `root.null`.
MSG,
);

$root->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(
<<<MSG
Recursive proxy detected for `Spiral\Tests\Core\Scope\Stub\UserInterface`.
Calling scope: `root.null`.
MSG,
);

$root->runScope(
Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions src/Framework/Bootloader/Http/CookiesBootloader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
19 changes: 13 additions & 6 deletions src/Framework/Bootloader/Http/HttpBootloader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
),
),
);

/**
Expand Down
Loading

0 comments on commit eadb500

Please sign in to comment.