Vodiči

Symfony: Standardize Social Media Links in Your Directory with AI Identity Resolver

Symfony: Standardizirajte poveznice na društvene mreže u svom direktoriju pomoću AI Identity Resolvera

Direktorij zajednice rijetko prima društvene profile u jednom urednom formatu. Jedan član zalijepi puni Instagram URL, drugi pošalje Facebook poveznicu s parametrima za praćenje, a netko drugi unese LinkedIn profil kopiran iz mobilnog preglednika. Ako se te vrijednosti pohrane nepromijenjene, pretraživanje, uklanjanje duplikata, provjera valjanosti i prikaz brzo postaju nepouzdani.

Ovaj vodič izrađuje produkcijski usmjerenu Symfony funkcionalnost koja poslane Facebook, Instagram i LinkedIn poveznice šalje servisu Identity Resolver te vraća stabilan objekt javnog identiteta. Integracija koristi Symfonyjev HTTP klijent, eksplicitno mapiranje domene, ograničene ponovne pokušaje, strukturirane neuspjehe, determinističke testove i konfiguraciju sigurnu za implementaciju.

Pribavite pristup prije pisanja integracijskog koda

Započnite sa stranicom servisa Identity Resolver, koja opisuje servis i informacije o dostupnim planovima. Zatim pročitajte službenu dokumentaciju za Identity Resolver prije slanja zahtjeva.

Uobičajena pitanja o registraciji i zahtjevima za prijavu ovdje imaju osobito jednostavan odgovor: trenutačni javni krajnji punkt ne zahtijeva token računa ni API ključ. Stoga ne postoji zaslon s vjerodajnicama s kojeg bi se kopirao token i ne treba dodati zaglavlje autorizacije. Nemojte izmišljati niti dodjeljivati rezervnu tajnu za ovu integraciju.

Točan zahtjev je HTTP GET prema:

https://ai.mihajlo.mk/api/identity-resolver/v1/resolve

Krajnji punkt prihvaća platform uz jedan podržani parametar username, id, identifier, profile ili url. Budući da ovaj projekt normalizira poveznice poslane u direktorij, namjerno šalje samo platform i url.

Pošaljite minimalni zahtjev prije izrade funkcionalnosti:

curl --fail-with-body --get \
  'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
  --data-urlencode 'platform=linkedin' \
  --data-urlencode 'url=https://www.linkedin.com/in/example-profile/'

Zamijenite primjer javnim URL-om profila koji smijete obraditi. Zaglavlje Authorization nije potrebno. Vraćeni JSON tretirajte kao normalizirani objekt javnog identiteta; granica aplikacije u nastavku namjerno izbjegava pretpostavljati nedokumentirana imena polja.

Preduvjeti i struktura projekta

Potrebni su vam PHP 8.3 ili noviji, Composer i Symfony aplikacija sposobna primati JSON zahtjeve. Usmjerena instalacija može se stvoriti pomoću:

composer create-project symfony/skeleton community-directory
cd community-directory
composer require symfony/http-client symfony/monolog-bundle
composer require --dev symfony/test-pack

Funkcionalnost ima tri sloja:

  • Kontroler provjerava prijavu u direktorij i upravlja HTTP odgovorom aplikacije.
  • IdentityResolver upravlja udaljenim protokolom, vremenskim ograničenjima, ponovnim pokušajima i klasifikacijom uzvodnih pogrešaka.
  • NormalizedSocialIdentity prenosi platformu i neprozirni normalizirani objekt identiteta u domenu direktorija.

Normalizacija je sinkrona jer osobi koja šalje profil u direktorij koristi trenutačna povratna informacija. Messenger bi dodao operativnu složenost bez poboljšanja ovog kratkog puta zahtjeva. Ako uvozi kasnije obrađuju tisuće postojećih članova, ista se usluga može pozvati iz Messenger rukovatelja bez promjene granice API-ja.

Relevantna struktura namjerno je mala:

config/services.yaml
src/Controller/NormalizeSocialLinkController.php
src/Identity/IdentityResolver.php
src/Identity/NormalizedSocialIdentity.php
src/Identity/ResolverFailure.php
tests/Identity/IdentityResolverTest.php

Konfigurirajte krajnji punkt bez izmišljanja vjerodajnice

Vrijednosti specifične za implementaciju držite u konfiguraciji podržanoj varijablama okruženja. Budući da ovaj javni krajnji punkt trenutačno nema token, tijekom razvoja u .env.local pohranite samo njegov URL:

IDENTITY_RESOLVER_ENDPOINT=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve

Nemojte dodavati IDENTITY_RESOLVER_TOKEN ni YOUR_SERVICE_TOKEN; nijedno ne pripada dostavljenom ugovoru o autentikaciji. Ako se autentikacija uvede u budućnosti, slijedite službenu dokumentaciju i dodajte vjerodajnicu putem spremišta tajni platforme za implementaciju, umjesto da je učinite dijelom repozitorija.

Povežite krajnji punkt i HTTP klijent u config/services.yaml:

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    App\Identity\IdentityResolver:
        arguments:
            $endpoint: '%env(string:IDENTITY_RESOLVER_ENDPOINT)%'

Mapirajte odgovor na granici domene

Objavljeni ugovor obećava normalizirani odgovor javnog identiteta, ali ne opravdava nagađanje pojedinačnih JSON svojstava. Sačuvajte taj objekt, a aplikaciji istodobno pružite vlastiti stabilni omotač:

<?php

namespace App\Identity;

final readonly class NormalizedSocialIdentity
{
    public function __construct(
        public string $platform,
        public array $publicIdentity,
    ) {
    }

    public function toArray(): array
    {
        return [
            'platform' => $this->platform,
            'identity' => $this->publicIdentity,
        ];
    }
}

Ovo razdvajanje je važno. Ugovor vašeg kontrolera ostaje platform plus identity, dok dokumentirani objekt javnog identiteta razrješivača ostaje netaknut. Ako direktorij koristi Doctrine, pohranite objekt u JSON stupac uz člana i platformu ili mapirajte dokumentirana polja tek nakon što ih potvrdite u službenoj dokumentaciji.

Izričito predstavite operativne neuspjehe:

<?php

namespace App\Identity;

final class ResolverFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly ?int $upstreamStatus = null,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($reason, 0, $previous);
    }
}

Izradite ograničeni HTTP klijent svjestan ponovnih pokušaja

Klasa servisa prihvaća samo podržane platforme i valjane HTTP ili HTTPS URL-ove. Ponovno pokušava kod transportnih pogrešaka, ograničenja brzine i uskog skupa prolaznih odgovora poslužitelja. Pogreške klijenta ne pokušavaju se naslijepo ponovno. Odgoda je ograničena kako slanje profila ne bi neograničeno zauzimalo PHP radnika.

<?php

namespace App\Identity;

use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class IdentityResolver
{
    private const PLATFORMS = ['facebook', 'instagram', 'linkedin'];
    private readonly \Closure $sleeper;

    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly string $endpoint,
        ?\Closure $sleeper = null,
    ) {
        $this->sleeper = $sleeper ?? static fn (int $microseconds) => usleep($microseconds);
    }

    public function resolveUrl(string $platform, string $url): NormalizedSocialIdentity
    {
        $platform = strtolower(trim($platform));
        $url = trim($url);

        if (!in_array($platform, self::PLATFORMS, true)) {
            throw new \InvalidArgumentException('Unsupported social platform.');
        }

        if (
            filter_var($url, FILTER_VALIDATE_URL) === false
            || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)
        ) {
            throw new \InvalidArgumentException('A valid HTTP or HTTPS profile URL is required.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->httpClient->request('GET', $this->endpoint, [
                    'query' => [
                        'platform' => $platform,
                        'url' => $url,
                    ],
                    'timeout' => 3.0,
                    'max_duration' => 8.0,
                ]);

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    $payload = $response->toArray(false);

                    if ($payload === [] || array_is_list($payload)) {
                        throw new ResolverFailure('malformed_response', $status);
                    }

                    return new NormalizedSocialIdentity($platform, $payload);
                }

                $retryable = $status === 429 || in_array($status, [502, 503, 504], true);

                if ($retryable && $attempt < 3) {
                    $retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;
                    ($this->sleeper)($this->delay($attempt, $retryAfter));
                    continue;
                }

                if ($status >= 400 && $status < 500) {
                    throw new ResolverFailure('request_rejected', $status);
                }

                throw new ResolverFailure('upstream_unavailable', $status);
            } catch (DecodingExceptionInterface $exception) {
                throw new ResolverFailure('malformed_response', null, $exception);
            } catch (TransportExceptionInterface $exception) {
                if ($attempt === 3) {
                    throw new ResolverFailure('transport_failure', null, $exception);
                }

                ($this->sleeper)($this->delay($attempt, null));
            }
        }

        throw new ResolverFailure('upstream_unavailable');
    }

    private function delay(int $attempt, ?string $retryAfter): int
    {
        if ($retryAfter !== null && ctype_digit($retryAfter)) {
            return min(2_000_000, (int) $retryAfter * 1_000_000);
        }

        return min(2_000_000, 200_000 * (2 ** ($attempt - 1)));
    }
}

Neispravan uspješan odgovor ne pokušava se ponovno jer ponavljanje istog zahtjeva vjerojatno neće popraviti problem sheme. Brojčane vrijednosti Retry-After poštuju se, ali su ograničene na dvije sekunde. Klijent ne šalje kolačiće, vjerodajnice ni zaglavlja autorizacije.

Izložite rutu za normalizaciju direktorija

Kontroler prihvaća prijavu okrenutu članu, poziva razrješivač, bilježi operativni kontekst bez zapisivanja punog URL-a profila ili vraćenog identiteta te proizvodi strukturirane pogreške aplikacije:

<?php

namespace App\Controller;

use App\Identity\IdentityResolver;
use App\Identity\ResolverFailure;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class NormalizeSocialLinkController
{
    #[Route('/directory/social-links/normalize', methods: ['POST'])]
    public function __invoke(
        Request $request,
        IdentityResolver $resolver,
        LoggerInterface $logger,
    ): JsonResponse {
        try {
            $input = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);

            if (!is_array($input)) {
                throw new \InvalidArgumentException('A JSON object is required.');
            }

            $platform = $input['platform'] ?? null;
            $url = $input['url'] ?? null;

            if (!is_string($platform) || !is_string($url)) {
                throw new \InvalidArgumentException('Platform and URL must be strings.');
            }

            $identity = $resolver->resolveUrl($platform, $url);

            return new JsonResponse($identity->toArray(), 200);
        } catch (\JsonException|\InvalidArgumentException $exception) {
            return new JsonResponse([
                'error' => 'invalid_submission',
                'message' => $exception->getMessage(),
            ], 422);
        } catch (ResolverFailure $exception) {
            $logger->warning('Identity resolution failed.', [
                'reason' => $exception->reason,
                'upstream_status' => $exception->upstreamStatus,
            ]);

            $status = $exception->reason === 'request_rejected' ? 422 : 503;

            return new JsonResponse([
                'error' => $exception->reason,
            ], $status);
        }
    }
}

Ruta ne prihvaća proizvoljni udaljeni krajnji punkt, pa je korisnici ne mogu pretvoriti u proxy za krivotvorenje zahtjeva na strani poslužitelja. Poslani URL šalje se samo kao kodirani parametar upita konfiguriranom krajnjem punktu razrješivača.

Testirajte uspjeh, oblik zahtjeva i ponašanje ponovnog pokušaja

MockHttpClient čini testove determinističkima i sprječava slučajni mrežni promet. Umjetni ključevi testnih podataka u nastavku provjeravaju transparentno mapiranje granice; ne tvrde da dokumentiraju polja odgovora servisa.

<?php

namespace App\Tests\Identity;

use App\Identity\IdentityResolver;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class IdentityResolverTest extends TestCase
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve';

    public function testItMapsAResolvedIdentityAndSendsTheContractedQuery(): void
    {
        $http = new MockHttpClient(
            function (string $method, string $url): MockResponse {
                self::assertSame('GET', $method);

                parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
                self::assertSame('instagram', $query['platform']);
                self::assertSame(
                    'https://www.instagram.com/example/',
                    $query['url']
                );

                return new MockResponse(
                    json_encode(['fixture' => 'normalized'], JSON_THROW_ON_ERROR),
                    ['http_code' => 200]
                );
            }
        );

        $resolver = new IdentityResolver($http, self::ENDPOINT);
        $result = $resolver->resolveUrl(
            'instagram',
            'https://www.instagram.com/example/'
        );

        self::assertSame('instagram', $result->platform);
        self::assertSame(['fixture' => 'normalized'], $result->publicIdentity);
    }

    public function testItRetriesATransientFailureWithoutActuallySleeping(): void
    {
        $http = new MockHttpClient([
            new MockResponse('', ['http_code' => 503]),
            new MockResponse(
                json_encode(['fixture' => 'recovered'], JSON_THROW_ON_ERROR),
                ['http_code' => 200]
            ),
        ]);

        $delays = [];
        $resolver = new IdentityResolver(
            $http,
            self::ENDPOINT,
            static function (int $delay) use (&$delays): void {
                $delays[] = $delay;
            }
        );

        $result = $resolver->resolveUrl(
            'facebook',
            'https://www.facebook.com/example/'
        );

        self::assertSame(['fixture' => 'recovered'], $result->publicIdentity);
        self::assertSame([200_000], $delays);
        self::assertSame(2, $http->getRequestsCount());
    }
}

Pokrenite skup testova s php bin/phpunit. Dodajte testove kontrolera za neispravan JSON i nepodržane platforme ako je ovaj krajnji punkt dio veće sigurnosne granice aplikacije.

Sigurnost, vidljivost i implementacija

Javne društvene poveznice i dalje su podaci koje pružaju korisnici. Primijenite ograničenja veličine zahtjeva na web-poslužitelju, zahtijevajte uobičajenu autentikaciju aplikacije ondje gdje je uređivanje direktorija ograničeno i primijenite CSRF zaštitu na obrasce u pregledniku. Nemojte polja iz normaliziranog objekta prikazivati kao pouzdani HTML. Izbjegnite prikazane vrijednosti i dopustite samo očekivane URL sheme.

Dnevnici trebaju sadržavati razlog neuspjeha, uzvodni status, platformu kada je korisno, ID korelacije zahtjeva i proteklo vrijeme. Izbjegavajte zapisivanje poslanog URL-a ili potpunog odgovora jer bilo koji može sadržavati osobne podatke ili neočekivane parametre upita. Odvojeno pratite stope za request_rejected, transport_failure, malformed_response i HTTP 429 odgovore.

Pri implementaciji definirajte IDENTITY_RESOLVER_ENDPOINT u svakom okruženju, zagrijte Symfony predmemoriju i pokrenite skup testova prije preusmjeravanja prometa. Osigurajte da je dopušten odlazni HTTPS pristup prema ai.mihajlo.mk. Zadržite vremensko ograničenje PHP radnika iznad klijentova maksimalnog trajanja od osam sekundi, uz očuvanje konačnog vremenskog ograničenja zahtjeva na razini aplikacije.

Uobičajeni načini neuspjeha

  • Svaki zahtjev prima 422: potvrdite da je tijelo JSON i da sadrži vrijednosti niza nazvane platform i url.
  • Platforma je lokalno odbijena: koristite točno facebook, instagram ili linkedin.
  • Uzvodni servis odbija zahtjev: provjerite odgovara li odabrana platforma poslanom javnom URL-u. Nemojte ponovno pokušavati nepromijenjene pogreške klijenta.
  • Odgovori povremeno postaju 503: pregledajte dnevnike transporta, vremenskih ograničenja, 429 i uzvodnog statusa. Zahtjev je već iscrpio ograničene ponovne pokušaje.
  • Testovi kontaktiraju stvarni servis: umetnite MockHttpClient; dostupnost živog API-ja nikada ne smije biti dio jediničnog testa.
  • Implementacija očekuje tajnu: uklonite tu pretpostavku. Trenutačni javni krajnji punkt ne zahtijeva token računa ni API ključ.

Kontrolni popis za završnu provjeru

  1. Potvrdite da službena dokumentacija i dalje opisuje javni krajnji punkt kao onaj bez tokena.
  2. Pošaljite minimalni curl zahtjev s odgovarajućim URL-om javnog profila.
  3. Pokrenite php bin/phpunit i provjerite prolaze li testovi mapiranja i ponovnog pokušaja.
  4. Pošaljite Facebook, Instagram i LinkedIn URL-ove putem rute direktorija.
  5. Provjerite sadrže li uspješni odgovori vaš stabilni omotač s platform i identity.
  6. Simulirajte neispravan JSON, nepodržanu platformu, vremensko ograničenje, HTTP 429 i HTTP 503.
  7. Potvrdite da dnevnici sadrže strukturirane metapodatke o neuspjehu, ali ne i potpuni društveni URL ili sadržaj identiteta.
  8. Provjerite dopušta li produkcija odlazni HTTPS i dostavlja li krajnji punkt putem konfiguracije okruženja.

Učinite normalizaciju granicom, a ne naknadnom mišlju

Pouzdan direktorij ne bi trebao prisiljavati svaku stranicu za pretraživanje, predložak, izvoz i provjeru duplikata da ponovno tumači proizvoljne društvene poveznice. Normalizirajte jednom, na točki na kojoj podaci ulaze u sustav, i nastavite koristiti promišljeni objekt domene.

Najvažniji dizajnerski izbor nije sam HTTP poziv. To je granica oko tog poziva: dokumentirani ulazi, bez izmišljene autentikacije, obrambena obrada odgovora, ograničeno ponašanje pri neuspjehu, dnevnici koji vode računa o privatnosti i testovi koji nikada ne ovise o mreži. S tim elementima na mjestu, nedosljedne društvene poveznice postaju predvidljivi podaci direktorija umjesto trajnog posla čišćenja.

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.