Vodiči

Symfony Social Link Normalization: Resolve Facebook, Instagram, LinkedIn with Identity Resolver

Symfony normalizacija poveznica društvenih mreža: razriješite Facebook, Instagram, LinkedIn pomoću Identity Resolvera

Direktorij zajednice rijetko dobiva čiste podatke s društvenih mreža. Jedan član zalijepi puni Instagram URL, drugi pošalje varijantu Facebook profila, a netko drugi kopira LinkedIn poveznicu koja sadrži parametre za praćenje. Ako se te vrijednosti pohranjuju neizmijenjene, otkrivanje duplikata, prikaz profila, moderiranje i buduće migracije postaju teži.

Identity Resolver rješava taj problem na granici sustava normalizacijom javnih Facebook, Instagram i LinkedIn referenci u stabilan objekt identiteta. U ovom vodiču izgradit ćemo Symfony integraciju spremnu za produkciju koja provjerava poslane poveznice, poziva resolver, čuva njegov odgovor bez pretpostavki o nedokumentiranoj shemi i pohranjuje normalizirani identitet na članu direktorija.

Dobijte pristup prije pisanja integracijskog koda

Započnite na stranici usluge Identity Resolver, gdje možete pregledati informacije o usluzi i planu. Trenutačni javni endpoint zahtijeva ni račun token ni API ključ.

Postupak pristupa stoga je namjerno kratak:

  1. Otvorite službenu dokumentaciju za Identity Resolver.
  2. Prije integracije pregledajte podržane platforme i oblike unosa.
  3. Nemojte stvarati, kopirati ni konfigurirati token za trenutačni javni endpoint. Ne šalje se zaglavlje za autorizaciju.
  4. Ako želite račun za druge usluge ili upravljanje računom, upotrijebite stranicu za registraciju ili stranicu za prijavu. Nijedan korak nije potreban za ovaj zahtjev resolveru.

Budući da za ovaj endpoint ne postoji vjerodajnica, nema ničega za kopirati u Symfony tajne ili datoteku okruženja. Pohranit ćemo samo osnovni URL usluge u konfiguraciji koja koristi varijable okruženja. Ako se službeni ugovor o autentifikaciji kasnije promijeni, slijedite dokumentaciju umjesto da nagađate shemu autorizacije.

Provjerite točan endpoint

Resolver koristi GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Pošaljite platform uz jedan podržani parametar username, id, identifier, profile ili url. Naš direktorij prihvaća poveznice, stoga će slati platform i url.

curl --get \
  'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
  --data-urlencode 'platform=instagram' \
  --data-urlencode 'url=https://www.instagram.com/example/'

U taj zahtjev ne pripadaju bearer token, API ključ, kolačić ni prilagođeno zaglavlje za autentifikaciju. Pregledajte vraćeni JSON objekt, ali nemojte donositi odluke u aplikaciji na temelju polja koja službenim ugovorom nisu zajamčena.

Preduvjeti i struktura projekta

Ova implementacija pretpostavlja PHP 8.3 ili noviji, Symfony aplikaciju s integracijama HttpClienta, Doctrine ORMa, Monologa i PHPUnita te postojeći entitet DirectoryMember. Instalirajte komponente prve strane ako ih projekt već ne sadrži:

composer require symfony/http-client symfony/monolog-bundle symfony/orm-pack
composer require --dev symfony/test-pack

Zahtjev ostaje sinkron jer slanje u direktorij treba trenutačnu povratnu informaciju o provjeri. Messenger bi dodao operativnu složenost bez poboljšanja ove kratke interakcije. Ako normalizacija kasnije postane skupno obogaćivanje, bilo bi razumno premjestiti isti klijent iza Messenger obrađivača.

Relevantna struktura projekta namjerno je sažeta:

config/
  services.yaml
src/
  Controller/DirectorySocialLinkController.php
  Entity/DirectoryMember.php
  Identity/IdentityResolverClient.php
  Identity/ResolverFailure.php
  Identity/ResolverResult.php
tests/
  Identity/IdentityResolverClientTest.php
.env

Konfigurirajte okruženje i ubrizgavanje ovisnosti

Dodajte osnovni URL koji nije tajan u .env. Produkcijska implementacija trebala bi ga nadjačati putem hosting okruženja umjesto uređivanjem predanih datoteka aplikacije.

IDENTITY_RESOLVER_BASE_URI=https://ai.mihajlo.mk/api/identity-resolver

Izričito povežite tu vrijednost kako klijent ne bi slučajno primio nepovezani parametar niza:

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

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

    App\Identity\IdentityResolverClient:
        arguments:
            $baseUri: '%env(string:IDENTITY_RESOLVER_BASE_URI)%'

Izgradite obrambenu API granicu

Isporučeni ugovor obećava normalizirani odgovor javnog identiteta, ali ne daje nam dopuštenje da pretpostavimo određena polja odgovora. Granica stoga provjerava je li uspješan sadržaj JSON i čuva cijeli objekt kao neprozirni asocijativni niz. Kod za prikaz i pohranu može se razvijati kada bude dostupna dokumentirana shema odgovora.

Neuspjesi se vraćaju kao stanja domene umjesto da se iznimke prijenosa prosljeđuju kontroleru:

<?php
// src/Identity/ResolverFailure.php
namespace App\Identity;

enum ResolverFailure: string
{
    case InvalidInput = 'invalid_input';
    case Rejected = 'rejected';
    case RateLimited = 'rate_limited';
    case Unavailable = 'unavailable';
    case MalformedResponse = 'malformed_response';
}

// src/Identity/ResolverResult.php
namespace App\Identity;

final readonly class ResolverResult
{
    private function __construct(
        public bool $succeeded,
        public ?array $identity,
        public ?ResolverFailure $failure,
        public ?int $httpStatus,
    ) {}

    public static function success(array $identity, int $status): self
    {
        return new self(true, $identity, null, $status);
    }

    public static function failure(
        ResolverFailure $failure,
        ?int $status = null,
    ): self {
        return new self(false, null, $failure, $status);
    }
}

HTTP klijent primjenjuje ograničeno vrijeme neaktivnosti veze i ukupnog trajanja. Ponovno pokušava samo privremene neuspjehe prijenosa, status 429 i pogreške poslužitelja. Pogreške klijenta ne pokušavaju se slijepo ponovno jer ponovljeni nevaljani zahtjev ostaje nevaljan.

<?php
// src/Identity/IdentityResolverClient.php
namespace App\Identity;

use JsonException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class IdentityResolverClient
{
    private \Closure $sleep;

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly LoggerInterface $logger,
        private readonly string $baseUri,
        ?\Closure $sleep = null,
    ) {
        $this->sleep = $sleep ?? static fn (int $microseconds)
            => usleep($microseconds);
    }

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

        if (!in_array($platform, ['facebook', 'instagram', 'linkedin'], true)
            || !$this->isPublicProfileUrl($url)
        ) {
            return ResolverResult::failure(ResolverFailure::InvalidInput);
        }

        $endpoint = rtrim($this->baseUri, '/').'/v1/resolve';

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            $started = microtime(true);

            try {
                $response = $this->http->request('GET', $endpoint, [
                    'query' => [
                        'platform' => $platform,
                        'url' => $url,
                    ],
                    'timeout' => 2.0,
                    'max_duration' => 5.0,
                    'headers' => [
                        'Accept' => 'application/json',
                    ],
                ]);

                $status = $response->getStatusCode();
                $body = $response->getContent(false);
                $headers = $response->getHeaders(false);
            } catch (TransportExceptionInterface $exception) {
                $this->logger->warning('Identity resolver transport failure', [
                    'platform' => $platform,
                    'attempt' => $attempt,
                    'exception_class' => $exception::class,
                ]);

                if ($attempt === 3) {
                    return ResolverResult::failure(ResolverFailure::Unavailable);
                }

                ($this->sleep)($attempt * 100_000);
                continue;
            }

            $this->logger->info('Identity resolver request completed', [
                'platform' => $platform,
                'attempt' => $attempt,
                'status' => $status,
                'duration_ms' => (int) ((microtime(true) - $started) * 1000),
            ]);

            if ($status >= 200 && $status < 300) {
                try {
                    $identity = json_decode(
                        $body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR,
                    );
                } catch (JsonException) {
                    return ResolverResult::failure(
                        ResolverFailure::MalformedResponse,
                        $status,
                    );
                }

                if (!is_array($identity)) {
                    return ResolverResult::failure(
                        ResolverFailure::MalformedResponse,
                        $status,
                    );
                }

                return ResolverResult::success($identity, $status);
            }

            if ($status === 429) {
                if ($attempt === 3) {
                    return ResolverResult::failure(
                        ResolverFailure::RateLimited,
                        $status,
                    );
                }

                ($this->sleep)($this->retryDelay($headers, $attempt));
                continue;
            }

            if ($status >= 500) {
                if ($attempt === 3) {
                    return ResolverResult::failure(
                        ResolverFailure::Unavailable,
                        $status,
                    );
                }

                ($this->sleep)($attempt * 100_000);
                continue;
            }

            return ResolverResult::failure(ResolverFailure::Rejected, $status);
        }

        return ResolverResult::failure(ResolverFailure::Unavailable);
    }

    private function isPublicProfileUrl(string $url): bool
    {
        if (strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
            return false;
        }

        return in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true);
    }

    private function retryDelay(array $headers, int $attempt): int
    {
        $value = $headers['retry-after'][0] ?? null;

        if (is_string($value) && ctype_digit($value)) {
            return min((int) $value, 2) * 1_000_000;
        }

        return $attempt * 100_000;
    }
}

Poslani URL namjerno se ne nalazi u zapisnicima. Javni profili možda nisu tajni, ali korisnička imena i URL-ovi i dalje su podaci povezani s korisnicima te rijetko pomažu u rutinskoj operativnoj analizi.

Pohranite normalizirani identitet

Dodajte JSON stupac postojećem entitetu člana, zatim generirajte i pokrenite uobičajenu Doctrine migraciju. Omotnica u vlasništvu aplikacije bilježi odabranu platformu i vrijeme razrješavanja; resolver sadrži odgovor neizmijenjen.

<?php
// Relevant addition to src/Entity/DirectoryMember.php
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Column(type: Types::JSON)]
private array $socialIdentities = [];

public function putSocialIdentity(
    string $platform,
    array $resolverIdentity,
): void {
    $this->socialIdentities[$platform] = [
        'platform' => $platform,
        'resolved_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
        'resolver' => $resolverIdentity,
    ];
}

public function socialIdentities(): array
{
    return $this->socialIdentities;
}
php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction

Kontroler provjerava JSON, odbija nepodržane platforme i nepodudarajuće hostove, razrješava URL i pohranjuje samo uspješan rezultat. Provjere hostova smanjuju slučajna slanja i sprječavaju da direktorij postane opći relej za URL-ove.

<?php
// src/Controller/DirectorySocialLinkController.php
namespace App\Controller;

use App\Entity\DirectoryMember;
use App\Identity\IdentityResolverClient;
use App\Identity\ResolverFailure;
use Doctrine\ORM\EntityManagerInterface;
use JsonException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class DirectorySocialLinkController extends AbstractController
{
    #[Route(
        '/directory/members/{id}/social-links',
        name: 'directory_social_link_put',
        methods: ['PUT'],
    )]
    public function __invoke(
        int $id,
        Request $request,
        IdentityResolverClient $resolver,
        EntityManagerInterface $entityManager,
    ): JsonResponse {
        try {
            $input = json_decode(
                $request->getContent(),
                true,
                512,
                JSON_THROW_ON_ERROR,
            );
        } catch (JsonException) {
            return $this->json(['error' => 'invalid_json'], 400);
        }

        if (!is_array($input)) {
            return $this->json(['error' => 'invalid_body'], 400);
        }

        $platform = strtolower(trim((string) ($input['platform'] ?? '')));
        $url = trim((string) ($input['url'] ?? ''));

        if (!$this->hostMatchesPlatform($platform, $url)) {
            return $this->json(['error' => 'invalid_social_link'], 422);
        }

        $member = $entityManager->find(DirectoryMember::class, $id);

        if (!$member instanceof DirectoryMember) {
            return $this->json(['error' => 'member_not_found'], 404);
        }

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

        if (!$result->succeeded) {
            $status = match ($result->failure) {
                ResolverFailure::InvalidInput,
                ResolverFailure::Rejected => 422,
                ResolverFailure::RateLimited => 429,
                default => 503,
            };

            return $this->json([
                'error' => $result->failure?->value,
            ], $status);
        }

        $member->putSocialIdentity($platform, $result->identity);
        $entityManager->flush();

        return $this->json([
            'member_id' => $id,
            'identity' => $member->socialIdentities()[$platform],
        ]);
    }

    private function hostMatchesPlatform(string $platform, string $url): bool
    {
        $roots = [
            'facebook' => 'facebook.com',
            'instagram' => 'instagram.com',
            'linkedin' => 'linkedin.com',
        ];

        $host = strtolower((string) parse_url($url, PHP_URL_HOST));
        $root = $roots[$platform] ?? null;

        return $root !== null
            && ($host === $root || str_ends_with($host, '.'.$root));
    }
}

Stvarne aplikacije moraju također autorizirati izmjenu odabranog člana. Dodajte projektno pravilo votera ili kontrole pristupa prije bilo kakvog razrješavanja; poznavanje identifikatora baze podataka nikada ne smije dati dopuštenje za uređivanje.

Testirajte bez pozivanja javne usluge

MockHttpClient klijentu daje determinističke odgovore i omogućuje testovima provjeru odlaznog ugovora. Donji fixture namjerno je neutralan prema shemi: tvrdnja dokazuje da je proizvoljan JSON objekt sačuvan, a ne da postoji nedokumentirano polje.

<?php
// tests/Identity/IdentityResolverClientTest.php
namespace App\Tests\Identity;

use App\Identity\IdentityResolverClient;
use App\Identity\ResolverFailure;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class IdentityResolverClientTest extends TestCase
{
    public function testItSendsTheDocumentedRequestAndMapsJson(): void
    {
        $http = new MockHttpClient(function (
            string $method,
            string $url,
        ): MockResponse {
            self::assertSame('GET', $method);
            self::assertSame('/api/identity-resolver/v1/resolve', parse_url($url, PHP_URL_PATH));

            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('{"example":"preserved"}', [
                'http_code' => 200,
                'response_headers' => ['content-type: application/json'],
            ]);
        });

        $client = new IdentityResolverClient(
            $http,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/identity-resolver',
            static fn (int $microseconds) => null,
        );

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

        self::assertTrue($result->succeeded);
        self::assertSame(['example' => 'preserved'], $result->identity);
    }

    public function testItStopsAfterBoundedTemporaryFailures(): void
    {
        $calls = 0;
        $http = new MockHttpClient(function () use (&$calls): MockResponse {
            $calls++;

            return new MockResponse('temporarily unavailable', [
                'http_code' => 503,
            ]);
        });

        $client = new IdentityResolverClient(
            $http,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/identity-resolver',
            static fn (int $microseconds) => null,
        );

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

        self::assertFalse($result->succeeded);
        self::assertSame(ResolverFailure::Unavailable, $result->failure);
        self::assertSame(3, $calls);
    }
}
php bin/phpunit
php bin/console lint:container
php bin/console debug:router directory_social_link_put

Produkcijski načini neuspjeha i operacije

Korisna integracija razlikuje neuspjehe koje korisnici mogu ispraviti od neuspjeha kojima se operateri moraju baviti:

  • Nevaljan JSON, platforma, URL ili host: vratite 400 ili 422 bez kontaktiranja resolvera.
  • Odbijanje resolvera: vratite 422; nemojte automatski ponovno pokušavati pogrešku klijenta.
  • Ograničenje stope zahtjeva: poštujte numeričku vrijednost Retry-After unutar strogog ograničenja od dvije sekunde, zatim vratite 429 nakon iscrpljivanja proračuna za ponovne pokušaje.
  • Istek vremena ili pogreška poslužitelja: kratko pokušajte ponovno i vratite 503 bez promjene pohranjenih podataka člana.
  • Neispravno oblikovan payload uspjeha: tretirajte ga kao uzvodni neuspjeh. Nikada nemojte spremati HTML, skalarni JSON ili djelomično dekodiran sadržaj kao identitet.

Pratite brojanja prema ishodu, platformi, HTTP statusu i latenciji. Upozoravajte na trajne stope nedostupnosti ili neispravno oblikovanih odgovora, a ne na pojedinačne neuspjehe. Izbjegavajte oznake visoke kardinalnosti koje sadrže ID-jeve članova, URL-ove profila ili poruke iznimki.

Na rubu primijenite ograničenja veličine zahtjeva i ograničavanje stope po korisniku. Održavajte Symfony i CA certifikate ažurnima, nikada ne onemogućujte TLS provjeru i osigurajte da produkcijski zapisnici ne mogu zabilježiti potpuna tijela zahtjeva. Fiksni osnovni URI sprječava pozivatelje da upravljaju uzvodnim odredištem, dok provjere duljine URL-a i sheme ograničavaju potencijalno zlonamjeran unos.

Sigurno implementirajte i provjerite

Implementirajte kôd i migraciju zajedno, koristeći uobičajeni postupak izdanja projekta. Konfigurirajte IDENTITY_RESOLVER_BASE_URI u svakom okruženju, zagrijte produkcijski predmemoriju, jednom pokrenite migracije i potvrdite odlazni HTTPS pristup dokumentiranom hostu. Nije potreban korak umetanja tajne jer endpoint trenutačno ne koristi token.

Završite s ovim popisom za provjeru:

  • Pregledani su dokumentacija i stranica usluge radi trenutačnih pojedinosti ugovora.
  • Minimalni GET zahtjev uspijeva bez zaglavlja za autentifikaciju.
  • Testne poveznice za Facebook, Instagram i LinkedIn dosežu točan endpoint resolvera.
  • Uspješan JSON pohranjuje se pod ispravnim članom direktorija i platformom.
  • Nevaljani hostovi odbijaju se prije bilo kakvog vanjskog zahtjeva.
  • Pogreške klijenta ne pokušavaju se ponovno, dok privremeni neuspjesi imaju ograničen proračun za ponovne pokušaje.
  • Neispravno oblikovani odgovori i iscrpljeni ponovni pokušaji ostavljaju postojeće identitete nepromijenjenima.
  • Zapisnici sadrže status, pokušaj, trajanje i platformu, ali ne i poslani URL profila.
  • Autorizacija sprječava jednog člana da uređuje unos drugog člana u direktoriju.

Normalizacija je najvrjednija kada se odvija na granici sustava. Kada svaka poveznica s društvene mreže uđe u direktorij kao razriješeni identitet umjesto proizvoljnog niza, ostatak aplikacije postaje mirniji: pohrana je dosljedna, ponašanje pri neuspjehu izričito je, a buduće značajke mogu se graditi na jednom pouzdanom prikazu umjesto na godinama nakupljenih varijacija URL-ova.

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.