Laravel Identity Resolver: Normalizirajte društvene poveznice za svoj direktorij zajednice
Direktorij zajednice izgleda uredno dok članovi ne zalijepe isti društveni identitet u pola tuceta oblika: mobilne Facebook poveznice, Instagram URL-ove s nizovima upita ili regionalne LinkedIn adrese. Izravna usporedba tih nizova stvara duplikate, krhko pretraživanje i nezgrapnu moderaciju.
Prava granica nije još jedna hrpa regularnih izraza. Ovaj vodič izrađuje Laravel integraciju koja prihvaća poveznice na Facebook, Instagram i LinkedIn profile, šalje ih Identity Resolveru te dobiveni normalizirani javni identitet pohranjuje kao stabilan JSON objekt. Prijave ulaze kroz brzi kontroler; normalizacija se izvršava u Laravelovom redu s ograničenim vremenskim ograničenjima, promišljenim ponavljanjima, strukturiranim neuspjesima i determinističkim testovima.
Preduvjeti
Potrebni su vam PHP 8.3 ili noviji, Composer, Laravel aplikacija s autentifikacijom, podržana baza podataka i funkcionalan pozadinski sustav redova. Primjer koristi Laravelov red u bazi podataka kako bi projekt bio samostalan, ali Redis ga može zamijeniti bez izmjene domenskog koda.
Aplikacija će pohranjivati poslane URL-ove jer članovi direktorija trebaju pregledati što su unijeli. Te URL-ove tretirajte kao osobne podatke: ograničite pristup, definirajte pravila zadržavanja i nikada ih ne uključujte u operativne zapise.
Prije pisanja integracijskog koda pribavite pristup
Započnite na stranici usluge i paketa Identity Resolver, a zatim pročitajte službenu dokumentaciju usluge. Dokumentacija je također mjerodavna točka za registraciju i točka za prijavu za ovu integraciju.
Trenutačni javni krajnji punkt ne zahtijeva token računa ni API ključ. Posljedično, nema zaslona za vjerodajnice, nema tokena za kopiranje i nema zaglavlja za autentifikaciju koje treba poslati prije prvog zahtjeva. Nemojte izmišljati prazan bearer token: neki proxyji prazno zaglavlje Authorization tretiraju drukčije nego izostavljeno zaglavlje.
Točna operacija je GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Prihvaća platform te podržani parametar username, id, identifier, profile ili url. Naš direktorij prihvaća poveznice pa dosljedno šalje platform i url.
Napravite minimalni test s URL-om javnog profila koji smijete obrađivati:
curl --get \
--data-urlencode "platform=instagram" \
--data-urlencode "url=https://www.instagram.com/example/" \
"https://ai.mihajlo.mk/api/identity-resolver/v1/resolve"
Uspješan poziv vraća odgovor s normaliziranim javnim identitetom. Ovdje dani javni ugovor ne jamči pojedinačne nazive JSON polja, stoga će aplikacija provjeriti da je primila neprazan JSON objekt i sačuvati taj objekt bez nagađanja njegove sheme.
Nema vjerodajnice koju treba staviti u .env. Spremite sam krajnji punkt u konfiguraciju potkrijepljenu okruženjem, a autentifikaciju izostavite:
IDENTITY_RESOLVER_ENDPOINT=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
QUEUE_CONNECTION=database
Dodajte sljedeći unos u config/services.php:
'identity_resolver' => [
'endpoint' => env(
'IDENTITY_RESOLVER_ENDPOINT',
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
),
],
Arhitektura i kompromisi
Putanja zahtjeva ne bi trebala čekati vanjsku uslugu. Kontroler provjerava platformu i host, stvara zapis na čekanju, šalje zadatak i vraća HTTP 202. Zadatak poziva namjenski API klijent i mijenja zapis u resolved ili failed.
To uvodi eventualnu dosljednost: novoposlani profil kratko je na čekanju. Zauzvrat, spor resolver ne može zauzeti sve PHP radnike niti učiniti da obrazac djeluje neispravno. Zapis u bazi podataka također postaje eksplicitni stroj stanja umjesto da djelomično dovršeni zahtjevi ostanu nevidljivi.
Relevantna struktura projekta je:
app/Domain/Identity/ResolvedIdentity.phpapp/Services/IdentityResolverClient.phpapp/Jobs/ResolveSocialProfile.phpapp/Http/Controllers/StoreCommunityProfileController.phpapp/Models/SocialProfile.phpdatabase/migrations/..._create_social_profiles_table.phptests/Unit/IdentityResolverClientTest.phptests/Feature/StoreCommunityProfileTest.php
Sačuvajte životni ciklus normalizacije
Izradite model, migraciju, kontroler i zadatak pomoću Artisana. Ako aplikacija već nema tablice reda u bazi podataka, izradite i njih:
php artisan make:model SocialProfile -m
php artisan make:controller StoreCommunityProfileController
php artisan make:job ResolveSocialProfile
php artisan make:queue-table
php artisan migrate
Definirajte tablicu profila samo s poljima u vlasništvu aplikacije. Odgovor resolvera ostaje JSON jer njegovi nedokumentirani detalji ne smiju procuriti u stupce ni poslovna pravila.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('social_profiles', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('platform', 20);
$table->text('submitted_url');
$table->json('normalized_identity')->nullable();
$table->string('status', 20)->default('pending');
$table->string('failure_kind', 40)->nullable();
$table->timestamps();
$table->index(['user_id', 'platform', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('social_profiles');
}
};
U SocialProfile dopustite samo polja koja zapisuje ovaj tijek rada i pretvorite objekt identiteta:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class SocialProfile extends Model
{
protected $fillable = [
'user_id',
'platform',
'submitted_url',
'normalized_identity',
'status',
'failure_kind',
];
protected $casts = [
'normalized_identity' => 'array',
];
}
Izgradite obrambenu API granicu
Domensko mapiranje namjerno obećava vrlo malo: poznatu platformu i potpuni normalizirani objekt javnog identiteta. Time se izbjegava povezivanje ostatka aplikacije s poljima odgovora koja nisu utvrđena ugovorom usluge.
<?php
namespace App\Domain\Identity;
final readonly class ResolvedIdentity
{
public function __construct(
public string $platform,
public array $publicIdentity,
) {}
}
Izradite app/Services/IdentityResolverException.php i klijent. Klijent pokušava zahtjev najviše dva puta. Ponavlja neuspjehe veze, HTTP 429 i pogreške poslužitelja, ali nikada naslijepo ne ponavlja druge 4xx odgovore. Retry-After se poštuje kada sadrži sekunde, uz ograničenje kako bi radnici ostali ograničeni.
<?php
namespace App\Services;
use App\Domain\Identity\ResolvedIdentity;
use Closure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class IdentityResolverException extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $status = null,
public readonly ?int $retryAfter = null,
) {
parent::__construct("Identity resolution failed: {$kind}");
}
}
final class IdentityResolverClient
{
private Closure $sleep;
public function __construct(?Closure $sleep = null)
{
$this->sleep = $sleep ?? static fn (int $seconds) => sleep($seconds);
}
public function resolve(string $platform, string $url): ResolvedIdentity
{
$endpoint = config('services.identity_resolver.endpoint');
for ($attempt = 1; $attempt <= 2; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(2)
->timeout(6)
->get($endpoint, [
'platform' => $platform,
'url' => $url,
]);
} catch (ConnectionException) {
if ($attempt < 2) {
($this->sleep)(1);
continue;
}
throw new IdentityResolverException('connection');
}
if ($response->successful()) {
$data = $response->json();
if (! is_array($data) || $data === [] || array_is_list($data)) {
throw new IdentityResolverException(
'invalid_response',
$response->status()
);
}
return new ResolvedIdentity($platform, $data);
}
$status = $response->status();
$retryAfter = $this->retryAfter($response);
$retryable = $status === 429 || $status >= 500;
if ($retryable && $attempt < 2) {
($this->sleep)(min($retryAfter ?? 1, 5));
continue;
}
$kind = match (true) {
$status === 429 => 'rate_limited',
$status >= 500 => 'upstream',
default => 'rejected',
};
throw new IdentityResolverException($kind, $status, $retryAfter);
}
throw new IdentityResolverException('unexpected');
}
private function retryAfter(Response $response): ?int
{
$value = $response->header('Retry-After');
return is_string($value) && ctype_digit($value)
? max(1, min(300, (int) $value))
: null;
}
}
Provjerite prijave i pošaljite zadatak
Provjera URL-a na razini sintakse nije dovoljna. Uskladite host URL-a s odabranom platformom prije nego što ga pošaljete izvan svojeg sustava. Provjera sufiksa prihvaća uobičajene poddomene, a odbija obmanjujuće hostove kao što je instagram.com.attacker.example.
<?php
namespace App\Http\Controllers;
use App\Jobs\ResolveSocialProfile;
use App\Models\SocialProfile;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
final class StoreCommunityProfileController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$data = $request->validate([
'platform' => ['required', Rule::in([
'facebook', 'instagram', 'linkedin',
])],
'url' => ['required', 'url:http,https', 'max:2048'],
]);
$expected = [
'facebook' => 'facebook.com',
'instagram' => 'instagram.com',
'linkedin' => 'linkedin.com',
][$data['platform']];
$host = strtolower(parse_url($data['url'], PHP_URL_HOST) ?? '');
$matches = $host === $expected
|| str_ends_with($host, ".{$expected}");
if (! $matches) {
throw ValidationException::withMessages([
'url' => 'The URL host does not match the selected platform.',
]);
}
$profile = SocialProfile::create([
'user_id' => $request->user()->id,
'platform' => $data['platform'],
'submitted_url' => $data['url'],
'status' => 'pending',
]);
ResolveSocialProfile::dispatch($profile);
return response()->json([
'id' => $profile->id,
'status' => $profile->status,
], 202);
}
}
Registrirajte autentificiranu rutu s ograničenjem broja zahtjeva u routes/web.php. Zadržavanje u grupi web posrednog sloja također osigurava Laravelovu uobičajenu CSRF zaštitu:
use App\Http\Controllers\StoreCommunityProfileController;
use Illuminate\Support\Facades\Route;
Route::post('/directory/profiles', StoreCommunityProfileController::class)
->middleware(['auth', 'throttle:20,1']);
Zadatak primjenjuje dulje vraćanje u red na razini reda nakon klijentova kratkog prozora trenutačnog ponavljanja. Bilježi identifikatore i kategorije neuspjeha, a ne poslane URL-ove ni tijela odgovora.
<?php
namespace App\Jobs;
use App\Models\SocialProfile;
use App\Services\IdentityResolverClient;
use App\Services\IdentityResolverException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
final class ResolveSocialProfile implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public bool $deleteWhenMissingModels = true;
public function __construct(public SocialProfile $profile) {}
public function handle(IdentityResolverClient $client): void
{
try {
$identity = $client->resolve(
$this->profile->platform,
$this->profile->submitted_url,
);
$this->profile->update([
'normalized_identity' => $identity->publicIdentity,
'status' => 'resolved',
'failure_kind' => null,
]);
} catch (IdentityResolverException $exception) {
Log::warning('identity_resolver.failure', [
'profile_id' => $this->profile->id,
'platform' => $this->profile->platform,
'kind' => $exception->kind,
'status' => $exception->status,
'attempt' => $this->attempts(),
]);
$retryable = in_array(
$exception->kind,
['connection', 'rate_limited', 'upstream'],
true
);
if ($retryable && $this->attempts() < $this->tries) {
$this->release($exception->retryAfter ?? 60);
return;
}
$this->profile->update([
'status' => 'failed',
'failure_kind' => $exception->kind,
]);
}
}
}
Testirajte uspjeh, ponavljanja i slanje zahtjeva
Http::fake() sprječava da testovi ovise o javnoj usluzi. Fiksni odgovor koristi eksplicitni testni sentinel umjesto da se pretvara da je službeno polje odgovora.
<?php
namespace Tests\Unit;
use App\Services\IdentityResolverClient;
use App\Services\IdentityResolverException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class IdentityResolverClientTest extends TestCase
{
public function test_it_retries_a_server_error_then_maps_json(): void
{
config(['services.identity_resolver.endpoint' => 'https://resolver.test']);
Http::preventStrayRequests();
Http::fakeSequence()
->pushStatus(503)
->push(['test_sentinel' => true], 200);
$client = new IdentityResolverClient(
static function (int $seconds): void {}
);
$result = $client->resolve(
'instagram',
'https://www.instagram.com/example/'
);
$this->assertSame('instagram', $result->platform);
$this->assertSame(
['test_sentinel' => true],
$result->publicIdentity
);
Http::assertSentCount(2);
}
public function test_it_rejects_a_json_list_as_an_invalid_response(): void
{
Http::preventStrayRequests();
Http::fake(['*' => Http::response(['unexpected'], 200)]);
$this->expectException(IdentityResolverException::class);
(new IdentityResolverClient())->resolve(
'facebook',
'https://www.facebook.com/example'
);
}
}
Dodajte funkcionalni test kako biste dokazali da HTTP zahtjev stvara rad na čekanju bez kontaktiranja resolvera:
<?php
namespace Tests\Feature;
use App\Jobs\ResolveSocialProfile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
final class StoreCommunityProfileTest extends TestCase
{
use RefreshDatabase;
public function test_an_authenticated_member_can_submit_a_profile(): void
{
Queue::fake();
$user = User::factory()->create();
$this->actingAs($user)->postJson('/directory/profiles', [
'platform' => 'linkedin',
'url' => 'https://www.linkedin.com/in/example',
])->assertStatus(202)
->assertJson(['status' => 'pending']);
$this->assertDatabaseHas('social_profiles', [
'user_id' => $user->id,
'platform' => 'linkedin',
'status' => 'pending',
]);
Queue::assertPushed(ResolveSocialProfile::class);
}
}
Sigurnost, vidljivost i implementacija
Dopustite samo autentificirane prijave, zadržite CSRF zaštitu, ograničite broj zahtjeva po korisniku i zasebno autorizirajte uređivanje direktorija od pregledavanja. Nikada ne prikazujte normalizirani JSON kao pouzdani HTML. Ako se prikazuju poveznice, izbjegnite oznake i ponovno provjerite sheme pri prikazu.
Pratite brojanja i latenciju prema ishodu: riješeno, odbijeno, ograničeno stopom, neuspjeh uzvodne usluge, nevaljan odgovor i neuspjeh veze. Upozorite na trajne omjere neuspjeha ili rastući red na čekanju. Izbjegavajte bilježenje URL-ova, tijela odgovora, kolačića ili budućih vjerodajnica.
Implementirajte migraciju i predmemoriranu konfiguraciju prije ponovnog pokretanja radnika:
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --tries=3 --timeout=30 --max-time=3600
U produkciji pokrenite radnika pod nadzorom procesa kako bi se ponovno pokrenuo nakon izlazaka i implementacija. Zadržite vremensko ograničenje radnika iznad klijentova ograničenog najgoreg mogućeg trajanja, a ispod intervala retry-after veze reda.
Uobičajeni načini neuspjeha
- HTTP 422 ili drugo odbijanje: provjerite pisanje platforme i osigurajte da zahtjev šalje jedan podržani parametar identifikatora. Ovaj projekt uvijek koristi
url. - HTTP 429: sačuvajte zapis na čekanju dok postoje ograničena ponavljanja, poštujte numerički
Retry-Afteri izbjegavajte dodavanje dodatnih slojeva trenutačnog ponavljanja. - HTTP 401 ili 403: nemojte ponavljati. Javni krajnji punkt trenutačno ne treba token, stoga najprije provjerite je li uvedeno nepotrebno zaglavlje za autorizaciju ili pravilo pristupnika.
- Zapisi na čekanju nikad se ne mijenjaju: potvrdite da radnik reda radi s istim okruženjem i predmemoriranom konfiguracijom kao web-proces.
- Neuspjesi zbog nevaljanog odgovora: privatno provjerite stanje usluge, ali nemojte ublažavati provjeru ni masovno bilježiti korisne sadržaje javnog identiteta.
Završni kontrolni popis provjere
- Potvrdite da službena dokumentacija i dalje navodi da krajnji punkt ne zahtijeva token.
- Pokrenite minimalni GET zahtjev sa svakom podržanom platformom.
- Pošaljite Facebook, Instagram i LinkedIn URL-ove putem autentificirane rute.
- Potvrdite da svaki zapis napreduje iz
pendinguresolved. - Potvrdite da se neispravni hostovi odbacuju prije slanja zadatka.
- Pokrenite skup testova s
php artisan test. - Simulirajte odgovore 429 i 5xx te potvrdite da ponavljanja ostaju ograničena.
- Provjerite da zapisi sadrže ID-jeve profila i kategorije neuspjeha, a nikada poslane URL-ove ni tijela odgovora.
Trajna pouka dizajna jednostavna je: normalizacija pripada promišljenoj granici. Kada neobrađene društvene poveznice uđu kao nepouzdane prijave, prođu kroz usko definiran klijent resolvera i izađu kao provjereni objekti identiteta s vidljivim stanjima, direktorij prestaje biti zbirka krhkih nizova i postaje pouzdan podatak aplikacije.