Symfony Onboarding: Provjerite javne profile na društvenim mrežama pomoću alata Identity Resolver
Uvođenje društvenih profila izgleda jednostavno dok nepouzdani unos ne stigne u produkciju. Osoba može zalijepiti puni LinkedIn URL, upisati Instagram korisničko ime ili navesti Facebook identifikator. Izravno pohranjivanje tih nizova stvara nedosljedne zapise i unosi dvosmislenost u svaku nizvodnu značajku.
Ova implementacija u Symfonyju pretvara taj unos u normalizirani javni identitet, pohranjuje izvornu referencu uz odgovor i namjerno se zaustavlja na pending_review. Konačnu odluku o prihvaćanju donosi ljudski recenzent, a ne vanjski API.
Pribavite pristup prije pisanja integracijskog koda
Započnite sa službenom dokumentacijom alata Identity Resolver, a zatim pregledajte stranicu usluge i planova.
Trenutačni javni krajnji punkt ne zahtijeva ni token računa ni API ključ. Stoga nema vjerodajnice koju trebate kopirati prije prvog zahtjeva. Registracija i prijava nisu preduvjeti za ovaj krajnji punkt; ako želite račun za druge usluge, upotrijebite polazišta za registraciju ili prijavu dostupna putem službenog web-mjesta usluge, umjesto da nagađate izravne URL-ove računa.
- Otvorite dokumentaciju i potvrdite podržane platforme i vrste referenci.
- Pregledajte stranicu usluge za trenutačnu dostupnost i informacije o planovima.
- Nemojte stvarati rezervno zaglavlje za autorizaciju. Krajnji punkt trenutačno prihvaća javne zahtjeve bez njega.
- Napravite mali testni zahtjev prije nego što ga integrirate u uvođenje korisnika.
Točan API poziv jest GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Pošaljite platform uz jedan podržani parametar reference: username, id, identifier, profile ili url.
curl --get \
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--data-urlencode 'platform=instagram' \
--data-urlencode 'username=example_public_profile'
Nemojte stavljati izmišljeni token u konfiguraciju Symfonyja. Pohranite samo osnovni URI krajnjeg punkta. Ako se autentikacija uvede u budućem dokumentiranom ugovoru, tada dodajte stvarnu vjerodajnicu putem tajne varijable okruženja.
# .env
IDENTITY_RESOLVER_BASE_URI=https://ai.mihajlo.mk/api/identity-resolver/v1
Arhitektura: razriješite, pohranite, zatim pregledajte
Uobičajeni slučaj upotrebe jest obrazac za uvođenje koji od freelancera, kreatora ili predstavnika malog poduzeća traži jedan javni profil na društvenoj mreži. Zahtjev prolazi kroz četiri izričite granice:
- Kontroler provjerava platformu i oblik reference.
- Namjenski klijent poziva Identity Resolver i provjerava je li odgovor JSON objekt.
- Aplikacija pohranjuje i poslanu referencu i neizmijenjeni normalizirani odgovor.
- Ovlašteni recenzent pregledava zapis i prihvaća ga ili odbija.
Integracija ostaje sinkrona jer uvođenje ima koristi od trenutačne povratne informacije, a API poziv ograničen je kratkim vremenskim ograničenjima. Symfony Messenger dodao bi operativnu složenost bez poboljšanja ovog malog tijeka rada. Ako promet kasnije zahtijeva asinkrone uvoze, isti klijent i entitet mogu stajati iza rukovatelja porukama.
Dostatna je kompaktna struktura projekta:
src/
Controller/OnboardingProfileController.php
Entity/OnboardingProfile.php
Identity/IdentityResolverClient.php
Identity/IdentityResolverFailure.php
Identity/ResolvedPublicIdentity.php
tests/
Identity/IdentityResolverClientTest.php
config/
services.yaml
Instalirajte i konfigurirajte Symfony komponente
Ovaj vodič pretpostavlja Symfony aplikaciju koja radi na PHP-u 8.3 ili novijem, s već konfiguriranim uobičajenim sigurnosnim sustavom. Dodajte HTTP klijent prve strane, Doctrine integraciju, podršku za validaciju i alate za testiranje:
composer require symfony/http-client symfony/orm-pack symfony/validator
composer require --dev symfony/test-pack
Povežite vrijednost okruženja prema nazivu argumenta. Ovdje ne pripadaju zaglavlje za autorizaciju ni parametar vjerodajnice.
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Identity\IdentityResolverClient:
arguments:
$identityResolverBaseUri: '%env(string:IDENTITY_RESOLVER_BASE_URI)%'
Izgradite obrambenu API granicu
Usluga normalizira javne Facebook, Instagram i LinkedIn reference u stabilan objekt identiteta. Aplikacija ne bi trebala nagađati nedokumentirana polja odgovora. Umjesto toga, provjerava JSON strukturu na najvišoj razini i čuva normalizirani objekt onako kako je primljen.
Lokalni otisak u nastavku pomoćno je sredstvo za reviziju aplikacije, a ne polje za koje se tvrdi da ga pruža usluga. Sortiranje ključeva objekta prije hashiranja sprječava da bezopasan redoslijed JSON ključeva proizvede drukčiji otisak.
<?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 klijent prihvaća samo dokumentirane platforme i parametre referenci. Nakon početnog zahtjeva najviše dvaput ponovno pokušava pri transportnim neuspjesima, ograničenju stope i neuspjesima poslužitelja. Ne pokušava ponovno za druge 4xx odgovore jer nepromijenjeni unos i stanje autentikacije takve pokušaje čine uzaludnima.
<?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,
]);
}
}
Primijetite što zapisnik izostavlja: poslano korisničko ime, URL, tijelo odgovora i niz upita. Javne informacije i dalje zaslužuju minimiziranje podataka, posebno kada se povežu s korisničkim računom.
Pohranite izričito stanje pregleda
Zapis baze podataka odvaja podatke pružatelja od odluke aplikacije. Normalizirani sadržaj dokaz je za pregled; nije ovlaštenje za polaganje prava na vanjski profil ili njegovu kontrolu.
<?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();
}
}
Izložite krajnje točke za uvoz i samo za recenzente
Krajnja točka za uvoz vraća samo identifikator i stanje lokalnog zapisa. Recenzenti mogu pregledati normalizirani objekt putem zasebne zaštićene rute, a zatim poslati izričitu odluku.
<?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()]);
}
}
Za sesije preglednika primijenite CSRF zaštitu na obje rute koje mijenjaju stanje. Također provedite granice zakupca ili vlasništva uz uloge; recenzent ne smije dobiti pristup nepovezanim organizacijama samo promjenom identifikatora u URL-u.
Deterministički testirajte transport
MockHttpClient provjerava izgradnju zahtjeva bez ovisnosti o mreži. Drugi test dokazuje da se neuspjeh uzvodne usluge nalik validacijskom ne pokušava naslijepo ponovno.
<?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);
}
}
}
Dodajte funkcionalne testove kontrolera za autentikaciju, autorizaciju recenzenata, neispravan JSON, ponovljene pokušaje pregleda i postojanost baze podataka. Ti testovi trebali bi zamijeniti klijenta determinističkom testnom uslugom kako vanjski prekid rada ne bi mogao uzrokovati neuspjeh paketa testova.
Postavite uz suzdržano promatranje
Lokalno generirajte migraciju, pregledajte njezin SQL, pokrenite paket testova i primijenite pregledanu migraciju tijekom postavljanja:
php bin/console doctrine:migrations:diff
php bin/phpunit
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear
Pratite broj zahtjeva, latenciju, broj ponovnih pokušaja, razloge neuspjeha, klase statusa uzvodne usluge i starost pregleda na čekanju. Upozorite na trajne transportne neuspjehe ili rastući zaostatak pregleda. Nikada nemojte prilažiti sirove odgovore, poslane URL-ove profila ni nizove upita zapisnicima i tragovima.
Uobičajeni produkcijski neuspjesi predvidljivi su: nepodržani zapis naziva platforme trebao bi lokalno pasti; neispravne reference trebale bi vratiti odgovor 422 koji se može pregledati; odgovori 429 i 5xx mogu dobiti ograničene ponovne pokušaje prije nego što postanu 503; neispravan JSON nikada se ne smije pohraniti; a dvostruki pregled mora vratiti sukob umjesto da tiho zamijeni izvornu odluku.
Završni kontrolni popis za provjeru
- Aplikacija poziva dokumentirani krajnji punkt metodom
GET. - Uz
platformdolazi točno jedan podržani parametar reference. - Ne šalju se token, API ključ ni izmišljeno zaglavlje za autorizaciju.
- Vrijeme povezivanja i ukupno vrijeme odgovora ograničeni su.
- Ponovno se pokušavaju samo prolazni transportni neuspjesi te neuspjesi 429 i 5xx.
- Odgovor se provjerava kao JSON objekt bez pretpostavljanja nedokumentiranih polja.
- Svaki uvezeni profil započinje u stanju
pending_review. - Samo ovlašteni recenzenti mogu pregledavati i odlučivati o zapisima.
- Testovi upotrebljavaju
MockHttpClientumjesto aktivne usluge. - Zapisnici i metrike isključuju vrijednosti profila i normalizirane sadržaje.
Normalizirani identitet koristan je dokaz, ali nije dokaz vlasništva i ne bi trebao postati automatska odluka o povjerenju. Najbolji tijek uvođenja kombinira usku API granicu, lokalni stroj stanja koji se može revidirati i vidljivu ljudsku kontrolnu točku. Ta mala stanka pretvara praktičan uvoz profila u sustav kojim ljudi mogu odgovorno upravljati.