Symfony: Нормализирајте ги социјалните врски со AI Identity Resolver
Полето за врски до социјални мрежи изгледа едноставно сè додека луѓето не вметнат мобилни URL-адреси, параметри за следење, алијаси на профили или идентификатори во неколку различни форми. Ако еден директориум на заедница ги складира тие поднесувања непроменети, дупликатите идентитети и кревките врски брзо продираат во пребарувањето, модерацијата и страниците на профили.
Овој туторијал гради Symfony крајна точка ориентирана кон продукциска употреба, која прифаќа врски до профили на Facebook, Instagram и LinkedIn, ја испраќа секоја до Identity Resolver и враќа стабилен објект на ниво на апликацијата. Дизајнот го задржува оддалечениот одговор непроменет наместо да нагаѓа недокументирани полиња, а притоа додава валидација, ограничени повторни обиди, структурирани неуспеси, логирање и детерминистички тестови.
Добијте пристап пред да напишете интеграциски код
Започнете од официјалната страница на услугата Identity Resolver, а потоа прочитајте ја официјалната документација. Тековната јавна крајна точка не бара токен за сметка ниту API клуч.
За оваа крајна точка, регистрација не е потребна и најава не е потребна. Овие врски намерно водат назад до авторитативната документација за пристап, наместо да сугерираат страница за сметка што не е дел од обезбедениот договор за воведување.
- Отворете ја документацијата и потврдете дека јавната крајна точка сè уште не бара автентикација.
- Не создавајте API клуч, не копирајте токен и не додавајте заглавие
Authorization. Во моментов нема чекор за копирање акредитиви. - Користете ги точниот метод и крајна точка:
GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. - Испратете
platformплус еден поддржан влезен параметар. Овој проект користиurl; договорот исто така дозволува поддржаниusername,id,identifierилиprofile.
Направете го првиот тест со вистинска URL-адреса на јавен профил што ви е дозволено да ја обработувате:
curl --get 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--data-urlencode 'platform=linkedin' \
--data-urlencode 'url=https://www.linkedin.com/in/replace-with-a-real-public-profile'
Ниту еден токен не припаѓа во таа команда. Одговорот е нормализираниот јавен идентитет. Бидејќи оваа статија не претпоставува полиња надвор од доставениот договор, апликацијата ќе потврди дека одговорот е непразен JSON објект и ќе го зачува како непрозирен товар на идентитет.
Нема акредитив што треба да се складира. Ставете ја само локацијата на услугата во .env.local, задржувајќи ја конфигурацијата специфична за распоредувањето надвор од изворниот код:
IDENTITY_RESOLVER_BASE_URI="https://ai.mihajlo.mk/api/identity-resolver"
Изберете намерно мала архитектура
Функцијата е синхрона: формуларот на директориумот поднесува до три врски, а корисникот добива нормализирани резултати пред профилот да биде зачуван. Тоа дава непосредна повратна информација и избегнува складирање непроверен влез. Messenger би бил корисен за масовни увози, но додава малку вредност за интерактивно барање со три врски.
Границата има четири одговорности:
- Контролерот валидира JSON, поддржани платформи, должина на URL, шема, порта и хост на социјалната мрежа.
- Клиентот на резолверот ја поседува точната оддалечена крајна точка, временските ограничувања, повторните обиди и обработката на HTTP статусите.
- Доменскиот објект обезбедува стабилна локална обвивка без да измислува шема на одговорот од услугата.
- Слојот за зачувување профили прифаќа само записи чиј локален статус е
resolved.
Доволна е компактна структура на проектот:
src/
Controller/SocialLinkController.php
Identity/IdentityResolver.php
Identity/IdentityResolverException.php
Identity/ResolvedIdentity.php
tests/
Identity/IdentityResolverTest.php
config/
services.yaml
Создадете го Symfony проектот
Користете PHP 8.3 или понов и одржувано издание на Symfony компатибилно со него. Нова апликација може да ги инсталира рамката, HTTP клиентот, интеграцијата за логирање и поддршката за тестови со Composer:
composer create-project symfony/skeleton community-directory
cd community-directory
composer require symfony/http-client symfony/monolog-bundle
composer require --dev symfony/test-pack
Ако директориумот веќе постои, инсталирајте ги само пакетите што недостигаат. Имплементацијата користи HttpClientInterface, инјектирање преку конструктор, рути со атрибути и MockHttpClient; не ѝ е потребна посебна HTTP библиотека.
Дефинирајте стабилна доменска граница
Оддалечениот објект не треба да се шири низ контролери, шаблони и код за базата на податоци. Завиткајте го во обвивка во сопственост на апликацијата, задржувајќи го целосниот нормализиран идентитет.
<?php
// src/Identity/ResolvedIdentity.php
namespace App\Identity;
final readonly class ResolvedIdentity
{
public function __construct(
public string $platform,
public string $submittedUrl,
public array $identity,
) {
}
public function toArray(): array
{
return [
'platform' => $this->platform,
'submitted_url' => $this->submittedUrl,
'identity' => $this->identity,
];
}
}
<?php
// src/Identity/IdentityResolverException.php
namespace App\Identity;
final class IdentityResolverException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $upstreamStatus = null,
) {
parent::__construct($message);
}
}
Вредноста kind е внатрешна класификација на неуспех, а не поле од одговорот на надворешната услуга. Таа му овозможува на контролерот да разликува ограничување на стапката, проблеми со транспортот, невалидни одговори и отфрлени барања без да им ги открива оддалечените тела на корисниците.
Изградете отпорен HTTP клиент
Клиентот ја фиксира дестинацијата и прифаќа само вредности за платформа и URL. Поднесената URL-адреса станува кодиран параметар на барањето; таа никогаш не го контролира излезниот хост. Три обиди покриваат преодни грешки во транспортот, HTTP 429 и грешки на серверот. Другите 4xx одговори не се обидуваат повторно бидејќи повторувањето на невалидно барање троши капацитет.
<?php
// src/Identity/IdentityResolver.php
namespace App\Identity;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class IdentityResolver
{
public function __construct(
private readonly HttpClientInterface $http,
private readonly LoggerInterface $logger,
private readonly string $baseUri,
private readonly int $maxAttempts = 3,
) {
}
public function resolve(string $platform, string $url): ResolvedIdentity
{
$endpoint = rtrim($this->baseUri, '/').'/v1/resolve';
for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
try {
$response = $this->http->request('GET', $endpoint, [
'query' => [
'platform' => $platform,
'url' => $url,
],
'timeout' => 3.0,
'max_duration' => 8.0,
'headers' => [
'Accept' => 'application/json',
],
]);
$status = $response->getStatusCode();
$headers = $response->getHeaders(false);
} catch (TransportExceptionInterface $exception) {
$this->logger->warning('Identity Resolver transport failure', [
'platform' => $platform,
'attempt' => $attempt,
]);
if ($attempt === $this->maxAttempts) {
throw new IdentityResolverException(
'transport',
'The identity service could not be reached.',
);
}
$this->pause([], $attempt);
continue;
}
if ($status === 429 || $status >= 500) {
$this->logger->warning('Identity Resolver transient response', [
'platform' => $platform,
'attempt' => $attempt,
'status' => $status,
]);
if ($attempt === $this->maxAttempts) {
throw new IdentityResolverException(
$status === 429 ? 'rate_limited' : 'upstream_unavailable',
'The identity service is temporarily unavailable.',
$status,
);
}
$this->pause($headers, $attempt);
continue;
}
if ($status < 200 || $status >= 300) {
$this->logger->notice('Identity Resolver rejected a request', [
'platform' => $platform,
'status' => $status,
]);
throw new IdentityResolverException(
'upstream_rejected',
'The submitted identity could not be resolved.',
$status,
);
}
try {
$payload = $response->toArray(false);
} catch (\Throwable $exception) {
throw new IdentityResolverException(
'invalid_response',
'The identity service returned invalid JSON.',
$status,
);
}
if ($payload === []) {
throw new IdentityResolverException(
'invalid_response',
'The identity service returned an empty identity.',
$status,
);
}
return new ResolvedIdentity($platform, $url, $payload);
}
throw new \LogicException('The retry loop terminated unexpectedly.');
}
private function pause(array $headers, int $attempt): void
{
$retryAfter = $headers['retry-after'][0] ?? null;
if (is_string($retryAfter) && ctype_digit($retryAfter)) {
$seconds = min(2.0, (float) $retryAfter);
} else {
$seconds = min(0.6, 0.15 * (2 ** ($attempt - 1)));
}
usleep((int) ($seconds * 1_000_000));
}
}
Повлекувањето е намерно ограничено. Синхроното поднесување формулар не треба да виси за произволен интервал Retry-After. Откако ќе се исцрпи буџетот за повторни обиди, апликацијата враќа структуриран неуспех и му дозволува на корисникот да се обиде повторно подоцна.
Поврзете конфигурација поддржана од околината
Централно поврзете ја основната URI-адреса. Не додавајте токен-заменка или заглавие за авторизација: тоа погрешно би го претставило тековниот јавен договор за автентикација.
# config/services.yaml
parameters:
identity_resolver.base_uri: '%env(IDENTITY_RESOLVER_BASE_URI)%'
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Identity\IdentityResolver:
arguments:
$baseUri: '%identity_resolver.base_uri%'
Нормализирајте поднесување во директориумот
Контролерот прифаќа објект links што содржи една или повеќе поддржани платформи. Валидацијата се случува пред каков било надворешен повик, така што една неправилно форматирана врска не може да произведе делумно валидирано поднесување.
<?php
// src/Controller/SocialLinkController.php
namespace App\Controller;
use App\Identity\IdentityResolver;
use App\Identity\IdentityResolverException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class SocialLinkController
{
private const DOMAINS = [
'facebook' => 'facebook.com',
'instagram' => 'instagram.com',
'linkedin' => 'linkedin.com',
];
#[Route(
'/api/directory/social-links/normalize',
name: 'directory_social_links_normalize',
methods: ['POST']
)]
public function __invoke(
Request $request,
IdentityResolver $resolver,
): JsonResponse {
try {
$body = $request->toArray();
} catch (\JsonException) {
return new JsonResponse(['error' => 'invalid_json'], 400);
}
$links = $body['links'] ?? null;
if (!is_array($links) || $links === []) {
return new JsonResponse(['error' => 'links_required'], 422);
}
$unknown = array_diff(array_keys($links), array_keys(self::DOMAINS));
if ($unknown !== []) {
return new JsonResponse([
'error' => 'unsupported_platform',
'platforms' => array_values($unknown),
], 422);
}
foreach ($links as $platform => $url) {
if (!$this->validUrl($platform, $url)) {
return new JsonResponse([
'error' => 'invalid_social_url',
'platform' => $platform,
], 422);
}
}
$results = [];
$failures = 0;
$onlyRateLimits = true;
foreach ($links as $platform => $url) {
try {
$results[$platform] = [
'status' => 'resolved',
'value' => $resolver->resolve($platform, $url)->toArray(),
];
} catch (IdentityResolverException $exception) {
$failures++;
$onlyRateLimits = $onlyRateLimits
&& $exception->kind === 'rate_limited';
$results[$platform] = [
'status' => 'failed',
'error' => $exception->kind,
];
}
}
if ($failures === count($links)) {
return new JsonResponse(
['results' => $results],
$onlyRateLimits ? 429 : 503,
);
}
return new JsonResponse(['results' => $results]);
}
private function validUrl(string $platform, mixed $value): bool
{
if (!is_string($value) || $value === '' || strlen($value) > 2048) {
return false;
}
if (filter_var($value, FILTER_VALIDATE_URL) === false) {
return false;
}
$scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
$host = strtolower((string) parse_url($value, PHP_URL_HOST));
$port = parse_url($value, PHP_URL_PORT);
$user = parse_url($value, PHP_URL_USER);
$pass = parse_url($value, PHP_URL_PASS);
$base = self::DOMAINS[$platform];
$hostAllowed = $host === $base
|| str_ends_with($host, '.'.$base);
return $scheme === 'https'
&& $hostAllowed
&& ($port === null || $port === 443)
&& $user === null
&& $pass === null;
}
}
Формуларот сега може да ги поднесе сите три мрежи во едно барање. Зачувајте ја само секоја value чиј статус е resolved; задржете го вратениот објект на идентитет како JSON ако договорот на услугата не дефинира потесни полиња од кои вашиот домен може безбедно да зависи.
curl 'https://directory.example/api/directory/social-links/normalize' \
--request POST \
--header 'Content-Type: application/json' \
--data '{
"links": {
"facebook": "https://www.facebook.com/replace-with-a-real-profile",
"instagram": "https://www.instagram.com/replace-with-a-real-profile",
"linkedin": "https://www.linkedin.com/in/replace-with-a-real-profile"
}
}'
Тестирајте повторни обиди и мапирање на границата
MockHttpClient ги прави тестовите детерминистички и спречува случајни мрежни повици. Успешната поставка подолу е намерно непрозирна; нејзиниот клуч само за пример не е претставен како дел од вистинската API шема.
<?php
// tests/Identity/IdentityResolverTest.php
namespace App\Tests\Identity;
use App\Identity\IdentityResolver;
use App\Identity\IdentityResolverException;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class IdentityResolverTest extends TestCase
{
public function testMapsSuccessfulIdentityWithoutGuessingFields(): void
{
$client = new MockHttpClient([
new MockResponse('{"example-only":"opaque-value"}', [
'http_code' => 200,
'response_headers' => ['content-type: application/json'],
]),
]);
$resolver = new IdentityResolver(
$client,
new NullLogger(),
'https://ai.mihajlo.mk/api/identity-resolver',
1,
);
$result = $resolver->resolve(
'instagram',
'https://www.instagram.com/public-profile',
);
self::assertSame('instagram', $result->platform);
self::assertSame(
['example-only' => 'opaque-value'],
$result->identity,
);
}
public function testRetriesServerFailureThenSucceeds(): void
{
$client = new MockHttpClient([
new MockResponse('', ['http_code' => 503]),
new MockResponse('{"example-only":"resolved"}', [
'http_code' => 200,
]),
]);
$resolver = new IdentityResolver(
$client,
new NullLogger(),
'https://ai.mihajlo.mk/api/identity-resolver',
2,
);
self::assertSame(
['example-only' => 'resolved'],
$resolver->resolve(
'facebook',
'https://www.facebook.com/public-profile',
)->identity,
);
}
public function testDoesNotRetryRejectedRequest(): void
{
$calls = 0;
$client = new MockHttpClient(
function () use (&$calls): MockResponse {
$calls++;
return new MockResponse('', ['http_code' => 400]);
}
);
$resolver = new IdentityResolver(
$client,
new NullLogger(),
'https://ai.mihajlo.mk/api/identity-resolver',
3,
);
try {
$resolver->resolve(
'linkedin',
'https://www.linkedin.com/in/public-profile',
);
self::fail('An exception was expected.');
} catch (IdentityResolverException $exception) {
self::assertSame('upstream_rejected', $exception->kind);
self::assertSame(1, $calls);
}
}
}
Извршете го пакетот со php bin/phpunit. Додајте тестови за контролерот за правилата за автентикација и сопственост на вашата апликација, бидејќи тие политики му припаѓаат на директориумот, а не на клиентот на резолверот.
Безбедност и оперативна дисциплина
Заштитете ја рутата со истата автентикација и авторизација што се користи за уредување профил на заедницата. Најавен член не смее да може да нормализира или зачувува врски за записот на друг член. Ако сесиите во прелистувачот го автентицираат барањето, применете ја и CSRF стратегијата на апликацијата.
Дозволената листа на хостови ги намалува неправилно форматираните поднесувања и измамничките домени. Фиксната основна URI-адреса спречува фалсификување барања од страна на серверот: корисничкиот влез останува податок на барањето и никогаш не станува дестинација. Применете ограничувања на стапката на барања во апликацијата или на рабната инфраструктура, ограничете ја големината на JSON телото и избегнувајте логирање поднесени URL-адреси бидејќи корисничките имиња може да бидат лични податоци.
Доставениот договор за автентикација денес е јавен. Ако се промени, додајте ја акредитивата во складиштето на тајни за распоредување и изложете ја преку Symfony параметар поддржан од околината. Никогаш не ја предавајте во изворниот код, не ја враќајте во грешки и не ја ставајте во поставки.
Корисните логови содржат платформа, обид, статус и вид на внатрешен неуспех. Поставете известувања за трајни зголемувања на rate_limited, transport или invalid_response. Не поставувајте известување за единечен отфрлен профил; тоа обично е прашање на влез или поддршка, а не прекин на услугата.
Распоредување и вообичаени режими на неуспех
Поставете IDENTITY_RESOLVER_BASE_URI во секоја извршна околина, вклучително и во дефинициите на работник или контејнер, иако оваа верзија е синхрона. Распоредете со вообичаените продукциски команди:
composer install --no-dev --classmap-authoritative
php bin/console cache:clear --env=prod
php bin/console cache:warmup --env=prod
php bin/console debug:router directory_social_links_normalize
- Секоја URL-адреса враќа 422: потврдете дека врската користи HTTPS и дека хостот навистина ѝ припаѓа на избраната платформа.
- Услугата враќа 4xx: проверете ги платформата и URL-адресата користејќи ја официјалната документација. Клиентот правилно избегнува повторни обиди за овие барања.
- Барањата завршуваат како 429: намалете ја честотата на поднесување и обидете се повторно подоцна. Не го зголемувајте синхроното спиење на неодредено време.
- Повремени одговори 503: прегледајте ги структурираните логови и достапноста на надворешната услуга. Ограничениот повторен обид веќе покрива кратки прекини.
- Одговорите стануваат
invalid_response: оперативно забележете ги статусот и типот на содржина, но не го изложувајте и не го логирајте неселективно телото на одговорот. - Локалните барања работат, но продукцијата не успева: проверете го излезниот HTTPS пристап, DNS, доверливите издавачи на сертификати и променливата на продукциската околина.
Конечна контролна листа за проверка
- Апликацијата повикува точно
GET /api/identity-resolver/v1/resolveсоplatformиurl. - Не се испраќа токен, API клуч или измислено заглавие за авторизација.
- Врските на Facebook, Instagram и LinkedIn се валидираат пред мрежниот пристап.
- Неактивноста на врската и вкупното времетраење на барањето се ограничени.
- Само грешки во транспортот, HTTP 429 и грешки на серверот се обидуваат повторно.
- Нормализираниот јавен идентитет се зачувува без претпоставка за недокументирани полиња.
- Делумните неуспеси се изречни, а неразрешените идентитети не се зачувуваат.
- Тестовите користат
MockHttpClientи не прават вистински надворешни барања. - Логовите содржат оперативен контекст, но ги исклучуваат целосните URL-адреси на профили и телата на одговорите.
Важниот резултат не се само почисти врски. Тој е доверлива граница меѓу непредвидливиот човечки влез и долготрајните податоци на директориумот. Штом таа граница рано валидира, селективно се обидува повторно, дефанзивно мапира и видливо не успева, социјалните профили престануваат да бидат кревки низи и стануваат идентитети што остатокот од апликацијата може безбедно да ги користи.