From c1626a89686b8621668a532c03b840eeb4121b0f Mon Sep 17 00:00:00 2001 From: Ollie Date: Mon, 9 Sep 2024 22:39:21 +0100 Subject: [PATCH 1/7] feat(resolvers): Add the cookie identity resolver --- resources/config/multitenancy.php | 6 +- src/Http/Resolvers/CookieIdentityResolver.php | 160 ++++++++++++++++++ src/Managers/IdentityResolverManager.php | 20 +++ tests/Resolvers/CookieResolverTest.php | 75 ++++++++ 4 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 src/Http/Resolvers/CookieIdentityResolver.php create mode 100644 tests/Resolvers/CookieResolverTest.php diff --git a/resources/config/multitenancy.php b/resources/config/multitenancy.php index 7828830..63cc94e 100644 --- a/resources/config/multitenancy.php +++ b/resources/config/multitenancy.php @@ -47,7 +47,11 @@ ], 'header' => [ - 'driver' => 'header', + 'driver' => 'header', + ], + + 'cookie' => [ + 'driver' => 'cookie', ], ], diff --git a/src/Http/Resolvers/CookieIdentityResolver.php b/src/Http/Resolvers/CookieIdentityResolver.php new file mode 100644 index 0000000..6d85f78 --- /dev/null +++ b/src/Http/Resolvers/CookieIdentityResolver.php @@ -0,0 +1,160 @@ + + */ + private array $options; + + /** + * @param string $name + * @param string|null $cookie + * @param array $options + */ + public function __construct(string $name, ?string $cookie = null, array $options = []) + { + parent::__construct($name); + + $this->cookie = $cookie ?? '{Tenancy}-Identifier'; + $this->options = $options; + } + + public function getCookie(): string + { + return $this->cookie; + } + + /** + * @param \Sprout\Contracts\Tenancy<\Sprout\Contracts\Tenant> $tenancy + * + * @return string + */ + public function getRequestCookieName(Tenancy $tenancy): string + { + return str_replace( + ['{tenancy}', '{resolver}', '{Tenancy}', '{Resolver}'], + [$tenancy->getName(), $this->getName(), ucfirst($tenancy->getName()), ucfirst($this->getName())], + $this->getCookie() + ); + } + + /** + * Get an identifier from the request + * + * Locates a tenant identifier within the provided request and returns it. + * + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Illuminate\Http\Request $request + * @param \Sprout\Contracts\Tenancy $tenancy + * + * @return string|null + */ + public function resolveFromRequest(Request $request, Tenancy $tenancy): ?string + { + /** + * This is unfortunately here because of the ludicrous return type + * + * @var string|null $cookie + */ + $cookie = $request->cookie($this->getRequestCookieName($tenancy)); + + return $cookie; + } + + /** + * Create a route group for the resolver + * + * Creates and configures a route group with the necessary settings to + * support identity resolution. + * + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Illuminate\Routing\Router $router + * @param \Closure $groupRoutes + * @param \Sprout\Contracts\Tenancy $tenancy + * + * @return \Illuminate\Routing\RouteRegistrar + */ + public function routes(Router $router, Closure $groupRoutes, Tenancy $tenancy): RouteRegistrar + { + return $router->middleware([TenantRoutes::ALIAS . ':' . $this->getName() . ',' . $tenancy->getName()]) + ->group($groupRoutes); + } + + /** + * @param \Sprout\Contracts\Tenancy<\Sprout\Contracts\Tenant> $tenancy + * @param \Illuminate\Http\Response $response + * + * @return void + */ + public function terminate(Tenancy $tenancy, Response $response): void + { + if ($tenancy->check()) { + /** + * @var array{name:string, value:string} $details + */ + $details = $this->getCookieDetails( + [ + 'name' => $this->getRequestCookieName($tenancy), + 'value' => $tenancy->identifier(), + ] + ); + + $response->withCookie(Cookie::make(...$details)); + } + } + + /** + * @param array $details + * + * @return array + * + * @codeCoverageIgnore + */ + private function getCookieDetails(array $details): array + { + if (isset($this->options['minutes'])) { + $details['minutes'] = $this->options['minutes']; + } + + if (isset($this->options['path'])) { + $details['path'] = $this->options['path']; + } + + if (isset($this->options['domain'])) { + $details['domain'] = $this->options['domain']; + } + + if (isset($this->options['secure'])) { + $details['secure'] = $this->options['secure']; + } + + if (isset($this->options['httpOnly'])) { + $details['httpOnly'] = $this->options['httpOnly']; + } + + if (isset($this->options['sameSite'])) { + $details['sameSite'] = $this->options['sameSite']; + } + + return $details; + } +} diff --git a/src/Managers/IdentityResolverManager.php b/src/Managers/IdentityResolverManager.php index bbee71f..5fef3da 100644 --- a/src/Managers/IdentityResolverManager.php +++ b/src/Managers/IdentityResolverManager.php @@ -4,6 +4,7 @@ namespace Sprout\Managers; use InvalidArgumentException; +use Sprout\Http\Resolvers\CookieIdentityResolver; use Sprout\Http\Resolvers\HeaderIdentityResolver; use Sprout\Http\Resolvers\PathIdentityResolver; use Sprout\Http\Resolvers\SubdomainIdentityResolver; @@ -107,4 +108,23 @@ protected function createHeaderResolver(array $config, string $name): HeaderIden $config['header'] ?? null ); } + + /** + * Create the cookie identity resolver + * + * @param array $config + * @param string $name + * + * @phpstan-param array{cookie?: string|null, options?: array|null} $config + * + * @return \Sprout\Http\Resolvers\CookieIdentityResolver + */ + protected function createCookieResolver(array $config, string $name): CookieIdentityResolver + { + return new CookieIdentityResolver( + $name, + $config['cookie'] ?? null, + $config['options'] ?? [] + ); + } } diff --git a/tests/Resolvers/CookieResolverTest.php b/tests/Resolvers/CookieResolverTest.php new file mode 100644 index 0000000..e2901be --- /dev/null +++ b/tests/Resolvers/CookieResolverTest.php @@ -0,0 +1,75 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + $config->set('multitenancy.defaults.resolver', 'cookie'); + $config->set('multitenancy.resolvers.cookie', [ + 'driver' => 'cookie', + 'cookie' => '{Tenancy}-Identifier', + ]); + }); + } + + protected function defineRoutes($router) + { + $router->get('/', function () { + return 'no'; + }); + + $router->tenanted(function (Router $router) { + $router->get('/cookie-route', function (#[CurrentTenant] Tenant $tenant) { + return $tenant->getTenantKey(); + })->name('cookie.route'); + }, 'cookie', 'tenants'); + } + + #[Test] + public function resolvesFromRoute(): void + { + $tenant = TenantModel::first(); + + $result = $this->withUnencryptedCookie('Tenants-Identifier', $tenant->getTenantIdentifier())->get(route('cookie.route')); + + $result->assertOk(); + $result->assertContent((string)$tenant->getTenantKey()); + $result->cookie('Tenants-Identifier', $tenant->getTenantIdentifier()); + } + + #[Test] + public function throwsExceptionForInvalidTenant(): void + { + $result = $this->withCookie('Tenants-Identifier', 'i-am-not-real')->get(route('cookie.route')); + $result->assertInternalServerError(); + } + + #[Test] + public function throwsExceptionWithoutHeader(): void + { + $result = $this->get(route('cookie.route')); + + $result->assertInternalServerError(); + } +} From f2e1d057f2acc70c5a07bc903255444a6e4c0878 Mon Sep 17 00:00:00 2001 From: Ollie Date: Mon, 9 Sep 2024 22:45:02 +0100 Subject: [PATCH 2/7] chore: Ignore the cookie details builder from coverage --- src/Http/Resolvers/CookieIdentityResolver.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Resolvers/CookieIdentityResolver.php b/src/Http/Resolvers/CookieIdentityResolver.php index 6d85f78..849a28e 100644 --- a/src/Http/Resolvers/CookieIdentityResolver.php +++ b/src/Http/Resolvers/CookieIdentityResolver.php @@ -127,7 +127,7 @@ public function terminate(Tenancy $tenancy, Response $response): void * * @return array * - * @codeCoverageIgnore + * @codeCoverageIgnore */ private function getCookieDetails(array $details): array { From ff67251ed03ad1037feef1f9d932693ee7fa5178 Mon Sep 17 00:00:00 2001 From: Ollie Date: Mon, 9 Sep 2024 22:45:16 +0100 Subject: [PATCH 3/7] fix: Fix static analysis for cookie resolver driver method --- src/Managers/IdentityResolverManager.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Managers/IdentityResolverManager.php b/src/Managers/IdentityResolverManager.php index 5fef3da..272418c 100644 --- a/src/Managers/IdentityResolverManager.php +++ b/src/Managers/IdentityResolverManager.php @@ -112,10 +112,10 @@ protected function createHeaderResolver(array $config, string $name): HeaderIden /** * Create the cookie identity resolver * - * @param array $config - * @param string $name + * @param array $config + * @param string $name * - * @phpstan-param array{cookie?: string|null, options?: array|null} $config + * @phpstan-param array{cookie?: string|null, options?: array|null} $config * * @return \Sprout\Http\Resolvers\CookieIdentityResolver */ From 9aaacbae8c9c907741d68bfea16de86f655905d2 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Tue, 10 Sep 2024 11:24:01 +0100 Subject: [PATCH 4/7] feat(resolvers): Introduced resolution hook Identity resolvers can now report whether they can resolve for the hook, and tenancies now track the hook where the tenancy was resolved --- src/Contracts/IdentityResolver.php | 17 +++++++++ src/Contracts/Tenancy.php | 18 +++++++++ src/Http/Resolvers/CookieIdentityResolver.php | 11 +++--- src/Http/Resolvers/HeaderIdentityResolver.php | 4 +- src/Http/Resolvers/PathIdentityResolver.php | 4 +- .../Resolvers/SubdomainIdentityResolver.php | 4 +- src/Managers/IdentityResolverManager.php | 37 +++++++++++-------- src/Support/BaseIdentityResolver.php | 34 ++++++++++++++++- src/Support/DefaultTenancy.php | 35 +++++++++++++++++- src/Support/ResolutionHook.php | 15 ++++++++ 10 files changed, 149 insertions(+), 30 deletions(-) create mode 100644 src/Support/ResolutionHook.php diff --git a/src/Contracts/IdentityResolver.php b/src/Contracts/IdentityResolver.php index 64bb9b1..8166270 100644 --- a/src/Contracts/IdentityResolver.php +++ b/src/Contracts/IdentityResolver.php @@ -6,6 +6,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Router; use Illuminate\Routing\RouteRegistrar; +use Sprout\Support\ResolutionHook; /** * Identity Resolver Contract @@ -73,4 +74,20 @@ public function routes(Router $router, Closure $groupRoutes, Tenancy $tenancy): * @return void */ public function setup(Tenancy $tenancy, ?Tenant $tenant): void; + + /** + * Can the resolver run on the request + * + * This method allows a resolver to prevent resolution with the request in + * its current state, whether that means it's too early, or too late. + * + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Illuminate\Http\Request $request + * @param \Sprout\Contracts\Tenancy $tenancy + * @param \Sprout\Support\ResolutionHook $hook + * + * @return bool + */ + public function canResolve(Request $request, Tenancy $tenancy, ResolutionHook $hook): bool; } diff --git a/src/Contracts/Tenancy.php b/src/Contracts/Tenancy.php index 51e0e24..1dfa023 100644 --- a/src/Contracts/Tenancy.php +++ b/src/Contracts/Tenancy.php @@ -2,6 +2,8 @@ namespace Sprout\Contracts; +use Sprout\Support\ResolutionHook; + /** * Tenancy Contract * @@ -108,6 +110,15 @@ public function provider(): TenantProvider; */ public function resolvedVia(IdentityResolver $resolver): static; + /** + * Set the hook where the tenant was resolved + * + * @param \Sprout\Support\ResolutionHook $hook + * + * @return $this + */ + public function resolvedAt(ResolutionHook $hook): static; + /** * Get the used identity resolver * @@ -115,6 +126,13 @@ public function resolvedVia(IdentityResolver $resolver): static; */ public function resolver(): ?IdentityResolver; + /** + * Get the hook where the tenant was resolved + * + * @return \Sprout\Support\ResolutionHook|null + */ + public function hook(): ?ResolutionHook; + /** * Check if the current tenant was resolved * diff --git a/src/Http/Resolvers/CookieIdentityResolver.php b/src/Http/Resolvers/CookieIdentityResolver.php index 849a28e..0857554 100644 --- a/src/Http/Resolvers/CookieIdentityResolver.php +++ b/src/Http/Resolvers/CookieIdentityResolver.php @@ -24,13 +24,14 @@ final class CookieIdentityResolver extends BaseIdentityResolver implements Ident private array $options; /** - * @param string $name - * @param string|null $cookie - * @param array $options + * @param string $name + * @param string|null $cookie + * @param array $options + * @param array<\Sprout\Support\ResolutionHook> $hooks */ - public function __construct(string $name, ?string $cookie = null, array $options = []) + public function __construct(string $name, ?string $cookie = null, array $options = [], array $hooks = []) { - parent::__construct($name); + parent::__construct($name, $hooks); $this->cookie = $cookie ?? '{Tenancy}-Identifier'; $this->options = $options; diff --git a/src/Http/Resolvers/HeaderIdentityResolver.php b/src/Http/Resolvers/HeaderIdentityResolver.php index a881648..3e9fa81 100644 --- a/src/Http/Resolvers/HeaderIdentityResolver.php +++ b/src/Http/Resolvers/HeaderIdentityResolver.php @@ -15,9 +15,9 @@ class HeaderIdentityResolver extends BaseIdentityResolver { private string $header; - public function __construct(string $name, ?string $header = null) + public function __construct(string $name, ?string $header = null, array $hooks = []) { - parent::__construct($name); + parent::__construct($name, $hooks); $this->header = $header ?? '{Tenancy}-Identifier'; } diff --git a/src/Http/Resolvers/PathIdentityResolver.php b/src/Http/Resolvers/PathIdentityResolver.php index 9e1d1e8..e6de4ba 100644 --- a/src/Http/Resolvers/PathIdentityResolver.php +++ b/src/Http/Resolvers/PathIdentityResolver.php @@ -19,9 +19,9 @@ final class PathIdentityResolver extends BaseIdentityResolver implements Identit private int $segment = 1; - public function __construct(string $name, ?int $segment = null, ?string $pattern = null, ?string $parameter = null) + public function __construct(string $name, ?int $segment = null, ?string $pattern = null, ?string $parameter = null, array $hooks = []) { - parent::__construct($name); + parent::__construct($name, $hooks); if ($segment !== null) { $this->segment = max(1, $segment); diff --git a/src/Http/Resolvers/SubdomainIdentityResolver.php b/src/Http/Resolvers/SubdomainIdentityResolver.php index 583a3f5..e851481 100644 --- a/src/Http/Resolvers/SubdomainIdentityResolver.php +++ b/src/Http/Resolvers/SubdomainIdentityResolver.php @@ -19,9 +19,9 @@ final class SubdomainIdentityResolver extends BaseIdentityResolver implements Id private string $domain; - public function __construct(string $name, string $domain, ?string $pattern = null, ?string $parameter = null) + public function __construct(string $name, string $domain, ?string $pattern = null, ?string $parameter = null, array $hooks = []) { - parent::__construct($name); + parent::__construct($name, $hooks); $this->domain = $domain; diff --git a/src/Managers/IdentityResolverManager.php b/src/Managers/IdentityResolverManager.php index 272418c..e343b8e 100644 --- a/src/Managers/IdentityResolverManager.php +++ b/src/Managers/IdentityResolverManager.php @@ -7,6 +7,7 @@ use Sprout\Http\Resolvers\CookieIdentityResolver; use Sprout\Http\Resolvers\HeaderIdentityResolver; use Sprout\Http\Resolvers\PathIdentityResolver; +use Sprout\Http\Resolvers\SessionIdentityResolver; use Sprout\Http\Resolvers\SubdomainIdentityResolver; use Sprout\Support\BaseFactory; @@ -40,10 +41,10 @@ protected function getConfigKey(string $name): string /** * Create the subdomain identity resolver * - * @param array $config - * @param string $name + * @param array $config + * @param string $name * - * @phpstan-param array{domain?: string, pattern?: string|null, parameter?: string|null} $config + * @phpstan-param array{domain?: string, pattern?: string|null, parameter?: string|null, hooks?: array<\Sprout\Support\ResolutionHook>} $config * * @return \Sprout\Http\Resolvers\SubdomainIdentityResolver */ @@ -59,17 +60,18 @@ protected function createSubdomainResolver(array $config, string $name): Subdoma $name, $config['domain'], $config['pattern'] ?? null, - $config['parameter'] ?? null + $config['parameter'] ?? null, + $config['hooks'] ?? [] ); } /** * Create the path identity resolver * - * @param array $config - * @param string $name + * @param array $config + * @param string $name * - * @phpstan-param array{segment?: int|null, pattern?: string|null, parameter?: string|null} $config + * @phpstan-param array{segment?: int|null, pattern?: string|null, parameter?: string|null, hooks?: array<\Sprout\Support\ResolutionHook>} $config * * @return \Sprout\Http\Resolvers\PathIdentityResolver */ @@ -87,17 +89,18 @@ protected function createPathResolver(array $config, string $name): PathIdentity $name, $segment, $config['pattern'] ?? null, - $config['parameter'] ?? null + $config['parameter'] ?? null, + $config['hooks'] ?? [] ); } /** * Create the header identity resolver * - * @param array $config - * @param string $name + * @param array $config + * @param string $name * - * @phpstan-param array{header?: string|null} $config + * @phpstan-param array{header?: string|null, hooks?: array<\Sprout\Support\ResolutionHook>} $config * * @return \Sprout\Http\Resolvers\HeaderIdentityResolver */ @@ -105,17 +108,18 @@ protected function createHeaderResolver(array $config, string $name): HeaderIden { return new HeaderIdentityResolver( $name, - $config['header'] ?? null + $config['header'] ?? null, + $config['hooks'] ?? [] ); } /** * Create the cookie identity resolver * - * @param array $config - * @param string $name + * @param array $config + * @param string $name * - * @phpstan-param array{cookie?: string|null, options?: array|null} $config + * @phpstan-param array{cookie?: string|null, options?: array|null, hooks?: array<\Sprout\Support\ResolutionHook>} $config * * @return \Sprout\Http\Resolvers\CookieIdentityResolver */ @@ -124,7 +128,8 @@ protected function createCookieResolver(array $config, string $name): CookieIden return new CookieIdentityResolver( $name, $config['cookie'] ?? null, - $config['options'] ?? [] + $config['options'] ?? [], + $config['hooks'] ?? [] ); } } diff --git a/src/Support/BaseIdentityResolver.php b/src/Support/BaseIdentityResolver.php index 4cc4464..fb9be2b 100644 --- a/src/Support/BaseIdentityResolver.php +++ b/src/Support/BaseIdentityResolver.php @@ -3,6 +3,7 @@ namespace Sprout\Support; +use Illuminate\Http\Request; use Sprout\Contracts\IdentityResolver; use Sprout\Contracts\Tenancy; use Sprout\Contracts\Tenant; @@ -14,9 +15,19 @@ abstract class BaseIdentityResolver implements IdentityResolver */ private string $name; - public function __construct(string $name) + /** + * @var array<\Sprout\Support\ResolutionHook> + */ + private array $hooks; + + /** + * @param string $name + * @param array<\Sprout\Support\ResolutionHook> $hooks + */ + public function __construct(string $name, array $hooks = []) { - $this->name = $name; + $this->name = $name; + $this->hooks = empty($hooks) ? [ResolutionHook::Routing] : $hooks; } /** @@ -50,4 +61,23 @@ public function setup(Tenancy $tenancy, ?Tenant $tenant): void { // This is intentionally empty } + + /** + * Can the resolver run on the request + * + * This method allows a resolver to prevent resolution with the request in + * its current state, whether that means it's too early, or too late. + * + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Illuminate\Http\Request $request + * @param \Sprout\Contracts\Tenancy $tenancy + * @param \Sprout\Support\ResolutionHook $hook + * + * @return bool + */ + public function canResolve(Request $request, Tenancy $tenancy, ResolutionHook $hook): bool + { + return ! $tenancy->wasResolved() && in_array($hook, $this->hooks, true); + } } diff --git a/src/Support/DefaultTenancy.php b/src/Support/DefaultTenancy.php index 19e513b..f40fa92 100644 --- a/src/Support/DefaultTenancy.php +++ b/src/Support/DefaultTenancy.php @@ -43,6 +43,11 @@ final class DefaultTenancy implements Tenancy */ private array $options; + /** + * @var \Sprout\Support\ResolutionHook|null + */ + private ?ResolutionHook $hook = null; + /** * @param string $name * @param \Sprout\Contracts\TenantProvider $provider @@ -139,6 +144,7 @@ public function identify(string $identifier): bool if ($tenant === null) { $this->resolver = null; + $this->hook = null; return false; } @@ -164,12 +170,15 @@ public function load(int|string $key): bool $tenant = $this->provider()->retrieveByKey($key); if ($tenant === null) { + $this->resolver = null; + $this->hook = null; + return false; } $this->setTenant($tenant); - event( new TenantLoaded($tenant, $this)); + event(new TenantLoaded($tenant, $this)); return true; } @@ -264,4 +273,28 @@ public function option(string $key, mixed $default = null): mixed { return $this->options()[$key] ?? $default; } + + /** + * Set the hook where the tenant was resolved + * + * @param \Sprout\Support\ResolutionHook $hook + * + * @return $this + */ + public function resolvedAt(ResolutionHook $hook): static + { + $this->hook = $hook; + + return $this; + } + + /** + * Get the hook where the tenant was resolved + * + * @return \Sprout\Support\ResolutionHook|null + */ + public function hook(): ?ResolutionHook + { + return $this->hook; + } } diff --git a/src/Support/ResolutionHook.php b/src/Support/ResolutionHook.php new file mode 100644 index 0000000..9166bba --- /dev/null +++ b/src/Support/ResolutionHook.php @@ -0,0 +1,15 @@ + Date: Tue, 10 Sep 2024 11:24:28 +0100 Subject: [PATCH 5/7] refactor(resolvers): Abstract out resolution logic to avoid duplication --- src/Http/Middleware/TenantRoutes.php | 19 +++--- src/Listeners/IdentifyTenantOnRouting.php | 44 +++----------- src/Support/ResolutionHelper.php | 73 +++++++++++++++++++++++ 3 files changed, 90 insertions(+), 46 deletions(-) create mode 100644 src/Support/ResolutionHelper.php diff --git a/src/Http/Middleware/TenantRoutes.php b/src/Http/Middleware/TenantRoutes.php index d49c652..b538947 100644 --- a/src/Http/Middleware/TenantRoutes.php +++ b/src/Http/Middleware/TenantRoutes.php @@ -11,6 +11,8 @@ use Sprout\Managers\IdentityResolverManager; use Sprout\Managers\TenancyManager; use Sprout\Sprout; +use Sprout\Support\ResolutionHelper; +use Sprout\Support\ResolutionHook; /** * Tenant Routes Middleware @@ -51,17 +53,12 @@ public function handle(Request $request, Closure $next, string ...$options): Res $resolverName = $tenancyName = null; } - $resolver = $this->sprout->resolvers()->get($resolverName); - $tenancy = $this->sprout->tenancies()->get($tenancyName); - - /** - * @var \Sprout\Contracts\IdentityResolver $resolver - * @var \Sprout\Contracts\Tenancy<\Sprout\Contracts\Tenant> $tenancy - */ - - if (! $tenancy->check()) { - throw NoTenantFound::make($resolver->getName(), $tenancy->getName()); - } + ResolutionHelper::handleResolution( + $request, + ResolutionHook::Middleware, + $resolverName, + $tenancyName + ); // TODO: Decide whether to do anything with the following conditions //if (! $tenancy->wasResolved()) { diff --git a/src/Listeners/IdentifyTenantOnRouting.php b/src/Listeners/IdentifyTenantOnRouting.php index 546ae28..653d33f 100644 --- a/src/Listeners/IdentifyTenantOnRouting.php +++ b/src/Listeners/IdentifyTenantOnRouting.php @@ -7,12 +7,12 @@ use Illuminate\Routing\Route; use Illuminate\Support\Arr; use Illuminate\Support\Str; -use Sprout\Contracts\IdentityResolverUsesParameters; -use Sprout\Exceptions\NoTenantFound; use Sprout\Http\Middleware\TenantRoutes; use Sprout\Managers\IdentityResolverManager; use Sprout\Managers\TenancyManager; use Sprout\Sprout; +use Sprout\Support\ResolutionHelper; +use Sprout\Support\ResolutionHook; final class IdentifyTenantOnRouting { @@ -55,39 +55,13 @@ public function handle(RouteMatched $event): void [$resolverName, $tenancyName] = $options; - $resolver = $this->resolverManager->get($resolverName); - $tenancy = $this->tenancyManager->get($tenancyName); - - $this->sprout->setCurrentTenancy($tenancy); - - /** - * @var \Sprout\Contracts\IdentityResolver $resolver - * @var \Sprout\Contracts\Tenancy<\Sprout\Contracts\Tenant> $tenancy - */ - - // Is the resolver using a parameter, and is the parameter present? - if ( - $resolver instanceof IdentityResolverUsesParameters - && $event->route->hasParameter($resolver->getRouteParameterName($tenancy)) - ) { - // Use the route to resolve the identity from the parameter - $identity = $resolver->resolveFromRoute($event->route, $tenancy, $event->request); - $event->route->forgetParameter($resolver->getRouteParameterName($tenancy)); - } else { - // If we reach here, either the resolver doesn't use parameters, or - // the parameter isn't present in the URL, so we'll default to - // using the request - $identity = $resolver->resolveFromRequest($event->request, $tenancy); - } - - // Make sure the tenancy knows which resolver resolved it - $tenancy->resolvedVia($resolver); - - if ($identity === null || $tenancy->identify($identity) === false) { - throw NoTenantFound::make($resolver->getName(), $tenancy->getName()); - } - - return; + ResolutionHelper::handleResolution( + $event->request, + ResolutionHook::Routing, + $resolverName, + $tenancyName, + false + ); } /** diff --git a/src/Support/ResolutionHelper.php b/src/Support/ResolutionHelper.php new file mode 100644 index 0000000..fc942d2 --- /dev/null +++ b/src/Support/ResolutionHelper.php @@ -0,0 +1,73 @@ +make(Sprout::class); + $resolver = $sprout->resolvers()->get($resolverName); + $tenancy = $sprout->tenancies()->get($tenancyName); + + /** + * @var \Sprout\Contracts\IdentityResolver $resolver + * @var \Sprout\Contracts\Tenancy<\Sprout\Contracts\Tenant> $tenancy + */ + + if ($tenancy->check() || ! $resolver->canResolve($request, $tenancy, $hook)) { + return false; + } + + $sprout->setCurrentTenancy($tenancy); + + /** @var \Illuminate\Routing\Route|null $route */ + $route = $request->route(); + + // Is the resolver using a parameter, and is the parameter present? + if ( + $resolver instanceof IdentityResolverUsesParameters + && $route !== null + && $route->hasParameter($resolver->getRouteParameterName($tenancy)) + ) { + // Use the route to resolve the identity from the parameter + $identity = $resolver->resolveFromRoute($route, $tenancy, $request); + $route->forgetParameter($resolver->getRouteParameterName($tenancy)); + } else { + // If we reach here, either the resolver doesn't use parameters, or + // the parameter isn't present in the URL, so we'll default to + // using the request + $identity = $resolver->resolveFromRequest($request, $tenancy); + } + + // Make sure the tenancy knows which resolver resolved it + $tenancy->resolvedVia($resolver)->resolvedAt($hook); + + if ($identity === null || $tenancy->identify($identity) === false) { + if ($throw) { + throw NoTenantFound::make($resolver->getName(), $tenancy->getName()); + } + + return false; + } + + return true; + } +} From e0757d59cdf4756acde6a41668885128f6707a0f Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Tue, 10 Sep 2024 11:25:15 +0100 Subject: [PATCH 6/7] feat(resolvers): Add session-based identity resolver --- resources/config/multitenancy.php | 5 + .../Resolvers/SessionIdentityResolver.php | 111 ++++++++++++++++++ src/Managers/IdentityResolverManager.php | 19 +++ tests/Resolvers/SessionResolverTest.php | 78 ++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 src/Http/Resolvers/SessionIdentityResolver.php create mode 100644 tests/Resolvers/SessionResolverTest.php diff --git a/resources/config/multitenancy.php b/resources/config/multitenancy.php index 63cc94e..b609326 100644 --- a/resources/config/multitenancy.php +++ b/resources/config/multitenancy.php @@ -54,6 +54,11 @@ 'driver' => 'cookie', ], + 'session' => [ + 'driver' => 'session', + 'session' => 'multitenancy.{tenancy}', + ], + ], ]; diff --git a/src/Http/Resolvers/SessionIdentityResolver.php b/src/Http/Resolvers/SessionIdentityResolver.php new file mode 100644 index 0000000..1818059 --- /dev/null +++ b/src/Http/Resolvers/SessionIdentityResolver.php @@ -0,0 +1,111 @@ +session = $session ?? 'multitenancy.{tenancy}'; + } + + public function getSession(): string + { + return $this->session; + } + + /** + * @param \Sprout\Contracts\Tenancy<\Sprout\Contracts\Tenant> $tenancy + * + * @return string + */ + public function getRequestSessionName(Tenancy $tenancy): string + { + return str_replace( + ['{tenancy}', '{resolver}', '{Tenancy}', '{Resolver}'], + [$tenancy->getName(), $this->getName(), ucfirst($tenancy->getName()), ucfirst($this->getName())], + $this->getSession() + ); + } + + /** + * Get an identifier from the request + * + * Locates a tenant identifier within the provided request and returns it. + * + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Illuminate\Http\Request $request + * @param \Sprout\Contracts\Tenancy $tenancy + * + * @return string|null + */ + public function resolveFromRequest(Request $request, Tenancy $tenancy): ?string + { + /** + * This is unfortunately here because of the ludicrous return type + * + * @var string|null $identity + */ + $identity = $request->session()->get($this->getRequestSessionName($tenancy)); + + return $identity; + } + + /** + * Create a route group for the resolver + * + * Creates and configures a route group with the necessary settings to + * support identity resolution. + * + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Illuminate\Routing\Router $router + * @param \Closure $groupRoutes + * @param \Sprout\Contracts\Tenancy $tenancy + * + * @return \Illuminate\Routing\RouteRegistrar + */ + public function routes(Router $router, Closure $groupRoutes, Tenancy $tenancy): RouteRegistrar + { + return $router->middleware([TenantRoutes::ALIAS . ':' . $this->getName() . ',' . $tenancy->getName()]) + ->group($groupRoutes); + } + + /** + * Can the resolver run on the request + * + * This method allows a resolver to prevent resolution with the request in + * its current state, whether that means it's too early, or too late. + * + * @template TenantClass of \Sprout\Contracts\Tenant + * + * @param \Illuminate\Http\Request $request + * @param \Sprout\Contracts\Tenancy $tenancy + * @param \Sprout\Support\ResolutionHook $hook + * + * @return bool + */ + public function canResolve(Request $request, Tenancy $tenancy, ResolutionHook $hook): bool + { + return $request->hasSession() && $hook === ResolutionHook::Middleware; + } +} diff --git a/src/Managers/IdentityResolverManager.php b/src/Managers/IdentityResolverManager.php index e343b8e..4bed29b 100644 --- a/src/Managers/IdentityResolverManager.php +++ b/src/Managers/IdentityResolverManager.php @@ -132,4 +132,23 @@ protected function createCookieResolver(array $config, string $name): CookieIden $config['hooks'] ?? [] ); } + + /** + * Create the session identity resolver + * + * @param array $config + * @param string $name + * + * @phpstan-param array{session?: string|null, hooks?: array<\Sprout\Support\ResolutionHook>} $config + * + * @return \Sprout\Http\Resolvers\SessionIdentityResolver + */ + protected function createSessionResolver(array $config, string $name): SessionIdentityResolver + { + + return new SessionIdentityResolver( + $name, + $config['session'] ?? null + ); + } } diff --git a/tests/Resolvers/SessionResolverTest.php b/tests/Resolvers/SessionResolverTest.php new file mode 100644 index 0000000..ad8a22b --- /dev/null +++ b/tests/Resolvers/SessionResolverTest.php @@ -0,0 +1,78 @@ +set('multitenancy.providers.tenants.model', TenantModel::class); + $config->set('multitenancy.defaults.resolver', 'session'); + $config->set('multitenancy.resolvers.session', [ + 'driver' => 'session', + 'session' => 'multitenancy.{tenancy}', + ]); + }); + } + + protected function defineRoutes($router) + { + $router->middleware(StartSession::class)->group(function (Router $router) { + $router->get('/', function () { + return 'no'; + }); + + $router->tenanted(function (Router $router) { + $router->get('/session-route', function (#[CurrentTenant] Tenant $tenant) { + return $tenant->getTenantKey(); + })->name('session.route'); + }, 'session', 'tenants'); + }); + } + + #[Test] + public function resolvesFromRoute(): void + { + $tenant = TenantModel::first(); + + $result = $this->withSession(['multitenancy' => ['tenants' => $tenant->getTenantIdentifier()]])->get(route('session.route')); + + $result->assertOk(); + $result->assertContent((string)$tenant->getTenantKey()); + $result->assertSessionHas('multitenancy.tenants', $tenant->getTenantIdentifier()); + } + + #[Test] + public function throwsExceptionForInvalidTenant(): void + { + $result = $this->withSession(['multitenancy' => ['tenants' => 'i-am-not-real']])->get(route('session.route')); + $result->assertInternalServerError(); + } + + #[Test] + public function throwsExceptionWithoutHeader(): void + { + $result = $this->get(route('session.route')); + + $result->assertInternalServerError(); + } +} From 78f50603b461aa64d59f95068a2a648699fb93c3 Mon Sep 17 00:00:00 2001 From: Ollie Read Date: Tue, 10 Sep 2024 11:26:53 +0100 Subject: [PATCH 7/7] refactor: Remove unused dependencies on tenant identification listener --- src/Listeners/IdentifyTenantOnRouting.php | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/Listeners/IdentifyTenantOnRouting.php b/src/Listeners/IdentifyTenantOnRouting.php index 653d33f..4c707e2 100644 --- a/src/Listeners/IdentifyTenantOnRouting.php +++ b/src/Listeners/IdentifyTenantOnRouting.php @@ -16,28 +16,6 @@ final class IdentifyTenantOnRouting { - /** - * @var \Sprout\Sprout - */ - private Sprout $sprout; - - /** - * @var \Sprout\Managers\IdentityResolverManager - */ - private IdentityResolverManager $resolverManager; - - /** - * @var \Sprout\Managers\TenancyManager - */ - private TenancyManager $tenancyManager; - - public function __construct(Sprout $sprout, IdentityResolverManager $resolverManager, TenancyManager $tenancyManager) - { - $this->sprout = $sprout; - $this->resolverManager = $resolverManager; - $this->tenancyManager = $tenancyManager; - } - /** * @param \Illuminate\Routing\Events\RouteMatched $event *