Symfony: Normalizirajte društvene poveznice pomoću AI Identity Resolvera
Polje za poveznicu na društvenu mrežu izgleda jednostavno sve dok ljudi ne zalijepe mobilne URL-ove, parametre za praćenje, pseudonime profila ili identifikatore u nekoliko različitih oblika. Ako imenik zajednice takve prijave pohranjuje nepromijenjene, dvostruki identiteti i nepouzdane poveznice brzo završe u pretraživanju, moderiranju i stranicama profila.
Ovaj vodič izrađuje Symfony endpoint spreman za produkciju koji prihvaća poveznice na profile na Facebooku, Instagramu i LinkedInu, šalje svaku od njih alatu Identity Resolver te vraća stabilan objekt na razini aplikacije. Dizajn zadržava udaljeni odgovor netaknutim umjesto nagađanja nedokumentiranih polja, uz dodavanje validacije, ograničenih ponovnih pokušaja, strukturiranih neuspjeha, zapisivanja i determinističkih testova.
Dobijte pristup prije pisanja integracijskog koda
Počnite na službenoj stranici usluge Identity Resolver, zatim pročitajte službenu dokumentaciju. Trenutačni javni endpoint ne zahtijeva token računa ni API ključ.
Za ovaj endpoint registracija nije potrebna i prijava nije potrebna. Te poveznice namjerno vraćaju na mjerodavnu dokumentaciju o pristupu umjesto da upućuju na stranicu računa koja nije dio zadanog ugovora za uvođenje.
- Otvorite dokumentaciju i potvrdite da javni endpoint i dalje ne zahtijeva autentikaciju.
- Ne stvarajte API ključ, ne kopirajte token i ne dodajte zaglavlje
Authorization. Trenutačno ne postoji korak kopiranja vjerodajnica. - Upotrijebite točnu metodu i endpoint:
GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. - Pošaljite
platformuz jedan podržani ulazni parametar. Ovaj projekt koristiurl; ugovor dopušta i podržaniusername,id,identifieriliprofile.
Prvi test izvedite sa stvarnim javnim URL-om profila za koji imate dopuštenje obrade:
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'
Tom naredbom ne pripada nikakav token. Odgovor je normalizirani javni identitet. Budući da ovaj članak ne pretpostavlja polja izvan zadanog ugovora, aplikacija će provjeriti da je odgovor neprazan JSON objekt i sačuvati ga kao neprozirni sadržaj identiteta.
Nema vjerodajnica za pohranu. U .env.local stavite samo lokaciju usluge, držeći konfiguraciju specifičnu za implementaciju izvan izvornog koda:
IDENTITY_RESOLVER_BASE_URI="https://ai.mihajlo.mk/api/identity-resolver"
Odaberite namjerno malu arhitekturu
Značajka je sinkrona: obrazac imenika šalje do tri poveznice, a korisnik prima normalizirane rezultate prije spremanja profila. To daje trenutačnu povratnu informaciju i izbjegava pohranjivanje neprovjerenog unosa. Messenger bi bio koristan za skupne uvoze, ali malo dodaje interaktivnom zahtjevu s tri poveznice.
Granica ima četiri odgovornosti:
- Kontroler provjerava JSON, podržane platforme, duljinu URL-a, shemu, priključak i host društvene mreže.
- Klijent resolvera upravlja točnim udaljenim endpointom, vremenskim ograničenjima, ponovnim pokušajima i rukovanjem HTTP statusima.
- Domenski objekt pruža stabilnu lokalnu ovojnicu bez izmišljanja sheme odgovora usluge.
- Sloj za spremanje profila prihvaća samo unose čiji je lokalni status
resolved.
Dovoljna je kompaktna struktura projekta:
src/
Controller/SocialLinkController.php
Identity/IdentityResolver.php
Identity/IdentityResolverException.php
Identity/ResolvedIdentity.php
tests/
Identity/IdentityResolverTest.php
config/
services.yaml
Izradite Symfony projekt
Upotrijebite PHP 8.3 ili noviji i održavano izdanje Symfonya kompatibilno s njim. Nova aplikacija može instalirati framework, HTTP klijent, integraciju zapisivanja i podršku za testiranje pomoću Composera:
composer create-project symfony/skeleton community-directory
cd community-directory
composer require symfony/http-client symfony/monolog-bundle
composer require --dev symfony/test-pack
Ako imenik već postoji, instalirajte samo pakete koji nedostaju. Implementacija koristi HttpClientInterface, injektiranje kroz konstruktor, rute s atributima i MockHttpClient; ne treba zasebnu HTTP biblioteku.
Definirajte stabilnu domensku granicu
Udaljeni objekt ne bi se trebao širiti kroz kontrolere, predloške i kod baze podataka. Zamotajte ga u ovojnicu u vlasništvu aplikacije uz zadržavanje potpunog normaliziranog identiteta.
<?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);
}
}
Vrijednost kind interna je klasifikacija neuspjeha, a ne polje odgovora uzvodne usluge. Kontroleru omogućuje razlikovanje ograničavanja brzine, problema s prijenosom, nevaljanih odgovora i odbijenih zahtjeva bez izlaganja udaljenih tijela korisnicima.
Izradite otporan HTTP klijent
Klijent fiksira odredište i prihvaća samo vrijednosti platforme i URL-a. Poslani URL postaje kodirani parametar upita; nikada ne upravlja izlaznim hostom. Tri pokušaja pokrivaju prolazne pogreške prijenosa, HTTP 429 i pogreške poslužitelja. Ostali odgovori 4xx ne pokušavaju se ponovno jer ponavljanje nevaljanog zahtjeva troši kapacitet.
<?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));
}
}
Povratno odgađanje namjerno je ograničeno. Sinkrona predaja obrasca ne bi trebala visjeti proizvoljan interval Retry-After. Nakon što se iscrpi proračun za ponovne pokušaje, aplikacija vraća strukturirani neuspjeh i dopušta korisniku da pokuša ponovno kasnije.
Povežite konfiguraciju podržanu okruženjem
Centralno povežite osnovni URI. Ne dodajte zamjenski token ni autorizacijsko zaglavlje: time biste pogrešno prikazali trenutačni javni ugovor o autentikaciji.
# 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%'
Normalizirajte predaju imenika
Kontroler prihvaća objekt links koji sadrži jednu ili više podržanih platformi. Validacija se odvija prije bilo kakvog vanjskog poziva, tako da jedna neispravna poveznica ne može proizvesti djelomično validiranu predaju.
<?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;
}
}
Obrazac sada može poslati sve tri mreže u jednom zahtjevu. Spremite samo svaki value čiji je status resolved; zadržite vraćeni objekt identiteta kao JSON ako ugovor usluge ne definira uža polja o kojima vaša domena može sigurno ovisiti.
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"
}
}'
Testirajte ponovne pokušaje i mapiranje granice
MockHttpClient čini testove determinističkima i sprječava slučajne mrežne pozive. Donji uspješni fixture namjerno je neproziran; njegov ključ samo za primjer nije predstavljen kao dio stvarne API sheme.
<?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);
}
}
}
Pokrenite skup testova s php bin/phpunit. Dodajte testove kontrolera za pravila autentikacije i vlasništva svoje aplikacije jer ta pravila pripadaju imeniku, a ne klijentu resolvera.
Sigurnosna i operativna disciplina
Zaštitite rutu istom autentikacijom i autorizacijom koja se koristi za uređivanje profila zajednice. Prijavljeni član ne smije moći normalizirati ni spremati poveznice za zapis drugog člana. Ako sesije preglednika autenticiraju zahtjev, primijenite i strategiju aplikacije za CSRF.
Popis dopuštenih hostova smanjuje neispravne prijave i obmanjujuće domene. Fiksni osnovni URI sprječava krivotvorenje zahtjeva na strani poslužitelja: korisnički unos ostaje podatak upita i nikada ne postaje odredište. Primijenite ograničenja brzine zahtjeva na razini aplikacije ili ruba, ograničite veličinu JSON tijela i izbjegavajte zapisivanje poslanih URL-ova jer korisnička imena mogu biti osobni podaci.
Dostavljeni ugovor o autentikaciji danas je javan. Ako se promijeni, dodajte vjerodajnicu u spremište tajni implementacije i izložite je kroz Symfony parametar podržan okruženjem. Nikada je ne predajte u repozitorij, ne vraćajte je u pogreškama i ne stavljajte je u fixtureove.
Korisni zapisi sadrže platformu, pokušaj, status i internu vrstu neuspjeha. Upozorite na trajna povećanja vrijednosti rate_limited, transport ili invalid_response. Ne upozoravajte na jedan odbijeni profil; to je obično pitanje unosa ili podrške, a ne prekid rada.
Implementacija i uobičajeni načini neuspjeha
Postavite IDENTITY_RESOLVER_BASE_URI u svakom izvršnom okruženju, uključujući definicije workera ili spremnika iako je ova verzija sinkrona. Implementirajte pomoću uobičajenih produkcijskih naredbi:
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
- Svaki URL vraća 422: potvrdite da poveznica koristi HTTPS i da host zaista pripada odabranoj platformi.
- Usluga vraća 4xx: provjerite platformu i URL pomoću službene dokumentacije. Klijent ispravno izbjegava ponovne pokušaje tih zahtjeva.
- Zahtjevi završavaju s 429: smanjite učestalost predaja i pokušajte ponovno kasnije. Nemojte neograničeno povećavati sinkrono čekanje.
- Povremeni odgovori 503: pregledajte strukturirane zapise i dostupnost uzvodne usluge. Ograničeni ponovni pokušaj već obrađuje kratke prekide.
- Odgovori postaju
invalid_response: operativno zabilježite status i vrstu sadržaja, ali nemojte izlagati ni neselektivno zapisivati tijelo odgovora. - Lokalni zahtjevi rade, ali produkcija ne uspijeva: provjerite izlazni HTTPS pristup, DNS, pouzdana tijela za izdavanje certifikata i varijablu produkcijskog okruženja.
Završni kontrolni popis za provjeru
- Aplikacija poziva točno
GET /api/identity-resolver/v1/resolves parametrimaplatformiurl. - Ne šalje se token, API ključ ni izmišljeno autorizacijsko zaglavlje.
- Poveznice za Facebook, Instagram i LinkedIn validiraju se prije mrežnog pristupa.
- Neaktivnost veze i ukupno trajanje zahtjeva ograničeni su.
- Ponovno se pokušavaju samo pogreške prijenosa, HTTP 429 i pogreške poslužitelja.
- Normalizirani javni identitet zadržava se bez pretpostavljanja nedokumentiranih polja.
- Djelomični neuspjesi su izričiti, a nerazriješeni identiteti ne spremaju se.
- Testovi koriste
MockHttpClienti ne izvode stvarne vanjske zahtjeve. - Zapisi sadrže operativni kontekst, ali isključuju potpune URL-ove profila i tijela odgovora.
Važan rezultat nisu samo čišće poveznice. To je pouzdana granica između nepredvidivog ljudskog unosa i dugotrajnih podataka imenika. Kada ta granica rano validira, selektivno ponavlja pokušaje, obrambeno mapira i vidljivo ne uspije, društveni profili prestaju biti krhki nizovi znakova i postaju identiteti koje ostatak aplikacije može sigurno koristiti.