feat(bootstrap): update tenant bootstrap endpoint to accept domain and path as query parameters, enhancing tenant resolution logic

This commit is contained in:
2026-08-14 15:17:24 -03:00
parent de8da72354
commit c1bfab471c
15 changed files with 344 additions and 37 deletions

View File

@@ -14,7 +14,10 @@ class TenantBootstrapController extends Controller
public function __invoke(TenantBootstrapRequest $request): TenantResource
{
return TenantResource::make(
$this->bootstrapService->get((string) $request->validated('dominio'))
$this->bootstrapService->get(
(string) $request->validated('dominio'),
(string) $request->validated('path'),
)
);
}
}

View File

@@ -10,6 +10,8 @@ class TenantBootstrapRequest extends FormRequest
{
protected bool $hasInvalidDomain = false;
protected bool $hasInvalidPath = false;
public function authorize(): bool
{
return true;
@@ -17,13 +19,20 @@ class TenantBootstrapRequest extends FormRequest
protected function prepareForValidation(): void
{
$rawDomain = $this->route('dominio');
$rawDomain = $this->query('dominio', $this->route('dominio'));
$rawPath = $this->query('path', '/');
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$normalizedPath = TenantDomainNormalizer::normalizePath($rawPath);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;
$this->merge(['dominio' => $normalizedDomain]);
$this->hasInvalidPath = ! is_string($rawPath) || $normalizedPath === null;
$this->merge([
'dominio' => $normalizedDomain,
'path' => $normalizedPath,
]);
}
/** @return array<string, mixed> */
@@ -41,6 +50,17 @@ class TenantBootstrapRequest extends FormRequest
'string',
'max:255',
],
'path' => [
'bail',
function (string $attribute, mixed $value, Closure $fail): void {
if ($this->hasInvalidPath) {
$fail("The {$attribute} field must contain a valid URL path.");
}
},
'required',
'string',
'max:2048',
],
];
}
}

View File

@@ -5,15 +5,31 @@ namespace App\Domains\Bootstrap\Services;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Services\TenantInformationService;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class TenantBootstrapService
{
public function __construct(protected TenantInformationService $tenantInformationService) {}
public function get(string $domain): Tenant
public function get(string $domain, string $path = '/'): Tenant
{
$candidateKeys = TenantDomainNormalizer::tenantKeyCandidates($domain, $path);
$tenantsByDomain = Tenant::query()
->whereIn('dominio', $candidateKeys)
->get()
->keyBy('dominio');
$tenant = collect($candidateKeys)
->map(fn (string $candidate): ?Tenant => $tenantsByDomain->get($candidate))
->first(fn (?Tenant $candidate): bool => $candidate !== null);
if (! $tenant instanceof Tenant) {
throw (new ModelNotFoundException)->setModel(Tenant::class);
}
return $this->tenantInformationService->load(
Tenant::query()->where('dominio', $domain)->firstOrFail(),
$tenant,
[
'menues' => fn ($query) => $query->whereHas(
'roles',

View File

@@ -12,12 +12,12 @@ Entrega la configuración inicial que necesitan la tienda y el panel administrat
## Endpoints
- `GET /tenants/bootstrap/{dominio}`: bootstrap público de la tienda.
- `GET /tenants/bootstrap?dominio={hostname}&path={path}`: bootstrap público de la tienda. Resuelve la clave de tenant más específica que sea prefijo completo del path y usa el dominio raíz como fallback.
- Endpoint de bootstrap bajo `/v1/adminapp`, protegido por `auth:sanctum` y `adminapp.tenant`.
## Validación
`TenantBootstrapRequest` valida el dominio recibido. `AdminAppBootstrapRequest` reutiliza ese contrato para el panel.
`TenantBootstrapRequest` valida y normaliza por separado el hostname y el path recibidos. `AdminAppBootstrapRequest` reutiliza ese contrato para el panel.
## Dependencias

View File

@@ -3,8 +3,7 @@
use App\Domains\Bootstrap\Controllers\TenantBootstrapController;
use Illuminate\Support\Facades\Route;
Route::get('tenants/bootstrap/{dominio}', TenantBootstrapController::class)
->where('dominio', '.*');
Route::get('tenants/bootstrap', TenantBootstrapController::class);
require __DIR__.'/adminapp.php';
require __DIR__.'/scanner.php';

View File

@@ -23,7 +23,7 @@ class StoreTenantRequest extends FormRequest
protected function prepareForValidation(): void
{
$rawDomain = $this->input('dominio');
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$normalizedDomain = TenantDomainNormalizer::normalizeTenantKey($rawDomain);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;

View File

@@ -24,7 +24,7 @@ class UpdateTenantRequest extends FormRequest
{
if ($this->has('dominio')) {
$rawDomain = $this->input('dominio');
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
$normalizedDomain = TenantDomainNormalizer::normalizeTenantKey($rawDomain);
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;

View File

@@ -33,4 +33,90 @@ class TenantDomainNormalizer
return strtolower($host);
}
public static function normalizeTenantKey(mixed $domain, mixed $path = null): ?string
{
$host = self::normalize($domain);
if ($host === null) {
return null;
}
if ($path === null && is_string($domain)) {
$decodedDomain = trim(urldecode($domain));
$candidate = str_contains($decodedDomain, '://')
? $decodedDomain
: "//{$decodedDomain}";
$path = parse_url($candidate, PHP_URL_PATH) ?: '/';
}
$normalizedPath = self::normalizePath($path);
if ($normalizedPath === null) {
return null;
}
return $host.($normalizedPath === '/' ? '' : $normalizedPath);
}
public static function normalizePath(mixed $path): ?string
{
if (! is_string($path)) {
return null;
}
$path = trim($path);
if ($path === '' || $path === '/') {
return '/';
}
$path = parse_url(str_starts_with($path, '/') ? $path : "/{$path}", PHP_URL_PATH);
if (! is_string($path)) {
return null;
}
$path = preg_replace('#/+#', '/', $path);
if (! is_string($path)) {
return null;
}
$segments = array_filter(explode('/', $path), static fn (string $segment): bool => $segment !== '');
foreach ($segments as $segment) {
if ($segment === '.' || $segment === '..') {
return null;
}
}
return '/'.implode('/', $segments);
}
/** @return list<string> */
public static function tenantKeyCandidates(mixed $domain, mixed $path): array
{
$host = self::normalize($domain);
$normalizedPath = self::normalizePath($path);
if ($host === null || $normalizedPath === null) {
return [];
}
$segments = array_values(array_filter(
explode('/', $normalizedPath),
static fn (string $segment): bool => $segment !== '',
));
$candidates = [];
while ($segments !== []) {
$candidates[] = $host.'/'.implode('/', $segments);
array_pop($segments);
}
$candidates[] = $host;
return $candidates;
}
}