Vodiči

Laravel Identity Resolver: Normalize Social Links for Your Community Directory

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.php
  • app/Services/IdentityResolverClient.php
  • app/Jobs/ResolveSocialProfile.php
  • app/Http/Controllers/StoreCommunityProfileController.php
  • app/Models/SocialProfile.php
  • database/migrations/..._create_social_profiles_table.php
  • tests/Unit/IdentityResolverClientTest.php
  • tests/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-After i 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

  1. Potvrdite da službena dokumentacija i dalje navodi da krajnji punkt ne zahtijeva token.
  2. Pokrenite minimalni GET zahtjev sa svakom podržanom platformom.
  3. Pošaljite Facebook, Instagram i LinkedIn URL-ove putem autentificirane rute.
  4. Potvrdite da svaki zapis napreduje iz pending u resolved.
  5. Potvrdite da se neispravni hostovi odbacuju prije slanja zadatka.
  6. Pokrenite skup testova s php artisan test.
  7. Simulirajte odgovore 429 i 5xx te potvrdite da ponavljanja ostaju ograničena.
  8. 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.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.