Туториали

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

Нормализација на социјалните врски во Symfony: Разрешете Facebook, Instagram, LinkedIn со Identity Resolver

Директориум на заедницата ретко добива чисти податоци од социјални мрежи. Еден член внесува целосна URL-адреса од Instagram, друг доставува варијанта на профил на Facebook, а некој друг копира врска од LinkedIn што содржи параметри за следење. Ако тие вредности се зачуваат непроменети, откривањето дупликати, прикажувањето профили, модерирањето и идните миграции стануваат потешки.

Identity Resolver го решава овој граничен проблем со нормализирање на јавните референци од Facebook, Instagram и LinkedIn во стабилен објект за идентитет. Во ова упатство, ќе изградиме Symfony интеграција ориентирана кон продукција која ги валидира доставените врски, го повикува resolver-от, го зачувува неговиот одговор без недокументирани претпоставки за шемата и го складира нормализираниот идентитет кај член на директориумот.

Добијте пристап пред да пишувате код за интеграција

Почнете од страницата на услугата Identity Resolver, каде што можете да ги прегледате информациите за услугата и плановите. Тековниот јавен endpoint не бара токен за сметка ниту API клуч.

Затоа, текот за пристап е намерно краток:

  1. Отворете ја официјалната документација за Identity Resolver.
  2. Прегледајте ги поддржаните платформи и облици на внес пред интеграцијата.
  3. Не создавајте, копирајте или конфигурирајте токен за тековниот јавен endpoint. Нема заглавие за авторизација што треба да се испрати.
  4. Ако сакате сметка за други услуги или управување со сметката, користете ја страницата за регистрација или страницата за најава. Ниту еден чекор не е потребен за ова барање до resolver-от.

Бидејќи за овој endpoint не постои акредитив, нема што да се копира во Symfony secrets или во датотека со променливи на околината. Ќе ја складираме само основната URL-адреса на услугата во конфигурација поддржана од околината. Ако официјалниот договор за автентикација се промени подоцна, следете ја документацијата наместо да нагаѓате шема за авторизација.

Потврдете го точниот endpoint

Resolver-от користи GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Испратете platform плус еден поддржан параметар username, id, identifier, profile или url. Нашиот директориум прифаќа врски, па ќе испраќа platform и url.

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

Во тоа барање не припаѓаат bearer токен, API клуч, cookie или приспособено заглавие за автентикација. Прегледајте го вратениот JSON објект, но не носете одлуки во апликацијата врз основа на полиња што не се гарантирани со официјалниот договор.

Предуслови и структура на проектот

Оваа имплементација претпоставува PHP 8.3 или понов, Symfony апликација со интеграции за HttpClient, Doctrine ORM, Monolog и PHPUnit, како и постоечки ентитет DirectoryMember. Инсталирајте ги компонентите од прва страна ако проектот сè уште не ги содржи:

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

Барањето останува синхроно бидејќи доставувањето во директориумот бара моментална повратна информација за валидацијата. Messenger би додал оперативна сложеност без да ја подобри оваа кратка интеракција. Ако нормализацијата подоцна стане масовно збогатување, би било разумно истиот клиент да се премести зад Messenger handler.

Релевантната структура на проектот е намерно компактна:

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

Конфигурирајте ги околината и dependency injection

Додајте ја несекретната основна URL-адреса во .env. Продукциското распоредување треба да ја замени преку околината на хостирањето, наместо со уредување на commit-ирани датотеки на апликацијата.

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

Експлицитно поврзете ја таа вредност за клиентот случајно да не прими неповрзан стринг-параметар:

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

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

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

Изградете дефанзивна API граница

Доставениот договор ветува нормализиран одговор за јавен идентитет, но не ни дава дозвола да претпоставуваме одредени полиња на одговорот. Затоа границата потврдува дека успешната содржина е JSON и го зачувува целиот објект како непрозирна асоцијативна низа. Кодот за прикажување и складирање може да еволуира кога ќе биде достапна документирана шема на одговорот.

Неуспесите се враќаат како состојби на доменот, наместо транспортните исклучоци да протекуваат во контролерот:

<?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 клиентот применува ограничени лимити за неактивност на поврзувањето и за вкупното времетраење. Повторува само привремени транспортни неуспеси, статус 429 и серверски грешки. Грешките на клиентот не се повторуваат слепо бидејќи повтореното невалидно барање останува невалидно.

<?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;
    }
}

Доставената URL-адреса намерно отсуствува од логовите. Јавните профили можеби не се тајни, но корисничките имиња и URL-адресите сè уште се податоци поврзани со корисниците и ретко помагаат при рутинска оперативна анализа.

Складирајте го нормализираниот идентитет

Додајте JSON колона во постоечкиот ентитет на членот, па генерирајте и извршете ја вообичаената Doctrine миграција. Обвивката во сопственост на апликацијата ја бележи избраната платформа и времето на разрешување; resolver го содржи одговорот непроменет.

<?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

Контролерот го валидира JSON, ги отфрла неподдржаните платформи и несовпаѓачките хостови, ја разрешува URL-адресата и зачувува само успешен резултат. Проверките на хостовите ги намалуваат случајните доставувања и спречуваат директориумот да стане генерички URL relay.

<?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));
    }
}

Вистинските апликации мора да ја авторизираат и измената на избраниот член. Додајте го voter-от или правилото за контрола на пристап на проектот пред да разрешите што било; познавањето идентификатор на база на податоци никогаш не смее да даде дозвола за уредување.

Тестирајте без да ја повикувате јавната услуга

MockHttpClient му дава на клиентот детерминистички одговори и им овозможува на тестовите да го потврдат излезниот договор. Fixtures подолу намерно не зависат од шемата: тврдењето докажува дека произволен JSON објект се зачувува, а не дека постои недокументирано поле.

<?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

Продукциски режими на неуспех и операции

Корисна интеграција разликува неуспеси што корисниците можат да ги поправат од неуспеси со кои мора да се справат операторите:

  • Невалиден JSON, платформа, URL-адреса или хост: вратете 400 или 422 без да го контактирате resolver-от.
  • Одбивање од resolver-от: вратете 422; не повторувајте автоматски грешка на клиентот.
  • Ограничување на стапката: почитувајте нумеричка вредност Retry-After во рамките на строг лимит од две секунди, а потоа вратете 429 откако ќе се исцрпи буџетот за повторување.
  • Истекување на времето или серверска грешка: повторете кратко и вратете 503 без да ги менувате складираните податоци за членот.
  • Неисправно обликуван успешен payload: третирајте го како неуспех од upstream. Никогаш не зачувувајте HTML, скаларен JSON или делумно декодирана содржина како идентитет.

Следете ги броевите по исход, платформа, HTTP статус и латентност. Алармирајте при трајни стапки на недостапност или неисправно обликувани одговори, наместо при поединечни неуспеси. Избегнувајте labels со висока кардиналност што содржат ID на членови, URL-адреси на профили или пораки за исклучоци.

На работ, применете ограничувања на големината на барањата и ограничување на стапката по корисник. Одржувајте ги Symfony и CA сертификатите ажурни, никогаш не ја оневозможувајте TLS верификацијата и осигурете се дека продукциските логови не можат да ги доловат целосните тела на барањата. Фиксниот основен URI ги спречува повикувачите да ја контролираат upstream дестинацијата, додека проверките на должината на URL-адресата и шемата го ограничуваат потенцијално злонамерниот влез.

Распоредете и проверете безбедно

Распоредете ги кодот и миграцијата заедно, користејќи го вообичаениот процес за изданија на проектот. Конфигурирајте IDENTITY_RESOLVER_BASE_URI во секоја околина, загрејте го продукцискиот cache, извршете ги миграциите еднаш и потврдете го излезниот HTTPS пристап до документираниот хост. Не е потребен чекор за вметнување тајна бидејќи endpoint-от во моментов не користи токен.

Завршете со оваа листа за проверка:

  • Документацијата и страницата на услугата се прегледани за тековните детали на договорот.
  • Минимално GET барање успева без заглавие за автентикација.
  • Тест-врските за Facebook, Instagram и LinkedIn стигнуваат до точниот endpoint на resolver-от.
  • Успешниот JSON се складира под точниот член на директориумот и платформа.
  • Невалидните хостови се одбиваат пред какво било надворешно барање.
  • Грешките на клиентот не се повторуваат, додека привремените неуспеси имаат ограничен буџет за повторување.
  • Неисправно обликуваните одговори и исцрпените повторувања ги оставаат постоечките идентитети непроменети.
  • Логовите содржат статус, обид, времетраење и платформа, но не и доставената URL-адреса на профилот.
  • Авторизацијата спречува еден член да ја уредува ставката во директориумот на друг член.

Нормализацијата е највредна кога се случува на границата. Штом секоја врска од социјална мрежа ќе влезе во директориумот како разрешен идентитет наместо како произволен стринг, остатокот од апликацијата станува помирен: складирањето е конзистентно, однесувањето при неуспех е експлицитно, а идните функционалности можат да се изградат врз една сигурна претстава наместо врз години наталожени варијации на URL-адреси.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.