Native PHP 8.3: Razrješavanje poveznica na društvene mreže za direktorije zajednice pomoću API-ja za razrješavanje identiteta
Direktorij zajednice rijetko prima uredne društvene identitete. Jedan član zalijepi puni Instagram URL, drugi pošalje mobilnu Facebook poveznicu, a treći uključi LinkedIn profil s parametrima za praćenje. Tretiranje tih nizova kao kanonskih identifikatora stvara duplicirane zapise, krhke poveznice i dodatni posao čišćenja.
Ovaj vodič izrađuje produkcijski usmjerenu uslugu u Native PHP 8.3 koja prihvaća URL-ove Facebook, Instagram i LinkedIn profila te ih šalje API-ju Identity Resolver. API normalizira svaku javnu referencu u stabilan objekt identiteta, dok naša aplikacija upravlja validacijom, vremenskim ograničenjima, ponovnim pokušajima, neuspjesima, zapisivanjem u log i obrambenim mapiranjem odgovora.
Dobijte pristup prije pisanja integracijskog koda
Započnite sa službenom dokumentacijom za Identity Resolver. Trenutačni javni endpoint ne zahtijeva token računa ni API ključ, stoga prije prvog zahtjeva nema vjerodajnice koju treba kopirati.
- Otvorite dokumentaciju i pregledajte podržane oblike unosa.
- Pregledajte stranicu usluge i plana za aktualne informacije o usluzi.
- Registracija nije potrebna za ovaj javni endpoint.
- Prijava nije potrebna prije testiranja.
- Nemojte dodavati izmišljeni bearer token, zaglavlje API ključa ni zamjensku vjerodajnicu. Ako se zahtjevi za autentikaciju kasnije promijene, slijedite službenu dokumentaciju umjesto nagađanja o shemi autentikacije.
Točan zahtjev je GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Prihvaća platform uz podržani parametar username, id, identifier, profile ili url. Naš direktorij koristi url.
Zamijenite rezervirano mjesto stvarnim javnim profilom i izvršite prvo testiranje bez autorizacijskog zaglavlja:
curl --get \
--url 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--header 'Accept: application/json' \
--data-urlencode 'platform=instagram' \
--data-urlencode 'url=https://www.instagram.com/YOUR_PUBLIC_USERNAME/'
Ugovor usluge jamči normalizirani odgovor javnog identiteta, ali ovaj vodič namjerno ne pretpostavlja nedokumentirana imena polja. Aplikacija provjerava je li odgovor JSON objekt i čuva ga kao korisni teret identiteta iz uzvodne usluge.
Arhitektura i kompromisi
Projekt direktorijskom frontendu izlaže POST /profiles/normalize. Provjerava poslanu platformu i URL, poziva fiksni uzvodni endpoint, mapira rezultat u domenski objekt i vraća normalizirani identitet. Postojeći repozitorij direktorija može taj objekt trajno pohraniti tek nakon uspješnog poziva.
Normalizacija ostaje sinkrona jer je obično dio pregleda ili spremanja jednog profila. Red bi dodao operativnu složenost i otežao trenutačnu validaciju. Ako skupni uvozi postanu potrebni, ista klasa razrješivača kasnije može raditi unutar workera bez promjene svoje HTTP granice.
Izvorni cURL održava ovisnosti malima. Transport se nalazi iza sučelja kako testovi nikada ne bi kontaktirali mrežu.
community-directory/
├── .env.example
├── composer.json
├── public/
│ └── index.php
├── src/
│ ├── Http.php
│ └── IdentityResolver.php
└── tests/
└── IdentityResolverTest.php
Preduvjeti i konfiguracija
Trebate PHP 8.3 ili noviji, proširenja cURL i JSON, Composer te PHPUnit 11 za testove.
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
}
}
Instalirajte ovisnosti pomoću composer install. Stavite nesigurne postavke vremena izvođenja u .env.example i kopirajte ga u nepraćeni .env za lokalni razvoj:
IDENTITY_RESOLVER_BASE_URL=https://ai.mihajlo.mk/api/identity-resolver
IDENTITY_RESOLVER_CONNECT_TIMEOUT_MS=1500
IDENTITY_RESOLVER_TIMEOUT_MS=5000
Namjerno nema varijable tokena: trenutačni endpoint ne treba vjerodajnicu. U produkciji umetnite ove vrijednosti putem upravitelja procesa ili platforme za implementaciju. Za jednostavnu lokalnu datoteku iznad, pokrenite aplikaciju pomoću:
set -a
. ./.env
set +a
php -S 127.0.0.1:8080 -t public
Izradite zamjenjivi cURL transport
Transport upravlja cURL mehanikom, dok razrješivač upravlja pravilima API-ja. Onemogućuje preusmjeravanja, primjenjuje odvojena vremenska ograničenja povezivanja i ukupnog trajanja, bilježi zaglavlja odgovora i pretvara mrežne pogreške u tipiziranu iznimku.
<?php
// src/Http.php
declare(strict_types=1);
namespace App;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
final class TransportException extends \RuntimeException {}
interface HttpTransport
{
public function get(
string $url,
array $query,
int $connectTimeoutMs,
int $timeoutMs,
): HttpResponse;
}
final class CurlTransport implements HttpTransport
{
public function get(
string $url,
array $query,
int $connectTimeoutMs,
int $timeoutMs,
): HttpResponse {
$headers = [];
$handle = curl_init($url . '?' . http_build_query(
$query,
'',
'&',
PHP_QUERY_RFC3986
));
curl_setopt_array($handle, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
CURLOPT_TIMEOUT_MS => $timeoutMs,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_USERAGENT => 'community-directory/1.0',
CURLOPT_HEADERFUNCTION => static function (
mixed $curl,
string $line
) use (&$headers): int {
$length = strlen($line);
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return $length;
},
]);
$body = curl_exec($handle);
if ($body === false) {
$message = curl_error($handle);
curl_close($handle);
throw new TransportException($message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $headers, $body);
}
}
Razrješavajte i mapirajte identitete obrambeno
Klasa usluge prihvaća samo tri platforme direktorija, zahtijeva HTTPS i provjerava pripada li naziv hosta odabranoj platformi. Time se otkrivaju slučajna nepodudaranja prije nego što potroše kapacitet uzvodne usluge.
Prolazne mrežne pogreške, HTTP 429 i odgovori poslužitelja 5xx dobivaju najviše tri pokušaja. HTTP 4xx odgovori ne pokušavaju se naslijepo ponovno. Odgoda je ograničena, a cjelobrojna vrijednost Retry-After poštuje se do dvije sekunde.
<?php
// src/IdentityResolver.php
declare(strict_types=1);
namespace App;
final readonly class IdentityResolution
{
public function __construct(
public string $platform,
public string $submittedUrl,
public array $publicIdentity,
) {}
}
final class ResolverException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly bool $retryable = false,
) {
parent::__construct($message);
}
}
final class IdentityResolver
{
private \Closure $sleep;
private \Closure $log;
public function __construct(
private HttpTransport $transport,
private string $baseUrl,
private int $connectTimeoutMs = 1500,
private int $timeoutMs = 5000,
?\Closure $sleep = null,
?\Closure $log = null,
) {
$this->sleep = $sleep ?? static fn (int $microseconds) =>
usleep($microseconds);
$this->log = $log ?? static function (array $context): void {};
}
public function resolve(string $platform, string $url): IdentityResolution
{
$platform = strtolower(trim($platform));
$domains = [
'facebook' => 'facebook.com',
'instagram' => 'instagram.com',
'linkedin' => 'linkedin.com',
];
if (!isset($domains[$platform])) {
throw new ResolverException('validation', 'Unsupported platform.');
}
if (strlen($url) > 2048 || filter_var($url, FILTER_VALIDATE_URL) === false) {
throw new ResolverException('validation', 'Invalid profile URL.');
}
$parts = parse_url($url);
$host = strtolower(rtrim((string) ($parts['host'] ?? ''), '.'));
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$domain = $domains[$platform];
if ($scheme !== 'https' ||
($host !== $domain && !str_ends_with($host, '.' . $domain))) {
throw new ResolverException(
'validation',
'The URL does not match the selected platform.'
);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->get(
rtrim($this->baseUrl, '/') . '/v1/resolve',
['platform' => $platform, 'url' => $url],
$this->connectTimeoutMs,
$this->timeoutMs,
);
} catch (TransportException $exception) {
($this->log)([
'event' => 'identity_resolver_transport_error',
'attempt' => $attempt,
]);
if ($attempt === 3) {
throw new ResolverException(
'transport',
'Identity service could not be reached.',
true
);
}
($this->sleep)(100000 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status === 429 ||
($response->status >= 500 && $response->status <= 599)) {
if ($attempt === 3) {
$kind = $response->status === 429
? 'rate_limited'
: 'unavailable';
throw new ResolverException(
$kind,
'Identity service is temporarily unavailable.',
true
);
}
$retryAfter = $response->headers['retry-after'] ?? '';
$delay = ctype_digit($retryAfter)
? min(2000000, (int) $retryAfter * 1000000)
: 100000 * (2 ** ($attempt - 1));
($this->sleep)($delay);
continue;
}
if ($response->status < 200 || $response->status >= 300) {
throw new ResolverException(
'upstream_rejected',
'Identity reference was rejected.'
);
}
try {
$payload = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException) {
throw new ResolverException(
'contract',
'Identity service returned invalid JSON.',
true
);
}
if (!is_array($payload) || $payload === [] || array_is_list($payload)) {
throw new ResolverException(
'contract',
'Identity service returned an unexpected response.',
true
);
}
($this->log)([
'event' => 'identity_resolver_success',
'platform' => $platform,
'attempt' => $attempt,
]);
return new IdentityResolution($platform, $url, $payload);
}
throw new \LogicException('Retry loop terminated unexpectedly.');
}
}
Izložite endpoint direktorija
Prednji kontroler vraća predvidljive lokalne pogreške bez otkrivanja cURL poruka ili tijela odgovora uzvodne usluge. Neispravna prijava dobiva 422, iscrpljeni prolazni neuspjeh dobiva 503, a neočekivani ugovor uzvodne usluge dobiva 502.
<?php
// public/index.php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use App\CurlTransport;
use App\IdentityResolver;
use App\ResolverException;
header('Content-Type: application/json; charset=utf-8');
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' ||
$path !== '/profiles/normalize') {
http_response_code(404);
echo json_encode(['error' => 'not_found']);
exit;
}
try {
$input = json_decode(
file_get_contents('php://input'),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($input)) {
throw new JsonException('Expected an object.');
}
$resolver = new IdentityResolver(
new CurlTransport(),
getenv('IDENTITY_RESOLVER_BASE_URL')
?: 'https://ai.mihajlo.mk/api/identity-resolver',
(int) (getenv('IDENTITY_RESOLVER_CONNECT_TIMEOUT_MS') ?: 1500),
(int) (getenv('IDENTITY_RESOLVER_TIMEOUT_MS') ?: 5000),
log: static fn (array $context) =>
error_log(json_encode($context, JSON_THROW_ON_ERROR)),
);
$result = $resolver->resolve(
(string) ($input['platform'] ?? ''),
(string) ($input['url'] ?? ''),
);
echo json_encode(
['data' => $result->publicIdentity],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
} catch (JsonException) {
http_response_code(400);
echo json_encode(['error' => 'invalid_json']);
} catch (ResolverException $exception) {
$status = match ($exception->kind) {
'validation' => 422,
'rate_limited', 'transport', 'unavailable' => 503,
default => 502,
};
http_response_code($status);
echo json_encode([
'error' => $exception->kind,
'retryable' => $exception->retryable,
]);
}
Testirajte bez ovisnosti o mreži
Deterministički lažni transport provjerava ponašanje mapiranja i ponovnog pokušaja. Također dokazuje da neuspjesi validacije ne stvaraju uzvodni zahtjev.
<?php
// tests/IdentityResolverTest.php
declare(strict_types=1);
namespace Tests;
use App\HttpResponse;
use App\HttpTransport;
use App\IdentityResolver;
use App\ResolverException;
use PHPUnit\Framework\TestCase;
final class IdentityResolverTest extends TestCase
{
public function testMapsObjectResponse(): void
{
$fake = new QueueTransport([
new HttpResponse(200, [], '{"stable":"public-value"}'),
]);
$result = (new IdentityResolver($fake, 'https://service.test'))
->resolve('instagram', 'https://www.instagram.com/example/');
self::assertSame(
['stable' => 'public-value'],
$result->publicIdentity
);
self::assertSame('instagram', $fake->queries[0]['platform']);
}
public function testRetriesRateLimitThenSucceeds(): void
{
$fake = new QueueTransport([
new HttpResponse(429, ['retry-after' => '1'], '{}'),
new HttpResponse(200, [], '{"stable":"value"}'),
]);
$delays = [];
$resolver = new IdentityResolver(
$fake,
'https://service.test',
sleep: static function (int $delay) use (&$delays): void {
$delays[] = $delay;
}
);
$resolver->resolve('linkedin', 'https://linkedin.com/in/example');
self::assertCount(2, $fake->queries);
self::assertSame([1000000], $delays);
}
public function testRejectsMismatchedDomainWithoutCallingTransport(): void
{
$fake = new QueueTransport([]);
$this->expectException(ResolverException::class);
try {
(new IdentityResolver($fake, 'https://service.test'))
->resolve('facebook', 'https://example.com/member');
} finally {
self::assertCount(0, $fake->queries);
}
}
}
final class QueueTransport implements HttpTransport
{
public array $queries = [];
public function __construct(private array $responses) {}
public function get(
string $url,
array $query,
int $connectTimeoutMs,
int $timeoutMs,
): HttpResponse {
$this->queries[] = $query;
return array_shift($this->responses);
}
}
Pokrenite paket testova pomoću vendor/bin/phpunit tests.
Sigurnost, opažljivost i implementacija
Fiksni osnovni URL uzvodne usluge sprječava zahtjeve na strani poslužitelja kojima upravlja korisnik. Validacija URL-a odbacuje HTTP poveznice, nepovezane domene, prevelik unos i nepodudaranja platforme. Također primijenite ograničenja veličine tijela zahtjeva i ograničenja stope po klijentu na web-poslužitelju ili pristupniku.
URL-ovi profila mogu sadržavati osobne identifikatore i parametre za praćenje. Nemojte zapisivati poslane URL-ove ni potpune korisne terete uzvodne usluge. Primjer zapisuje samo vrstu događaja, platformu i broj pokušaja. U produkciji dodajte identifikator korelacije zahtjeva, latenciju, konačni status i broj ponovnih pokušaja bez bilježenja podataka o identitetu.
Implementirajte s omogućenim proširenjem cURL, instaliranim produkcijskim ovisnostima i varijablama okruženja koje umeće vrijeme izvođenja. Držite .env izvan kontrole verzija. Izričito konfigurirajte PHP-FPM ili okruženje spremnika; nemojte pretpostavljati da će se datoteka razvojne ljuske automatski učitati.
Koristite nadzor stanja za promjene stope pogrešaka i latencije, ali nemojte upućivati zahtjev uživo prema trećoj strani iz svake provjere stanja aplikacije. To pretvara prolazne probleme uzvodne usluge u nepotrebna lokalna ponovna pokretanja.
Uobičajeni obrasci neuspjeha
- HTTP 422 lokalno: platforma nije podržana, URL je neispravan ili njegov naziv hosta ne odgovara odabranoj platformi.
- HTTP 503 lokalno: usluga je bila nedostupna, ograničila je zahtjev stopom ili je ostala nedostupna nakon ograničenih ponovnih pokušaja.
- HTTP 502 lokalno: uzvodna usluga odbila je referencu ili vratila podatke izvan dokumentiranog očekivanja JSON objekta na najvišoj razini.
- Neposredni neuspjeh cURL-a: potvrdite da su dostupni
ext-curl, DNS, TLS certifikati i odlazni HTTPS pristup. - Valjan, ali nerazriješen profil: potvrdite da je profil javan te da su odabrana platforma i oblik unosa podržani službenom dokumentacijom.
Završni kontrolni popis provjere
- Aplikacija poziva točno
GET /api/identity-resolver/v1/resolve. - Svaki zahtjev šalje
platformiurl. - Nema tokena, API ključa ni izmišljenog autentikacijskog zaglavlja.
- Hostovi za Facebook, Instagram i LinkedIn provjeravaju se prije zahtjeva.
- Vremenska ograničenja povezivanja i odgovora su ograničena.
- Ponovno se pokušavaju samo mrežne pogreške te odgovori 429 i 5xx.
- Odgovor se dekodira i validira na granici API-ja.
- Testovi koriste deterministički lažni transport i nikada ne pozivaju javnu uslugu.
- Logovi isključuju poslane URL-ove, korisne terete uzvodne usluge i vjerodajnice.
- Uspješan normalizirani objekt spreman je za sloj trajne pohrane direktorija.
Društvene poveznice izgledaju kao jednostavni nizovi dok ne postanu dugotrajni podaci aplikacije. Trajno rješenje nije veća gomila regularnih izraza za čišćenje URL-ova; ono je uska granica integracije sa strogim unosima, obrambenim mapiranjem izlaza, ograničenim ponašanjem pri neuspjehu i testovima koji mrežu čine zamjenjivom. S uspostavljenom tom granicom direktorij može tri nedosljedne društvene platforme tretirati kao jedan pouzdan tijek rada s identitetom.