Туториали

Symfony Onboarding: Verify Public Social Profiles with Identity Resolver

Вклучување во Symfony: Потврдете ги јавните профили на социјалните мрежи со Identity Resolver

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

Оваа Symfony имплементација го разрешува тој внес во нормализиран јавен идентитет, ја складира оригиналната референца заедно со одговорот и намерно запира на pending_review. Човечки рецензент — не надворешен API — ја носи конечната одлука за прифаќање.

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

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

Тековната јавна крајна точка бара ниту токен за сметка ниту API клуч. Затоа нема акредитив што треба да се копира пред вашето прво барање. Регистрацијата и најавувањето не се предуслови за оваа крајна точка; ако сакате сметка за други услуги, користете ги влезните точки за регистрација или најава достапни преку официјалната страница на услугата, наместо да нагаѓате директни URL-адреси за сметки.

  1. Отворете ја документацијата и потврдете ги поддржаните платформи и типови референци.
  2. Прегледајте ја страницата на услугата за тековната достапност и информации за плановите.
  3. Не создавајте привремено заглавие за авторизација. Крајната точка моментално прифаќа јавни барања без него.
  4. Направете мало тест-барање пред да ја интегрирате во воведувањето.

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

curl --get \
  'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
  --data-urlencode 'platform=instagram' \
  --data-urlencode 'username=example_public_profile'

Не ставајте измислен токен во Symfony конфигурацијата. Складирајте само основен URI на крајната точка. Ако автентикацијата биде воведена во иден документиран договор, тогаш додадете ја нејзината вистинска акредитива преку тајна поддржана од променлива на околина.

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

Архитектура: разреши, зачувај, па прегледај

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

  1. Контролерот ги валидира платформата и обликот на референцата.
  2. Посветен клиент повикува Identity Resolver и валидира дека одговорот е JSON објект.
  3. Апликацијата ги складира и поднесената референца и недопрената нормализирана содржина на одговорот.
  4. Овластен рецензент го прегледува записот и го прифаќа или одбива.

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

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

src/
  Controller/OnboardingProfileController.php
  Entity/OnboardingProfile.php
  Identity/IdentityResolverClient.php
  Identity/IdentityResolverFailure.php
  Identity/ResolvedPublicIdentity.php
tests/
  Identity/IdentityResolverClientTest.php
config/
  services.yaml

Инсталирајте ги и конфигурирајте ги Symfony компонентите

Ова упатство претпоставува Symfony апликација што работи со PHP 8.3 или понова верзија, со веќе конфигуриран нејзин стандарден безбедносен систем. Додадете ги HTTP клиентот од прва страна, Doctrine интеграцијата, поддршката за валидација и алатките за тестирање:

composer require symfony/http-client symfony/orm-pack symfony/validator
composer require --dev symfony/test-pack

Поврзете ја вредноста од околината според името на аргументот. Тука не припаѓаат ниту заглавие за авторизација ниту параметар за акредитиви.

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

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

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

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

Услугата ги нормализира јавните Facebook, Instagram и LinkedIn референци во стабилен објект за идентитет. Апликацијата не треба да нагаѓа недокументирани полиња на одговорот. Наместо тоа, таа го валидира JSON обликот на највисоко ниво и го зачувува нормализираниот објект како што е примен.

Локалниот отпечаток подолу е помош за ревизија во апликацијата, а не поле за кое се тврди дека го обезбедува услугата. Сортирањето на клучевите на објектот пред хеширање спречува безопасниот редослед на JSON клучевите да произведе различен отпечаток.

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

final readonly class ResolvedPublicIdentity
{
    public function __construct(
        public string $platform,
        public string $referenceType,
        public string $referenceValue,
        public array $normalized,
    ) {}

    public function fingerprint(): string
    {
        $value = $this->normalized;
        self::sortRecursively($value);

        return hash('sha256', json_encode(
            $value,
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
        ));
    }

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

        foreach ($value as &$child) {
            if (is_array($child)) {
                self::sortRecursively($child);
            }
        }
    }
}

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

final class IdentityResolverFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $reason,
        public readonly ?int $upstreamStatus,
        public readonly bool $retryable,
    ) {
        parent::__construct($reason);
    }
}

HTTP клиентот прифаќа само документирани платформи и референтни параметри. Тој повторно се обидува со транспортни неуспеси, ограничување на стапката и неуспеси на серверот најмногу двапати по првичното барање. Не се обидува повторно со други 4xx одговори бидејќи непроменувањето ниту на внесот ниту на состојбата на автентикација ги прави таквите повторни обиди залудни.

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

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

final class IdentityResolverClient
{
    private const PLATFORMS = ['facebook', 'instagram', 'linkedin'];
    private const REFERENCES = ['username', 'id', 'identifier', 'profile', 'url'];

    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $identityResolverBaseUri,
    ) {}

    public function resolve(
        string $platform,
        string $referenceType,
        string $referenceValue,
    ): ResolvedPublicIdentity {
        $platform = strtolower(trim($platform));
        $referenceType = strtolower(trim($referenceType));
        $referenceValue = trim($referenceValue);

        if (!in_array($platform, self::PLATFORMS, true)) {
            throw new \InvalidArgumentException('Unsupported platform.');
        }
        if (!in_array($referenceType, self::REFERENCES, true)) {
            throw new \InvalidArgumentException('Unsupported reference type.');
        }
        if ($referenceValue === '') {
            throw new \InvalidArgumentException('The profile reference is required.');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('GET',
                    rtrim($this->identityResolverBaseUri, '/').'/resolve',
                    [
                        'query' => [
                            'platform' => $platform,
                            $referenceType => $referenceValue,
                        ],
                        'timeout' => 8.0,
                        'max_duration' => 12.0,
                    ]
                );
                $status = $response->getStatusCode();
                $body = $response->getContent(false);
            } catch (TransportExceptionInterface $exception) {
                if ($attempt < 3) {
                    $this->warn($platform, $attempt, null, 'transport');
                    usleep(200_000 * $attempt);
                    continue;
                }

                throw new IdentityResolverFailure('transport_failure', null, true);
            }

            if (($status === 429 || $status >= 500) && $attempt < 3) {
                $this->warn($platform, $attempt, $status, 'retryable_response');
                $headers = $response->getHeaders(false);
                $retryAfter = (int) ($headers['retry-after'][0] ?? 0);
                $delay = $retryAfter > 0
                    ? min($retryAfter, 2) * 1_000_000
                    : 200_000 * $attempt;
                usleep($delay);
                continue;
            }

            if ($status >= 400) {
                throw new IdentityResolverFailure(
                    'upstream_rejected_request',
                    $status,
                    $status === 429 || $status >= 500,
                );
            }

            try {
                $payload = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
            } catch (\JsonException) {
                throw new IdentityResolverFailure('invalid_json_response', $status, false);
            }

            if (!is_array($payload) || array_is_list($payload)) {
                throw new IdentityResolverFailure('invalid_identity_shape', $status, false);
            }

            return new ResolvedPublicIdentity(
                $platform,
                $referenceType,
                $referenceValue,
                $payload,
            );
        }

        throw new IdentityResolverFailure('retry_exhausted', null, true);
    }

    private function warn(
        string $platform,
        int $attempt,
        ?int $status,
        string $reason,
    ): void {
        $this->logger->warning('Identity resolution attempt failed.', [
            'platform' => $platform,
            'attempt' => $attempt,
            'upstream_status' => $status,
            'reason' => $reason,
        ]);
    }
}

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

Зачувајте експлицитна состојба на преглед

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

<?php
// src/Entity/OnboardingProfile.php
namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class OnboardingProfile
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 20)]
    private string $platform;

    #[ORM\Column(length: 20)]
    private string $referenceType;

    #[ORM\Column(length: 2048)]
    private string $referenceValue;

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

    #[ORM\Column(length: 64)]
    private string $fingerprint;

    #[ORM\Column(length: 20)]
    private string $status = 'pending_review';

    #[ORM\Column(length: 180)]
    private string $submittedBy;

    #[ORM\Column(length: 180, nullable: true)]
    private ?string $reviewedBy = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $reviewedAt = null;

    public function __construct(
        string $platform,
        string $referenceType,
        string $referenceValue,
        array $normalized,
        string $fingerprint,
        string $submittedBy,
    ) {
        $this->platform = $platform;
        $this->referenceType = $referenceType;
        $this->referenceValue = $referenceValue;
        $this->normalized = $normalized;
        $this->fingerprint = $fingerprint;
        $this->submittedBy = $submittedBy;
    }

    public function getId(): ?int { return $this->id; }
    public function getStatus(): string { return $this->status; }
    public function getNormalized(): array { return $this->normalized; }

    public function review(string $decision, string $reviewer): void
    {
        if ($this->status !== 'pending_review') {
            throw new \LogicException('This profile has already been reviewed.');
        }
        if (!in_array($decision, ['accepted', 'rejected'], true)) {
            throw new \InvalidArgumentException('Invalid review decision.');
        }

        $this->status = $decision;
        $this->reviewedBy = $reviewer;
        $this->reviewedAt = new \DateTimeImmutable();
    }
}

Изложете крајни точки за увоз и само за рецензенти

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

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

use App\Entity\OnboardingProfile;
use App\Identity\IdentityResolverClient;
use App\Identity\IdentityResolverFailure;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class OnboardingProfileController extends AbstractController
{
    #[Route('/onboarding/social-profile', methods: ['POST'])]
    #[IsGranted('ROLE_USER')]
    public function import(
        Request $request,
        IdentityResolverClient $resolver,
        EntityManagerInterface $em,
    ): JsonResponse {
        try {
            $input = $request->toArray();
            $identity = $resolver->resolve(
                (string) ($input['platform'] ?? ''),
                (string) ($input['reference_type'] ?? ''),
                (string) ($input['reference_value'] ?? ''),
            );
        } catch (\JsonException|\InvalidArgumentException $exception) {
            return $this->json(['error' => 'invalid_request'], 422);
        } catch (IdentityResolverFailure $exception) {
            return $this->json(
                ['error' => $exception->reason],
                $exception->retryable ? 503 : 422,
            );
        }

        $record = new OnboardingProfile(
            $identity->platform,
            $identity->referenceType,
            $identity->referenceValue,
            $identity->normalized,
            $identity->fingerprint(),
            $this->getUser()->getUserIdentifier(),
        );

        $em->persist($record);
        $em->flush();

        return $this->json([
            'id' => $record->getId(),
            'status' => $record->getStatus(),
        ], 201);
    }

    #[Route('/review/social-profile/{id}', methods: ['GET'])]
    #[IsGranted('ROLE_PROFILE_REVIEWER')]
    public function show(int $id, EntityManagerInterface $em): JsonResponse
    {
        $record = $em->find(OnboardingProfile::class, $id);

        if (!$record) {
            throw $this->createNotFoundException();
        }

        return $this->json([
            'id' => $record->getId(),
            'status' => $record->getStatus(),
            'normalized' => $record->getNormalized(),
        ]);
    }

    #[Route('/review/social-profile/{id}', methods: ['POST'])]
    #[IsGranted('ROLE_PROFILE_REVIEWER')]
    public function review(
        int $id,
        Request $request,
        EntityManagerInterface $em,
    ): JsonResponse {
        $record = $em->find(OnboardingProfile::class, $id);

        if (!$record) {
            throw $this->createNotFoundException();
        }

        try {
            $record->review(
                (string) ($request->toArray()['decision'] ?? ''),
                $this->getUser()->getUserIdentifier(),
            );
            $em->flush();
        } catch (\JsonException|\InvalidArgumentException|\LogicException) {
            return $this->json(['error' => 'invalid_review'], 409);
        }

        return $this->json(['status' => $record->getStatus()]);
    }
}

За сесии во прелистувач, применете CSRF заштита на двете рути што ја менуваат состојбата. Исто така, наметнете граници за закупецот или сопственоста покрај улогите; рецензентот не смее да добие пристап до неповрзани организации само со менување идентификатор во URL-адресата.

Тестирајте го транспортот детерминистички

MockHttpClient ја тестира конструкцијата на барањата без мрежна зависност. Вториот тест докажува дека неуспех од добавувачот во стил на валидација не се повторува слепо.

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

use App\Identity\IdentityResolverClient;
use App\Identity\IdentityResolverFailure;
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 testItMapsAResolvedIdentity(): void
    {
        $http = new MockHttpClient(
            function (string $method, string $url): MockResponse {
                self::assertSame('GET', $method);
                self::assertStringContainsString(
                    'platform=instagram',
                    $url
                );
                self::assertStringContainsString(
                    'username=example_public_profile',
                    $url
                );

                return new MockResponse(
                    '{"identity":{"display":"Example"}}',
                    ['http_code' => 200]
                );
            }
        );

        $client = new IdentityResolverClient(
            $http,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/identity-resolver/v1'
        );

        $result = $client->resolve(
            'instagram',
            'username',
            'example_public_profile'
        );

        self::assertSame('instagram', $result->platform);
        self::assertSame(
            ['identity' => ['display' => 'Example']],
            $result->normalized
        );
    }

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

                return new MockResponse(
                    '{"error":"invalid"}',
                    ['http_code' => 400]
                );
            }
        );

        $client = new IdentityResolverClient(
            $http,
            new NullLogger(),
            'https://ai.mihajlo.mk/api/identity-resolver/v1'
        );

        try {
            $client->resolve('linkedin', 'url', 'not-a-valid-url');
            self::fail('Expected an IdentityResolverFailure.');
        } catch (IdentityResolverFailure $failure) {
            self::assertSame('upstream_rejected_request', $failure->reason);
            self::assertSame(1, $calls);
        }
    }
}

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

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

Генерирајте ја миграцијата локално, проверете го нејзиниот SQL, извршете го пакетот тестови и применете ја прегледаната миграција при распоредувањето:

php bin/console doctrine:migrations:diff
php bin/phpunit
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear

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

Вообичаените продукциски неуспеси се предвидливи: неподдржан правопис на платформа треба локално да не успее; погрешно форматирани референци треба да вратат 422 одговор што може да се прегледа; одговорите 429 и 5xx може да добијат ограничени повторни обиди пред да станат 503; невалидниот JSON никогаш не смее да се складира; а двоен преглед мора да врати конфликт наместо тивко да ја замени првичната одлука.

Конечна контролна листа за проверка

  • Апликацијата ја повикува документираната крајна точка со GET.
  • Точно еден поддржан референтен параметар го придружува platform.
  • Не се испраќа токен, API клуч или измислено заглавие за авторизација.
  • Времето за поврзување и вкупното време на одговор се ограничени.
  • Се повторуваат само минливи транспортни неуспеси, 429 и 5xx неуспеси.
  • Одговорот се валидира како JSON објект без претпоставка за недокументирани полиња.
  • Секој увезен профил започнува во pending_review.
  • Само овластени рецензенти можат да ги прегледуваат и да одлучуваат за записите.
  • Тестовите користат MockHttpClient наместо активната услуга.
  • Дневниците и метриките ги исклучуваат вредностите на профилите и нормализираните содржини.

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

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

Mihajlo

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