Nativni PHP 8.3: Ujedinite poveznice društvenih direktorija pomoću AI razrješivača identiteta
Direktorij zajednice često počinje s nekoliko bezazlenih tekstnih polja. Zatim suradnici lijepe mobilne Facebook URL-ove, poveznice na Instagram profile s parametrima za praćenje, LinkedIn varijante, a ponekad i same identifikatore. Ako se te vrijednosti pohranjuju nepromijenjene, pretraživanje, deduplikacija i prikaz profila postaju sve nepouzdaniji.
Ovaj vodič izrađuje Native PHP 8.3 endpoint koji prihvaća poveznice na Facebook, Instagram i LinkedIn profile, razrješava ih putem Identity Resolvera i pohranjuje dosljedan domenski objekt u SQLite. Integracija koristi nativni cURL, ograničene ponovne pokušaje, obrambeno mapiranje odgovora, strukturirane zapise i determinističke PHPUnit testove.
Pribavite pristup prije pisanja integracijskog koda
Započnite sa službenom dokumentacijom za Identity Resolver. Ona definira podržane ulaze i također je mjerodavno mjesto za provjeru jesu li se zahtjevi za pristup promijenili.
Trenutačni javni endpoint ne zahtijeva token računa ni API ključ. Slijedom toga, nema vjerodajnice koju treba kopirati u PHP, zaglavlja za autorizaciju koje treba sastaviti ni koraka odabira plana prije prvog zahtjeva. Slijed uvođenja je:
- Otvorite dokumentaciju i potvrdite da je endpoint još uvijek javan.
- Pregledajte stranicu usluge i plana za aktualne pojedinosti o usluzi.
- Budući da račun trenutačno nije potreban, dokumentacija služi kao službene smjernice za registraciju: za ovaj endpoint ne postoji radnja registracije.
- Također pogledajte stranicu za prijavu i status računa, ali nemojte čekati token niti izmišljati API ključ.
Točan poziv 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 već prikuplja poveznice, stoga će dosljedno slati platform i url.
Napravite minimalni test prije izrade značajke:
curl --get \
--header 'Accept: application/json' \
--data-urlencode 'platform=instagram' \
--data-urlencode 'url=https://www.instagram.com/example/' \
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
Nema zaglavlja Authorization. Provjerite stvarni odgovor u odnosu na dokumentaciju umjesto da pretpostavljate nedokumentirana polja.
Odaberite malu, pouzdanu arhitekturu
Aplikacija ima četiri granice: HTTP ulaznu točku, lokalnu validaciju URL-a, klijent Identity Resolvera i trajnu pohranu. Razrješavanje se odvija prije transakcije baze podataka, čime se SQLite blokada pisanja održava kratkom dok je vanjski zahtjev u tijeku.
Uzvodni odgovor namjerno se čuva kao neprozirni JSON objekt. Naša aplikacija dodaje vlastita polja—platform, source_url, identity_key i public_identity—bez tvrdnje da ta imena postoje u odgovoru usluge. Ta granica preživljava aditivne promjene odgovora i izbjegava povezivanje poslovnog koda s poljima koja nisu zajamčena dostavljenim ugovorom.
Koristite ovaj raspored projekta:
community-directory/
├── composer.json
├── .env.example
├── public/
│ └── index.php
├── src/
│ └── IdentityResolver.php
├── tests/
│ └── IdentityResolverTest.php
└── var/
└── directory.sqlite
Izradite composer.json s PHP-om 8.3, potrebnim proširenjima, classmap automatskim učitavanjem i PHPUnitom 11:
{
"require": {
"php": ">=8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"classmap": ["src/"]
}
}
composer install
composer dump-autoload --classmap-authoritative
Konfigurirajte okruženje bez izmišljanja vjerodajnice usluge
Nativni PHP ne učitava automatski datoteku .env. Koristite je lokalno putem upravitelja procesa ili ljuske, a u produkciji iste varijable konfigurirajte izravno u PHP-FPM-u ili svojoj platformi za implementaciju.
Izradite .env.example:
IDENTITY_RESOLVER_ENDPOINT=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
DIRECTORY_DSN=sqlite:var/directory.sqlite
DIRECTORY_WRITE_TOKEN=YOUR_DIRECTORY_WRITE_TOKEN
DIRECTORY_WRITE_TOKEN štiti vlastiti endpoint za slanje; nije token Identity Resolvera. Namjerno ne postoji varijabla uzvodnog API ključa. Generirajte snažan aplikacijski token izvan kontrole izvornog koda, stvarni .env držite izvan repozitorija i ubrizgajte njegove vrijednosti tijekom izvođenja.
Izradite cURL granicu i mapper domene
Sljedeće smjestite u src/IdentityResolver.php. Transport nameće HTTPS, onemogućuje preusmjeravanja, provjerava TLS koristeći cURL zadane postavke, ograničava vrijeme povezivanja i ukupno vrijeme te prekida odgovore veće od 256 KiB.
<?php
declare(strict_types=1);
namespace App;
use JsonException;
use RuntimeException;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
interface HttpTransport
{
public function get(string $url, array $query): HttpResponse;
}
final class CurlTransport implements HttpTransport
{
public function get(string $url, array $query): HttpResponse
{
$uri = $url . '?' . http_build_query(
$query,
'',
'&',
PHP_QUERY_RFC3986
);
$headers = [];
$body = '';
$handle = curl_init($uri);
if ($handle === false) {
throw new RuntimeException('Unable to initialize cURL');
}
curl_setopt_array($handle, [
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT_MS => 2000,
CURLOPT_TIMEOUT_MS => 6000,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'User-Agent: community-directory/1.0',
],
CURLOPT_HEADERFUNCTION => static function (
$handle,
string $line
) use (&$headers): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
CURLOPT_WRITEFUNCTION => static function (
$handle,
string $chunk
) use (&$body): int {
if (strlen($body) + strlen($chunk) > 262144) {
return 0;
}
$body .= $chunk;
return strlen($chunk);
},
]);
if (curl_exec($handle) === false) {
$message = curl_error($handle);
throw new RuntimeException('Resolver transport failed: ' . $message);
}
return new HttpResponse(
curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
$headers,
$body,
);
}
}
final readonly class ResolvedIdentity
{
public function __construct(
public string $platform,
public string $sourceUrl,
public string $identityKey,
public array $publicIdentity,
) {}
public static function fromApi(
string $platform,
string $sourceUrl,
array $payload
): self {
if ($payload === [] || array_is_list($payload)) {
throw new ResolverException(
'invalid_response',
false,
'Resolver returned no identity object'
);
}
$fingerprint = hash(
'sha256',
$platform . "\n" . json_encode($payload, JSON_THROW_ON_ERROR)
);
return new self($platform, $sourceUrl, $fingerprint, $payload);
}
public function toArray(): array
{
return [
'platform' => $this->platform,
'source_url' => $this->sourceUrl,
'identity_key' => $this->identityKey,
'public_identity' => $this->publicIdentity,
];
}
}
final class ResolverException extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly bool $retryable,
string $message
) {
parent::__construct($message);
}
}
final class IdentityResolver
{
public function __construct(
private HttpTransport $http,
private string $endpoint,
private \Closure $sleep,
private \Closure $log,
) {}
public function resolve(string $platform, string $url): ResolvedIdentity
{
if (!in_array($platform, ['facebook', 'instagram', 'linkedin'], true)) {
throw new ResolverException(
'validation',
false,
'Unsupported platform'
);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->get($this->endpoint, [
'platform' => $platform,
'url' => $url,
]);
} catch (RuntimeException $exception) {
($this->log)([
'event' => 'identity_resolver_transport_failure',
'platform' => $platform,
'attempt' => $attempt,
]);
if ($attempt === 3) {
throw new ResolverException(
'transport',
true,
'Identity service is temporarily unavailable'
);
}
($this->sleep)(200000 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status === 429 || $response->status >= 500) {
($this->log)([
'event' => 'identity_resolver_retry',
'platform' => $platform,
'status' => $response->status,
'attempt' => $attempt,
]);
if ($attempt === 3) {
throw new ResolverException(
'upstream_unavailable',
true,
'Identity service could not complete the request'
);
}
($this->sleep)(200000 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status === 401 || $response->status === 403) {
throw new ResolverException(
'access',
false,
'Identity service rejected access'
);
}
if ($response->status < 200 || $response->status >= 300) {
throw new ResolverException(
'invalid_reference',
false,
'Identity reference was rejected'
);
}
try {
$payload = json_decode(
$response->body,
true,
32,
JSON_THROW_ON_ERROR
);
} catch (JsonException) {
throw new ResolverException(
'invalid_response',
false,
'Identity service returned invalid JSON'
);
}
if (!is_array($payload)) {
throw new ResolverException(
'invalid_response',
false,
'Identity service returned an unexpected document'
);
}
return ResolvedIdentity::fromApi($platform, $url, $payload);
}
throw new ResolverException('internal', false, 'Unreachable state');
}
}
Ponavljaju se samo neuspjesi transporta, HTTP 429 i pogreške poslužitelja. Validacija, pristup i druge pogreške klijenta odmah ne uspijevaju. Odgode su ograničene na 200 i 400 milisekundi jer interaktivno slanje u direktorij ne bi trebalo čekati neograničeno.
Prihvatite i pohranite slanje u direktorij
Jednom implementirajte shemu:
CREATE TABLE directory_submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identities_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
Zatim izradite public/index.php. Zahtijeva aplikacijski bearer token, ograničava veličinu zahtjeva, lokalno validira svaki URL, razrješava sva tri identiteta i pohranjuje tek nakon što svako razrješavanje uspije.
<?php
declare(strict_types=1);
use App\CurlTransport;
use App\IdentityResolver;
use App\ResolverException;
require dirname(__DIR__) . '/vendor/autoload.php';
header('Content-Type: application/json');
$send = static function (int $status, array $body): never {
http_response_code($status);
echo json_encode($body, JSON_THROW_ON_ERROR);
exit;
};
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
$send(405, ['error' => 'method_not_allowed']);
}
$expected = getenv('DIRECTORY_WRITE_TOKEN') ?: '';
$authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if ($expected === '' || !hash_equals('Bearer ' . $expected, $authorization)) {
$send(401, ['error' => 'unauthorized']);
}
$raw = file_get_contents('php://input');
if ($raw === false || strlen($raw) > 32768) {
$send(413, ['error' => 'request_too_large']);
}
try {
$input = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
} catch (JsonException) {
$send(400, ['error' => 'invalid_json']);
}
$roots = [
'facebook' => 'facebook.com',
'instagram' => 'instagram.com',
'linkedin' => 'linkedin.com',
];
$links = $input['links'] ?? null;
if (!is_array($links) || array_keys($links) !== array_keys($roots)) {
$send(422, ['error' => 'three_platform_links_required']);
}
foreach ($roots as $platform => $root) {
$url = $links[$platform] ?? null;
$host = is_string($url) ? strtolower(parse_url($url, PHP_URL_HOST) ?? '') : '';
$scheme = is_string($url) ? parse_url($url, PHP_URL_SCHEME) : null;
$allowedHost = $host === $root || str_ends_with($host, '.' . $root);
if ($scheme !== 'https' || !$allowedHost) {
$send(422, ['error' => 'invalid_' . $platform . '_url']);
}
}
$logger = static fn(array $context) =>
error_log(json_encode($context, JSON_THROW_ON_ERROR));
$resolver = new IdentityResolver(
new CurlTransport(),
getenv('IDENTITY_RESOLVER_ENDPOINT')
?: 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
static fn(int $microseconds) => usleep($microseconds),
$logger,
);
try {
$identities = [];
foreach ($links as $platform => $url) {
$identities[] = $resolver->resolve($platform, $url)->toArray();
}
$pdo = new PDO(
getenv('DIRECTORY_DSN') ?: 'sqlite:var/directory.sqlite',
null,
null,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$pdo->beginTransaction();
$statement = $pdo->prepare(
'INSERT INTO directory_submissions
(identities_json, created_at) VALUES (:identities, :created_at)'
);
$statement->execute([
':identities' => json_encode($identities, JSON_THROW_ON_ERROR),
':created_at' => gmdate('c'),
]);
$id = (int) $pdo->lastInsertId();
$pdo->commit();
$send(201, ['id' => $id, 'identities' => $identities]);
} catch (ResolverException $exception) {
$logger([
'event' => 'directory_resolution_failed',
'kind' => $exception->kind,
'retryable' => $exception->retryable,
]);
$send($exception->retryable ? 503 : 422, [
'error' => $exception->kind,
'retryable' => $exception->retryable,
]);
} catch (Throwable $exception) {
$logger(['event' => 'directory_submission_failed']);
$send(500, ['error' => 'internal_error']);
}
Testirajte bez pozivanja javne usluge
Lažni transport čini ponovne pokušaje i klasifikaciju neuspjeha determinističkima. Spremite ovo kao tests/IdentityResolverTest.php:
<?php
declare(strict_types=1);
use App\HttpResponse;
use App\HttpTransport;
use App\IdentityResolver;
use App\ResolverException;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements HttpTransport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function get(string $url, array $query): HttpResponse
{
return $this->responses[$this->calls++];
}
}
final class IdentityResolverTest extends TestCase
{
public function testMapsAValidIdentityObject(): void
{
$fake = new FakeTransport([
new HttpResponse(200, [], '{"normalized":"public-value"}'),
]);
$resolver = new IdentityResolver(
$fake,
'https://example.test/resolve',
static fn(int $delay) => null,
static fn(array $context) => null,
);
$identity = $resolver->resolve(
'instagram',
'https://www.instagram.com/example/'
);
self::assertSame('instagram', $identity->platform);
self::assertSame(
['normalized' => 'public-value'],
$identity->publicIdentity
);
self::assertSame(1, $fake->calls);
}
public function testRetriesRateLimitThenSucceeds(): void
{
$fake = new FakeTransport([
new HttpResponse(429, [], '{}'),
new HttpResponse(200, [], '{"normalized":"ok"}'),
]);
$resolver = new IdentityResolver(
$fake,
'https://example.test/resolve',
static fn(int $delay) => null,
static fn(array $context) => null,
);
$resolver->resolve('facebook', 'https://facebook.com/example');
self::assertSame(2, $fake->calls);
}
public function testDoesNotRetryRejectedReference(): void
{
$fake = new FakeTransport([
new HttpResponse(400, [], '{"error":"invalid"}'),
]);
$resolver = new IdentityResolver(
$fake,
'https://example.test/resolve',
static fn(int $delay) => null,
static fn(array $context) => null,
);
try {
$resolver->resolve(
'linkedin',
'https://www.linkedin.com/in/example/'
);
self::fail('Expected ResolverException');
} catch (ResolverException $exception) {
self::assertSame('invalid_reference', $exception->kind);
self::assertFalse($exception->retryable);
self::assertSame(1, $fake->calls);
}
}
}
vendor/bin/phpunit tests
php -l src/IdentityResolver.php
php -l public/index.php
Sigurnost, nadziranost i implementacija
Završite HTTPS na web poslužitelju, izložite samo public/ kao korijen dokumenata i pokrenite PHP-FPM kao korisnik koji može pisati samo u direktorij SQLite baze podataka. Držite Composer razvojne pakete i datoteke okruženja izvan javnog stabla.
Endpoint provjerava točne domene i poddomene, čime blokira hostove poput facebook.com.attacker.example. Preusmjeravanja su onemogućena na uzvodnoj granici i dopušten je samo HTTPS. Usluga prima URL-ove javnih profila, no te vrijednosti i dalje mogu biti osjetljive u zbiru; stoga zapisi bilježe platformu, status, pokušaj i vrstu neuspjeha bez bilježenja poslanih URL-ova ili tijela odgovora.
Šaljite strukturirane zapise svom uobičajenom sakupljaču zapisa i postavite upozorenja za trajni identity_resolver_transport_failure, identity_resolver_retry ili povišene odgovore 503. HTTP 429 treba tretirati kao pritisak kapaciteta, a ne kao dokaz da je zahtjev nevaljan. Pri većim količinama premjestite razrješavanje u ograničeni pozadinski red i izričito označite slanja kao na čekanju umjesto povećanja broja sinkronih ponovnih pokušaja.
Implementirajte s reproducibilnim ovisnostima, pokrenite migraciju sheme prije prebacivanja prometa i provjerite prima li produkcijski proces sve tri varijable okruženja:
composer install --no-dev --classmap-authoritative
vendor/bin/phpunit tests
php -l src/IdentityResolver.php
php -l public/index.php
Uobičajeni neuspjesi i završna provjera
- Svaki zahtjev vraća 401: token za pisanje u direktorij nedostaje ili je pozivatelj izostavio
Authorization: Bearer YOUR_DIRECTORY_WRITE_TOKEN. To je sigurnost lokalne aplikacije, a ne autentikacija usluge. - Poveznica koja izgleda valjano vraća 422: potvrdite HTTPS, uparivanje platforme i domene te trenutačno podržane formate referenci u službenoj dokumentaciji.
- Odgovori postaju 503: provjerite strukturirane događaje zbog prekoračenja vremena, HTTP 429 ili pogrešaka uzvodnog poslužitelja. Nemojte ih pretvarati u trajne neuspjehe validacije.
- SQLite prijavljuje pogrešku pisanja: provjerite postoji li direktorij baze podataka i može li u njega pisati PHP-FPM, dok ostaje nedostupan iz web korijena.
- Testovi slučajno dosežu mrežu: konstruirajte resolver s
FakeTransport; integracijski testovi prema javnom endpointu trebaju biti odvojeni i izričito omogućeni.
Prije izdanja provjerite sljedeće:
- Dokumentacija i dalje navodi da endpoint ne treba token ni API ključ.
- Zahtjev koristi točno
GET, dokumentirani endpoint,platformiurl. - Poveznice za Facebook, Instagram i LinkedIn svaka se uspješno razrješavaju.
- Neispravna domena odbacuje se prije bilo kakvog vanjskog poziva.
- HTTP 400 se ne ponavlja, dok 429 i neuspjesi poslužitelja dobivaju ograničene ponovne pokušaje.
- Nijedan poslani URL, tijelo odgovora, bearer token ni nepostojeći ključ usluge ne pojavljuje se u zapisima.
- Baza podataka atomski pohranjuje sva tri normalizirana javna objekta identiteta.
Važan rezultat nisu samo čišći URL-ovi. Direktorij sada ima namjernu granicu identiteta: neuredne javne reference ulaze s jedne strane, dok stabilni aplikacijski objekti izlaze s druge. Ta granica sprječava da sutrašnje značajke pretraživanja, deduplikacije i profila naslijede današnji nedosljedan unos.