Vodiči

Native PHP 8.3: Unify Social Directory Links with AI Identity Resolver

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:

  1. Otvorite dokumentaciju i potvrdite da je endpoint još uvijek javan.
  2. Pregledajte stranicu usluge i plana za aktualne pojedinosti o usluzi.
  3. 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.
  4. 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, platform i url.
  • 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.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.