Туториали

Symfony: Create Consistent Creator Profile Cards from Social Links with Identity Resolver API

Symfony: Креирајте конзистентни картички за профили на креатори од социјални врски со Identity Resolver API

Управувачот со контакти на креатори често започнува со неколку залепени врски од социјални мрежи. Набрзо, истото лице се појавува како URL на Instagram, идентификатор на LinkedIn и поинаку форматирана референца на Facebook. Базата на податоци можеби ги прифаќа сите три, но интерфејсот станува неконзистентен, а откривањето дупликати станува ненадежно.

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

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

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

  1. Прочитајте ги правилата за поддржаните платформи и идентификатори во документацијата.
  2. Прегледајте ја страницата за услугата и плановите за тековната достапност и информации за плановите.
  3. За оваа јавна крајна точка не е потребна регистрација. Ако подоцна се воведат функции за сметки, користете ја страницата на услугата како авторитативна почетна точка за навигација до регистрација и најава, наместо да погодувате необјавени URL-адреси за сметки.
  4. Не создавајте привремен токен и не испраќајте заглавие Authorization. Измислена акредитива може едноставно јавно барање да претвори во проблем при распоредување или со клуч за кеш.

Точниот повик е GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Испратете platform плус еден поддржан избирач: username, id, identifier, profile или url.

Направете го првиот тест со референца на јавен профил за која сте овластени да ја обработувате:

curl --get \
  --data-urlencode "platform=linkedin" \
  --data-urlencode "url=https://www.linkedin.com/in/YOUR_PUBLIC_PROFILE" \
  --header "Accept: application/json" \
  "https://ai.mihajlo.mk/api/identity-resolver/v1/resolve"

Нема акредитива за складирање. Ставете ја само конфигурибилната локација на услугата во непотврдената датотека .env.local на Symfony:

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

Ако во иднина се додаде автентикација, следете ја документацијата достапна во тој момент и ставете ја вистинската тајна во складиштето за тајни при распоредување, а не во потврдени датотеки со променливи на околината.

Изберете намерно мала архитектура

Оваа функционалност одговара на синхрон работен тек: корисник поднесува социјална референца и веднаш очекува картичка за преглед. Messenger би додал оперативна сложеност без да ја подобри таа интеракција. Подоцнежна функционалност за масовен увоз би можела асинхроно да разрешува идентитети, но треба да биде посебен случај на употреба.

Имплементацијата има четири граници:

  • Ограничен Symfony HTTP клиент управува со временските ограничувања и политиката за повторни обиди.
  • API клиент ги валидира параметрите и ги претвора мрежните исходи во експлицитни состојби на резултат.
  • Доменски мапер создава стабилен идентитет на картичка од нормализираниот одговор.
  • Контролерот управува со авторизацијата, CSRF-заштитата, внесот и презентацијата.

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

src/
  Controller/CreatorCardController.php
  Domain/CreatorProfileCard.php
  Integration/IdentityResolverClient.php
  Integration/IdentityResolverResult.php
templates/
  creator/card.html.twig
tests/
  Integration/IdentityResolverClientTest.php
config/packages/framework.yaml
.env.local

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

composer require symfony/http-client symfony/twig-bundle symfony/security-csrf
composer require --dev symfony/test-pack

Конфигурирајте ограничени повторни обиди и временски ограничувања

Интерактивно барање не треба да зафаќа PHP worker неограничено долго. Конфигурирајте ограничен клиент со кратко временско ограничување за поврзување, ограничување на вкупното траење и повторни обиди само за ограничување на барања и привремени серверски неуспеси:

# config/packages/framework.yaml
framework:
  http_client:
    scoped_clients:
      identity.client:
        base_uri: '%env(IDENTITY_RESOLVER_BASE_URI)%'
        timeout: 3
        max_duration: 8
        retry_failed:
          max_retries: 2
          delay: 250
          multiplier: 2
          http_codes: [429, 500, 502, 503, 504]

Оваа политика никогаш не прави повторни обиди за вообичаени одговори за валидација или неуспеси при автентикација. Повторниот обид со погрешно име на платформа само создава дополнителен сообраќај. Ограничен повторен обид за 429 или привремен одговор 5xx е разумен за идемпотентно GET барање, но апликацијата сепак треба да прикаже неуспех кога ќе се исцрпи буџетот за повторни обиди.

Изградете одбранбена API граница

Користете објект за резултат за очекуваните неуспеси нагоре по текот да не протекуваат низ апликацијата како лабаво класифицирани исклучоци:

<?php
// src/Integration/IdentityResolverResult.php
namespace App\Integration;

final readonly class IdentityResolverResult
{
    private function __construct(
        public bool $ok,
        public ?array $identity,
        public ?string $failure,
        public ?int $status,
        public ?string $retryAfter,
    ) {}

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

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

Клиентот прифаќа само избирачи дефинирани во договорот. Тој запишува еднонасочен отпечаток наместо поднесената вредност на профилот, спречувајќи јавното идентификатори непотребно да се копираат во централизирани дневници.

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

use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class IdentityResolverClient
{
    private const SELECTORS = [
        'username', 'id', 'identifier', 'profile', 'url',
    ];

    public function __construct(
        #[Autowire(service: 'identity.client')]
        private HttpClientInterface $http,
        private LoggerInterface $logger,
    ) {}

    public function resolve(
        string $platform,
        string $selector,
        string $value,
    ): IdentityResolverResult {
        $platform = strtolower(trim($platform));
        $value = trim($value);

        if ($platform === '' || $value === '' ||
            !in_array($selector, self::SELECTORS, true)) {
            return IdentityResolverResult::failure('invalid_input', 422);
        }

        $context = [
            'platform' => $platform,
            'selector' => $selector,
            'reference_hash' => substr(hash('sha256', $value), 0, 12),
        ];

        try {
            $response = $this->http->request('GET', 'v1/resolve', [
                'headers' => ['Accept' => 'application/json'],
                'query' => [
                    'platform' => $platform,
                    $selector => $value,
                ],
            ]);

            $status = $response->getStatusCode();
            $headers = $response->getHeaders(false);

            if ($status === 429) {
                $retryAfter = $headers['retry-after'][0] ?? null;
                $this->logger->warning(
                    'Identity Resolver rate limit reached.',
                    $context + ['status' => $status]
                );

                return IdentityResolverResult::failure(
                    'rate_limited',
                    $status,
                    $retryAfter,
                );
            }

            if ($status === 400 || $status === 404 || $status === 422) {
                return IdentityResolverResult::failure(
                    'reference_rejected',
                    $status,
                );
            }

            if ($status === 401 || $status === 403) {
                $this->logger->error(
                    'Unexpected authentication response from public resolver.',
                    $context + ['status' => $status]
                );

                return IdentityResolverResult::failure(
                    'upstream_authentication',
                    $status,
                );
            }

            if ($status < 200 || $status >= 300) {
                $this->logger->error(
                    'Identity Resolver request failed.',
                    $context + ['status' => $status]
                );

                return IdentityResolverResult::failure(
                    'upstream_unavailable',
                    $status,
                );
            }

            $body = $response->getContent(false);
            $payload = json_decode(
                $body,
                true,
                512,
                JSON_THROW_ON_ERROR,
            );

            if (!str_starts_with(ltrim($body), '{') ||
                !is_array($payload)) {
                return IdentityResolverResult::failure(
                    'malformed_response',
                    $status,
                );
            }

            return IdentityResolverResult::success($payload);
        } catch (\JsonException $exception) {
            $this->logger->error(
                'Identity Resolver returned invalid JSON.',
                $context + ['exception' => $exception::class]
            );

            return IdentityResolverResult::failure('malformed_response');
        } catch (TransportExceptionInterface $exception) {
            $this->logger->warning(
                'Identity Resolver transport failure.',
                $context + ['exception' => $exception::class]
            );

            return IdentityResolverResult::failure('transport_failure');
        }
    }
}

Кодот намерно не погодува полиња во нормализираниот објект за идентитет. Официјалниот одговор може да еволуира без тивко да мапира претпоставено својство во погрешно доменско поле.

Создадете стабилна доменска картичка

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

<?php
// src/Domain/CreatorProfileCard.php
namespace App\Domain;

final readonly class CreatorProfileCard
{
    public function __construct(
        public string $identityKey,
        public string $platform,
        public string $sourceReference,
        public array $identity,
    ) {}

    public static function fromResolvedIdentity(
        string $platform,
        string $sourceReference,
        array $identity,
    ): self {
        $canonical = self::sortRecursively($identity);
        $json = json_encode(
            $canonical,
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES,
        );

        return new self(
            hash('sha256', $json),
            strtolower($platform),
            $sourceReference,
            $identity,
        );
    }

    private static function sortRecursively(array $value): array
    {
        if (!array_is_list($value)) {
            ksort($value);
        }

        foreach ($value as $key => $item) {
            if (is_array($item)) {
                $value[$key] = self::sortRecursively($item);
            }
        }

        return $value;
    }
}

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

Поврзете го разрешувачот со управувачот со контакти

Контролерот користи POST бидејќи разрешувањето профил е дејство на апликацијата, иако неговиот повисок повик е GET. Заштитете го со автентикација во заштитниот ѕид на апликацијата и CSRF токен специфичен за формуларот.

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

use App\Domain\CreatorProfileCard;
use App\Integration\IdentityResolverClient;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class CreatorCardController extends AbstractController
{
    #[Route('/creator-cards/resolve', name: 'creator_card_resolve',
        methods: ['POST'])]
    public function resolve(
        Request $request,
        IdentityResolverClient $resolver,
    ): Response {
        $this->denyAccessUnlessGranted('ROLE_USER');

        if (!$this->isCsrfTokenValid(
            'resolve_creator',
            $request->request->getString('_token'),
        )) {
            return new Response('Invalid CSRF token.', 403);
        }

        $platform = $request->request->getString('platform');
        $selector = $request->request->getString('selector');
        $reference = $request->request->getString('reference');

        $result = $resolver->resolve(
            $platform,
            $selector,
            $reference,
        );

        if (!$result->ok) {
            $status = match ($result->failure) {
                'invalid_input', 'reference_rejected' => 422,
                'rate_limited' => 429,
                default => 503,
            };

            return $this->render('creator/card.html.twig', [
                'card' => null,
                'failure' => $result->failure,
                'retryAfter' => $result->retryAfter,
            ], new Response(status: $status));
        }

        $card = CreatorProfileCard::fromResolvedIdentity(
            $platform,
            $reference,
            $result->identity,
        );

        return $this->render('creator/card.html.twig', [
            'card' => $card,
            'failure' => null,
            'retryAfter' => null,
        ]);
    }
}

Прикажете ја секоја картичка со истата визуелна хиерархија. Автоматското избегнување на Twig мора да остане овозможено:

<article class="creator-card">
{% if card %}
  <h2>{{ card.platform|title }} creator</h2>
  <p>Source: {{ card.sourceReference }}</p>
  <p>Identity key: {{ card.identityKey }}</p>
  <ul>
  {% for name, value in card.identity %}
    <li>
      <strong>{{ name }}:</strong>
      {{ value is iterable ? value|json_encode : value }}
    </li>
  {% endfor %}
  </ul>
{% else %}
  <h2>Profile unavailable</h2>
  <p>Resolution state: {{ failure }}</p>
  {% if retryAfter %}
    <p>Retry after: {{ retryAfter }}</p>
  {% endif %}
{% endif %}
</article>

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

MockHttpClient ги прави конструкцијата на барањето и однесувањето на границата детерминистички. Празен JSON објект е доволен овде бидејќи тестот не смее да измислува недокументирани полиња од одговорот.

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

use App\Integration\IdentityResolverClient;
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 testItSendsTheSelectedReference(): 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('linkedin', $query['platform']);
                self::assertSame(
                    'https://example.test/public-profile',
                    $query['url'],
                );

                return new MockResponse('{}', ['http_code' => 200]);
            },
            'https://ai.mihajlo.mk/api/identity-resolver/',
        );

        $result = (new IdentityResolverClient(
            $http,
            new NullLogger(),
        ))->resolve(
            'linkedin',
            'url',
            'https://example.test/public-profile',
        );

        self::assertTrue($result->ok);
        self::assertSame([], $result->identity);
    }

    public function testItRejectsAnUnsupportedSelector(): void
    {
        $http = new MockHttpClient();
        $client = new IdentityResolverClient($http, new NullLogger());

        $result = $client->resolve('instagram', 'handle', 'creator');

        self::assertFalse($result->ok);
        self::assertSame('invalid_input', $result->failure);
        self::assertSame(0, $http->getRequestsCount());
    }

    public function testItRejectsANonObjectResponse(): void
    {
        $http = new MockHttpClient(
            new MockResponse('[]', ['http_code' => 200]),
        );

        $result = (new IdentityResolverClient(
            $http,
            new NullLogger(),
        ))->resolve('facebook', 'identifier', 'public-reference');

        self::assertFalse($result->ok);
        self::assertSame('malformed_response', $result->failure);
    }
}

Извршете го пакетот со php bin/phpunit. Додајте тест за контролерот за вашата вистинска конфигурација на заштитен ѕид и CSRF, бидејќи политиката за автентикација е специфична за апликацијата.

Безбедност, набљудливост и распоредување

Прифаќајте само петте документирани имиња на избирачи; никогаш не дозволувајте параметар од барање да стане произволен клуч за пребарување. Ограничете ја должината на внесот пред да ја повикате услугата, држете ја рутата зад автентикација, зачувајте го избегнувањето на Twig и применете Symfony RateLimiter на границата на апликацијата ако многу корисници можат да активираат разрешувања.

Не запишувајте целосни URL-адреси, кориснички имиња, тела на одговори или поднесени идентификатори. Корисни метрики се број на барања, латентност, класа на конечен статус, број на повторни обиди и име на структурираниот неуспех. Алармирајте при трајни неуспеси во транспортот, неправилно форматирани одговори или неочекувани одговори 401/403, наместо при изолиран одбиен профил.

При распоредување, внесете IDENTITY_RESOLVER_BASE_URI, загрејте го кешот на Symfony, извршете автоматизирани тестови и потврдете го појдовниот HTTPS пристап од PHP извршната околина. Не изведувајте API барање во живо при изградба на контејнер или загревање на кешот; привремена мрежна неволја не треба да направи инаку валиден артефакт за издание невозможно да се изгради.

Вообичаени неуспеси

  • Одбиена референца: потврдете ја платформата и користете еден документиран избирач, наместо да преведувате имиња на полиња во контролерот.
  • Ограничена стапка: почитувајте го конечниот 429, прикажете UI состојба што може повторно да се обиде и избегнувајте автоматско анкетирање од прелистувачот.
  • Неправилен JSON: задржете го структурираниот неуспех и истражете го однесувањето нагоре по текот без да го запишувате телото.
  • Истек на време: прикажете привремен неуспех; не ги продолжувајте временските ограничувања на worker-от додека бавните барања не изгледаат успешни.
  • Картички што изгледаат како дупликати: споредете ги нормализираните клучеви за идентитет, но задржете ги изворните референци за ревизибилност.

Контролна листа за конечна верификација

  • Апликацијата точно повикува GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve.
  • Секое барање содржи platform и точно еден поддржан параметар за референца.
  • Не се испраќа токен, фабрикувана акредитива или непотребно заглавие за авторизација.
  • Временските ограничувања и бројот на повторни обиди се ограничени, а неуспесите при валидација не се обидуваат повторно.
  • Непознатите податоци од одговорот се валидираат на API границата и безбедно се избегнуваат во картичката.
  • Тестовите користат MockHttpClient и никогаш не контактираат со продукциската крајна точка.
  • Дневниците содржат оперативен контекст без необработени референци на јавни профили или тела на одговори.

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

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

Mihajlo

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