Add BootstrapTenantController and requests for tenant domain normalization; implement API route and tests
This commit is contained in:
21
app/Domains/Tenant/Controllers/BootstrapTenantController.php
Normal file
21
app/Domains/Tenant/Controllers/BootstrapTenantController.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Tenant\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Requests\BootstrapTenantRequest;
|
||||||
|
use App\Domains\Tenant\Resources\TenantResource;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class BootstrapTenantController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(BootstrapTenantRequest $request): TenantResource
|
||||||
|
{
|
||||||
|
/** @var string $dominio */
|
||||||
|
$dominio = $request->validated('dominio');
|
||||||
|
|
||||||
|
return TenantResource::make(
|
||||||
|
Tenant::query()->where('dominio', $dominio)->firstOrFail()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Domains/Tenant/Requests/BootstrapTenantRequest.php
Normal file
50
app/Domains/Tenant/Requests/BootstrapTenantRequest.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Tenant\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class BootstrapTenantRequest extends FormRequest
|
||||||
|
{
|
||||||
|
protected bool $hasInvalidDomain = false;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$rawDomain = $this->route('dominio');
|
||||||
|
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||||
|
|
||||||
|
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||||
|
&& $normalizedDomain === null;
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'dominio' => $normalizedDomain,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'dominio' => [
|
||||||
|
'bail',
|
||||||
|
function (string $attribute, mixed $value, Closure $fail): void {
|
||||||
|
if ($this->hasInvalidDomain) {
|
||||||
|
$fail("The {$attribute} field must contain a valid domain or URL.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,33 @@
|
|||||||
namespace App\Domains\Tenant\Requests;
|
namespace App\Domains\Tenant\Requests;
|
||||||
|
|
||||||
use App\Domains\Prop\Support\PropRules;
|
use App\Domains\Prop\Support\PropRules;
|
||||||
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
|
use Closure;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class StoreTenantRequest extends FormRequest
|
class StoreTenantRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
protected bool $hasInvalidDomain = false;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$rawDomain = $this->input('dominio');
|
||||||
|
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||||
|
|
||||||
|
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||||
|
&& $normalizedDomain === null;
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'dominio' => $normalizedDomain,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -21,7 +38,18 @@ class StoreTenantRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
'dominio' => ['nullable', 'string', 'max:255'],
|
'dominio' => [
|
||||||
|
'bail',
|
||||||
|
function (string $attribute, mixed $value, Closure $fail): void {
|
||||||
|
if ($this->hasInvalidDomain) {
|
||||||
|
$fail("The {$attribute} field must contain a valid domain or URL.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('tenants', 'dominio'),
|
||||||
|
],
|
||||||
...PropRules::sync(),
|
...PropRules::sync(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,33 @@ namespace App\Domains\Tenant\Requests;
|
|||||||
|
|
||||||
use App\Domains\Prop\Support\PropRules;
|
use App\Domains\Prop\Support\PropRules;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
|
use Closure;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class UpdateTenantRequest extends FormRequest
|
class UpdateTenantRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
protected bool $hasInvalidDomain = false;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$rawDomain = $this->input('dominio');
|
||||||
|
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||||
|
|
||||||
|
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||||
|
&& $normalizedDomain === null;
|
||||||
|
|
||||||
|
$this->merge([
|
||||||
|
'dominio' => $normalizedDomain,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -30,7 +47,18 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
Rule::unique('tenants', 'codigo')->ignore($tenant?->id),
|
Rule::unique('tenants', 'codigo')->ignore($tenant?->id),
|
||||||
],
|
],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
'dominio' => ['nullable', 'string', 'max:255'],
|
'dominio' => [
|
||||||
|
'bail',
|
||||||
|
function (string $attribute, mixed $value, Closure $fail): void {
|
||||||
|
if ($this->hasInvalidDomain) {
|
||||||
|
$fail("The {$attribute} field must contain a valid domain or URL.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
|
||||||
|
],
|
||||||
...PropRules::sync(),
|
...PropRules::sync(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ class TenantResource extends JsonResource
|
|||||||
'codigo' => $this->codigo,
|
'codigo' => $this->codigo,
|
||||||
'nombre' => $this->nombre,
|
'nombre' => $this->nombre,
|
||||||
'dominio' => $this->dominio,
|
'dominio' => $this->dominio,
|
||||||
'props' => PropValueResource::collapsedFromPropable($this->resource),
|
'props' => PropValueResource::collapsedFromPropable($this->resource)
|
||||||
'created_at' => $this->created_at,
|
|
||||||
'updated_at' => $this->updated_at,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
app/Domains/Tenant/Support/TenantDomainNormalizer.php
Normal file
36
app/Domains/Tenant/Support/TenantDomainNormalizer.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Tenant\Support;
|
||||||
|
|
||||||
|
class TenantDomainNormalizer
|
||||||
|
{
|
||||||
|
public static function hasValue(mixed $domain): bool
|
||||||
|
{
|
||||||
|
return is_string($domain) && trim(urldecode($domain)) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalize(mixed $domain): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($domain)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decodedDomain = trim(urldecode($domain));
|
||||||
|
|
||||||
|
if ($decodedDomain === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = str_contains($decodedDomain, '://')
|
||||||
|
? $decodedDomain
|
||||||
|
: "//{$decodedDomain}";
|
||||||
|
|
||||||
|
$host = parse_url($candidate, PHP_URL_HOST);
|
||||||
|
|
||||||
|
if (! is_string($host) || $host === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower($host);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Controllers\BootstrapTenantController;
|
||||||
use App\Domains\Tenant\Controllers\TenantController;
|
use App\Domains\Tenant\Controllers\TenantController;
|
||||||
use App\Domains\Tenant\Controllers\TenantPropController;
|
use App\Domains\Tenant\Controllers\TenantPropController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
|
||||||
|
->where('dominio', '.*');
|
||||||
|
|
||||||
Route::apiResource('tenants', TenantController::class);
|
Route::apiResource('tenants', TenantController::class);
|
||||||
Route::apiResource('tenant-props', TenantPropController::class)
|
Route::apiResource('tenant-props', TenantPropController::class)
|
||||||
->parameters(['tenant-props' => 'tenantProp']);
|
->parameters(['tenant-props' => 'tenantProp']);
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('tenant_props', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('codigo')->unique();
|
|
||||||
$table->string('prop_type');
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('tenant_props');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$normalizedDomains = [];
|
||||||
|
$duplicateTenantIdsByDomain = [];
|
||||||
|
|
||||||
|
$tenants = DB::table('tenants')
|
||||||
|
->select(['id', 'dominio'])
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($tenants as $tenant) {
|
||||||
|
$normalizedDomain = $this->normalizeDomain($tenant->dominio, $tenant->id);
|
||||||
|
|
||||||
|
if ($normalizedDomain === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedDomains[$tenant->id] = $normalizedDomain;
|
||||||
|
$duplicateTenantIdsByDomain[$normalizedDomain][] = $tenant->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$duplicates = array_filter(
|
||||||
|
$duplicateTenantIdsByDomain,
|
||||||
|
static fn (array $tenantIds): bool => count($tenantIds) > 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($duplicates !== []) {
|
||||||
|
$duplicateSummary = array_map(
|
||||||
|
static fn (string $domain, array $tenantIds): string => sprintf(
|
||||||
|
'%s => [%s]',
|
||||||
|
$domain,
|
||||||
|
implode(', ', $tenantIds),
|
||||||
|
),
|
||||||
|
array_keys($duplicates),
|
||||||
|
array_values($duplicates),
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Cannot create a unique index for tenants.dominio because normalized duplicates exist: '
|
||||||
|
.implode('; ', $duplicateSummary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($normalizedDomains as $tenantId => $normalizedDomain) {
|
||||||
|
DB::table('tenants')
|
||||||
|
->where('id', $tenantId)
|
||||||
|
->update(['dominio' => $normalizedDomain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('tenants', function (Blueprint $table) {
|
||||||
|
$table->unique('dominio', 'tenants_dominio_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants', function (Blueprint $table) {
|
||||||
|
$table->dropUnique('tenants_dominio_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeDomain(mixed $domain, int $tenantId): ?string
|
||||||
|
{
|
||||||
|
if ($domain === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($domain)) {
|
||||||
|
throw new RuntimeException("Cannot normalize tenant domain for tenant {$tenantId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$decodedDomain = trim(urldecode($domain));
|
||||||
|
|
||||||
|
if ($decodedDomain === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = str_contains($decodedDomain, '://')
|
||||||
|
? $decodedDomain
|
||||||
|
: "//{$decodedDomain}";
|
||||||
|
|
||||||
|
$host = parse_url($candidate, PHP_URL_HOST);
|
||||||
|
|
||||||
|
if (! is_string($host) || $host === '') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Cannot normalize tenant domain '{$domain}' for tenant {$tenantId}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower($host);
|
||||||
|
}
|
||||||
|
};
|
||||||
122
tests/Feature/Tenant/BootstrapTenantControllerTest.php
Normal file
122
tests/Feature/Tenant/BootstrapTenantControllerTest.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class BootstrapTenantControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_bootstraps_a_tenant_by_domain_and_includes_props(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Tenant::createProp([
|
||||||
|
'codigo' => 'primary_color',
|
||||||
|
'nombre' => 'Primary Color',
|
||||||
|
'is_required' => false,
|
||||||
|
'data_type' => 'string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->setPropValue('primary_color', 'blue');
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/tenants/bootstrap/acme.com');
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('codigo', 'acme')
|
||||||
|
->assertJsonPath('dominio', 'acme.com')
|
||||||
|
->assertJsonPath('props.primary_color', 'blue');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_bootstraps_a_tenant_from_a_full_url(): void
|
||||||
|
{
|
||||||
|
Tenant::create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$encodedDomain = urlencode('https://ACME.com:8080/path?foo=bar#frag');
|
||||||
|
|
||||||
|
$response = $this->getJson("/api/tenants/bootstrap/{$encodedDomain}");
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('codigo', 'acme')
|
||||||
|
->assertJsonPath('dominio', 'acme.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_not_found_when_the_domain_does_not_exist(): void
|
||||||
|
{
|
||||||
|
$response = $this->getJson('/api/tenants/bootstrap/missing.example');
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
|
||||||
|
{
|
||||||
|
$firstResponse = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'https://ACME.com/path',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$firstResponse
|
||||||
|
->assertCreated()
|
||||||
|
->assertJsonPath('dominio', 'acme.com');
|
||||||
|
|
||||||
|
$secondResponse = $this->postJson('/api/tenants', [
|
||||||
|
'codigo' => 'globex',
|
||||||
|
'nombre' => 'Globex',
|
||||||
|
'dominio' => 'acme.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$secondResponse
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['dominio']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_allows_keeping_the_same_domain_on_update_but_rejects_collisions(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme',
|
||||||
|
'dominio' => 'acme.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$otherTenant = Tenant::create([
|
||||||
|
'codigo' => 'globex',
|
||||||
|
'nombre' => 'Globex',
|
||||||
|
'dominio' => 'globex.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$successfulResponse = $this->putJson("/api/tenants/{$tenant->id}", [
|
||||||
|
'codigo' => 'acme',
|
||||||
|
'nombre' => 'Acme Updated',
|
||||||
|
'dominio' => 'https://ACME.com:443/admin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$successfulResponse
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('nombre', 'Acme Updated')
|
||||||
|
->assertJsonPath('dominio', 'acme.com');
|
||||||
|
|
||||||
|
$failingResponse = $this->putJson("/api/tenants/{$otherTenant->id}", [
|
||||||
|
'codigo' => 'globex',
|
||||||
|
'nombre' => 'Globex',
|
||||||
|
'dominio' => 'https://ACME.com/storefront',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$failingResponse
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['dominio']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user