Vodiči

Native PHP 8.3: Build a Creator Contact Manager with Social Link Identity Resolution

Native PHP 8.3: Izradite upravitelj kontakata za kreatore s razrješavanjem identiteta putem društvenih poveznica

Popis kontakata autora postaje nepouzdan iznenađujuće brzo. Jedna osoba može stići kao Instagram URL, druga kao LinkedIn profil, a treća kao goli Facebook identifikator. Ako aplikacija te nizove pohranjuje izravno, svaki put uvoza stvara drukčiji oblik, a svaka kartica profila treba posebno prikazivanje.

Ovaj projekt rješava taj problem na granici sustava u nativnom PHP-u 8.3. Šalje javne društvene reference Identity Resolveru, zadržava normalizirani objekt identiteta bez nagađanja njegovih nedokumentiranih polja i pretvara ga u dosljedne, sigurno prikazane kartice autora. Rezultat je mala aplikacija, ali njezini vremenski limiti, ponovni pokušaji, provjera valjanosti, testiranje, zapisivanje i model implementacije prikladni su temelji za produkcijski rad.

Pribavite pristup prije pisanja integracijskog koda

Započnite sa službenom stranicom usluge Identity Resolver, a zatim pročitajte službenu dokumentaciju. Trenutačni javni krajnji endpoint zahtijeva ni token računa ni API ključ. Stoga nema vjerodajnice koju treba kopirati u ovaj projekt.

  1. Pregledajte stranicu usluge kako biste potvrdili da Facebook, Instagram ili LinkedIn obuhvaćaju vaš namjeravani unos.
  2. Otvorite dokumentaciju i provjerite trenutačni ugovor zahtjeva prije implementacije.
  3. Platforma također nudi stranice za registraciju i prijavu za značajke računa, ali ni registracija ni prijava trenutačno nisu potrebne za ovaj javni endpoint.
  4. Nemojte izmišljati API ključ niti slati prazno zaglavlje Authorization. Ako se autentikacija uvede kasnije, slijedite dokumentaciju i izdanu vjerodajnicu pohranite u konfiguraciju podržanu varijablama okruženja.

Točan zahtjev je GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Prihvaća platform uz jedan podržani parametar username, id, identifier, profile ili url. Prvo testiranje izvedite s nejavnom osjetljivom javnom referencom:

curl --fail-with-body --get \
  'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
  --data-urlencode 'platform=instagram' \
  --data-urlencode 'username=example_creator'

Zamijenite rezervirano mjesto stvarnom javnom referencom koju smijete obraditi. Uspješan odgovor je JSON koji sadrži normalizirani javni identitet. Provjerit ćemo ga kao objekt umjesto da pretpostavljamo polja odgovora koja nisu dio dostavljenog ugovora.

Nema vjerodajnice koju treba staviti u .env. Pohranite samo konfiguraciju spremnu za implementaciju:

IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
DATABASE_PATH=var/contacts.sqlite

Preduvjeti i struktura projekta

Potrebni su vam PHP 8.3 ili noviji, Composer, izvorni cURL te proširenja JSON, PDO i SQLite. SQLite omogućuje pokretanje vodiča na jednom računalu; implementacija s više instanci trebala bi ga zamijeniti zajedničkom bazom podataka uz zadržavanje iste granice resolvera.

{
  "name": "example/creator-contact-manager",
  "type": "project",
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*",
    "ext-pdo": "*",
    "ext-pdo_sqlite": "*",
    "vlucas/phpdotenv": "^5.6"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Tests\\": "tests/"
    }
  }
}
composer install
mkdir -p src/Identity src/Infrastructure public tests var
cp .env.example .env
composer dump-autoload
php -S 127.0.0.1:8080 -t public

Važno razdvajanje je malo i namjerno:

  • CurlTransport upravlja mrežnom mehanikom i ograničenim vremenskim limitima.
  • IdentityResolverClient upravlja ugovorom udaljenog zahtjeva i pravilima ponovnih pokušaja.
  • ResolvedIdentity provjerava i prenosi neprozirni normalizirani objekt.
  • public/index.php obrađuje unos, trajnu pohranu, zapisivanje i prezentaciju.

Ovaj dizajn zahtijeva nekoliko klasa, ali sprječava širenje detalja cURL-a i nesigurnih udaljenih podataka kroz aplikaciju.

Izgradite HTTP granicu

Stvorite src/Infrastructure/Http.php. Transport dopušta samo HTTPS, ne prati preusmjeravanja, ima odvojene vremenske limite za povezivanje i ukupno trajanje te nikad ne dodaje autentikaciju:

<?php
namespace App\Infrastructure;

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
    {
        $headers = [];
        $target = $url . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
        $curl = curl_init($target);

        curl_setopt_array($curl, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT_MS => 2000,
            CURLOPT_TIMEOUT_MS => 8000,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_HTTPHEADER => ['Accept: application/json'],
            CURLOPT_USERAGENT => 'creator-contact-manager/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);
            },
        ]);

        $body = curl_exec($curl);
        if ($body === false) {
            throw new \RuntimeException(
                'Identity Resolver transport failure: ' . curl_error($curl)
            );
        }

        return new HttpResponse(
            (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE),
            $headers,
            $body
        );
    }
}

Defenzivno mapirajte normalizirani identitet

Aplikacija ne smije tiho ovisiti o svojstvima odgovora koja nisu zajamčena. DTO stoga zahtijeva JSON objekt, čuva ga bez gubitaka i pruža ograničen popis skalarnih listova za karticu. Svaka oznaka i vrijednost i dalje će se escapeati pri prikazivanju.

Stvorite src/Identity/ResolvedIdentity.php:

<?php
namespace App\Identity;

final readonly class ResolvedIdentity
{
    public function __construct(public array $payload)
    {
        if (array_is_list($payload)) {
            throw new \InvalidArgumentException('Identity must be a JSON object.');
        }
    }

    public function fingerprint(): string
    {
        return hash('sha256', json_encode(
            $this->sorted($this->payload),
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
        ));
    }

    public function fields(int $limit = 12): array
    {
        $output = [];
        $walk = function (mixed $value, string $path = '') use (&$walk, &$output, $limit): void {
            if (count($output) >= $limit) {
                return;
            }
            if (is_array($value)) {
                foreach ($value as $key => $child) {
                    $walk($child, ltrim($path . '.' . (string) $key, '.'));
                }
            } elseif (is_scalar($value) || $value === null) {
                $output[$path ?: 'value'] = $value === null
                    ? 'null'
                    : (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value);
            }
        };

        $walk($this->payload);
        return $output;
    }

    private function sorted(array $value): array
    {
        if (!array_is_list($value)) {
            ksort($value);
        }
        foreach ($value as $key => $child) {
            if (is_array($child)) {
                $value[$key] = $this->sorted($child);
            }
        }
        return $value;
    }
}

Otisak je deterministički otisak sadržaja, a ne tvrdnja o određenom polju udaljenog identifikatora. Baza podataka također čuva izvornu platformu i referencu kako bi se postojeći kontakt mogao ažurirati kada se javni podaci profila promijene.

Dodajte ponovne pokušaje bez stvaranja oluje ponovnih pokušaja

Stvorite src/Identity/IdentityResolverClient.php. Ponovno pokušava kod transportnih pogrešaka, HTTP-a 429 i privremenih pogrešaka poslužitelja. Pogreške provjere valjanosti, razrješavanja i autentikacije vraćaju se odmah jer ponavljanje istog zahtjeva ne može ih popraviti.

<?php
namespace App\Identity;

use App\Infrastructure\HttpTransport;

final class ResolverException extends \RuntimeException
{
    public function __construct(public readonly string $kind, string $message)
    {
        parent::__construct($message);
    }
}

final class IdentityResolverClient
{
    private const PLATFORMS = ['facebook', 'instagram', 'linkedin'];
    private const REFERENCES = ['username', 'id', 'identifier', 'profile', 'url'];

    public function __construct(
        private readonly HttpTransport $http,
        private readonly string $endpoint,
        private readonly ?\Closure $sleep = null
    ) {}

    public function resolve(string $platform, string $type, string $value): ResolvedIdentity
    {
        $platform = strtolower(trim($platform));
        $value = trim($value);

        if (!in_array($platform, self::PLATFORMS, true)
            || !in_array($type, self::REFERENCES, true)
            || $value === ''
            || strlen($value) > 2048) {
            throw new ResolverException('validation', 'Unsupported or empty social reference.');
        }

        for ($attempt = 0; $attempt < 3; $attempt++) {
            try {
                $response = $this->http->get($this->endpoint, [
                    'platform' => $platform,
                    $type => $value,
                ]);
            } catch (\RuntimeException $error) {
                if ($attempt === 2) {
                    throw new ResolverException('transport', $error->getMessage());
                }
                $this->pause($attempt, null);
                continue;
            }

            if ($response->status === 200) {
                try {
                    $data = json_decode($response->body, true, 512, JSON_THROW_ON_ERROR);
                } catch (\JsonException) {
                    throw new ResolverException('invalid_response', 'Resolver returned invalid JSON.');
                }
                if (!is_array($data) || array_is_list($data)) {
                    throw new ResolverException('invalid_response', 'Resolver returned an unexpected shape.');
                }
                return new ResolvedIdentity($data);
            }

            if ($response->status === 429 || in_array($response->status, [502, 503, 504], true)) {
                if ($attempt < 2) {
                    $this->pause($attempt, $response->headers['retry-after'] ?? null);
                    continue;
                }
                throw new ResolverException(
                    $response->status === 429 ? 'rate_limited' : 'unavailable',
                    'Resolver is temporarily unavailable.'
                );
            }

            $kind = match ($response->status) {
                400, 404, 422 => 'unresolvable_reference',
                401, 403 => 'authentication_contract_changed',
                default => 'upstream_error',
            };
            throw new ResolverException($kind, 'Resolver rejected the request.');
        }

        throw new ResolverException('unavailable', 'Retry budget exhausted.');
    }

    private function pause(int $attempt, ?string $retryAfter): void
    {
        $milliseconds = ctype_digit((string) $retryAfter)
            ? min(5000, (int) $retryAfter * 1000)
            : min(2000, 200 * (2 ** $attempt) + random_int(0, 100));

        ($this->sleep ?? static fn (int $ms) => usleep($ms * 1000))($milliseconds);
    }
}

Pretvorite razriješene podatke u kartice profila

Kontroler treba provjeravati CSRF tokene, razriješiti prije zapisivanja, pohraniti sirovi normalizirani JSON i zapisivati samo operativne metapodatke. Nikada nemojte zapisivati poslani URL ili vraćeni objekt identiteta: javni podaci i dalje mogu biti osjetljivi u zbiru.

U public/index.php pokrenite klijent, stvorite SQLite tablicu i obradite POST rutu:

<?php
use App\Identity\IdentityResolverClient;
use App\Identity\ResolverException;
use App\Infrastructure\CurlTransport;
use Dotenv\Dotenv;

require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv::createImmutable(dirname(__DIR__))->safeLoad();

session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
$escape = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');

$database = dirname(__DIR__) . '/' . ($_ENV['DATABASE_PATH'] ?? 'var/contacts.sqlite');
$pdo = new PDO('sqlite:' . $database, null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$pdo->exec('CREATE TABLE IF NOT EXISTS creators (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    display_name TEXT NOT NULL,
    platform TEXT NOT NULL,
    reference_type TEXT NOT NULL,
    source_reference TEXT NOT NULL,
    fingerprint TEXT NOT NULL,
    identity_json TEXT NOT NULL,
    updated_at TEXT NOT NULL,
    UNIQUE(platform, reference_type, source_reference)
)');

$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $requestId = bin2hex(random_bytes(8));
    try {
        if (!hash_equals($_SESSION['csrf'], (string) ($_POST['csrf'] ?? ''))) {
            throw new ResolverException('csrf', 'The form expired. Reload and try again.');
        }

        $name = trim((string) ($_POST['display_name'] ?? ''));
        if ($name === '' || strlen($name) > 120) {
            throw new ResolverException('validation', 'Enter a valid display name.');
        }

        $client = new IdentityResolverClient(
            new CurlTransport(),
            $_ENV['IDENTITY_RESOLVER_URL']
        );
        $identity = $client->resolve(
            (string) ($_POST['platform'] ?? ''),
            (string) ($_POST['reference_type'] ?? ''),
            (string) ($_POST['reference'] ?? '')
        );

        $statement = $pdo->prepare('INSERT INTO creators
            (display_name, platform, reference_type, source_reference,
             fingerprint, identity_json, updated_at)
            VALUES (:name, :platform, :type, :reference, :fingerprint, :json, :updated)
            ON CONFLICT(platform, reference_type, source_reference) DO UPDATE SET
                display_name = excluded.display_name,
                fingerprint = excluded.fingerprint,
                identity_json = excluded.identity_json,
                updated_at = excluded.updated_at');

        $statement->execute([
            'name' => $name,
            'platform' => strtolower((string) $_POST['platform']),
            'type' => (string) $_POST['reference_type'],
            'reference' => trim((string) $_POST['reference']),
            'fingerprint' => $identity->fingerprint(),
            'json' => json_encode($identity->payload, JSON_THROW_ON_ERROR),
            'updated' => gmdate(DATE_ATOM),
        ]);

        header('Location: /', true, 303);
        exit;
    } catch (ResolverException $exception) {
        $error = $exception->getMessage();
        error_log(json_encode([
            'event' => 'identity_resolution_failed',
            'request_id' => $requestId,
            'kind' => $exception->kind,
        ], JSON_THROW_ON_ERROR));
    }
}

$cards = $pdo->query('SELECT * FROM creators ORDER BY updated_at DESC')->fetchAll(PDO::FETCH_ASSOC);

Prikažite običan HTML obrazac s poljima nazvanima display_name, platform, reference_type, reference i skrivenim poljem csrf. Za svaki red dekodirajte identity_json, konstruirajte ResolvedIdentity i iterirajte kroz fields(). Escapeajte i oznake i vrijednosti pomoću kontrolerova zatvaranja $escape. Time se stvara jedan raspored kartice bez obzira na to koja je podržana mreža dostavila referencu.

Deterministički testirajte ponovne pokušaje i ponašanje granice

Lažni transport održava testove brzim i sprječava slučajne produkcijske pozive. Smjestite ovaj reprezentativni skup u tests/IdentityResolverClientTest.php:

<?php
namespace Tests;

use App\Identity\IdentityResolverClient;
use App\Identity\ResolverException;
use App\Infrastructure\HttpResponse;
use App\Infrastructure\HttpTransport;
use PHPUnit\Framework\TestCase;

final class FakeTransport implements HttpTransport
{
    public array $requests = [];
    public function __construct(private array $responses) {}

    public function get(string $url, array $query): HttpResponse
    {
        $this->requests[] = [$url, $query];
        return array_shift($this->responses);
    }
}

final class IdentityResolverClientTest extends TestCase
{
    public function testItPreservesTheNormalizedObject(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(200, [], '{"public":{"label":"Creator"}}'),
        ]);
        $client = new IdentityResolverClient($fake, 'https://example.test/resolve');

        $identity = $client->resolve('instagram', 'username', 'creator');

        self::assertSame('Creator', $identity->payload['public']['label']);
        self::assertSame(
            ['platform' => 'instagram', 'username' => 'creator'],
            $fake->requests[0][1]
        );
    }

    public function testItRetriesRateLimitingThenSucceeds(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(429, ['retry-after' => '1'], ''),
            new HttpResponse(200, [], '{"resolved":true}'),
        ]);
        $delays = [];
        $client = new IdentityResolverClient(
            $fake,
            'https://example.test/resolve',
            static function (int $ms) use (&$delays): void { $delays[] = $ms; }
        );

        self::assertTrue($client->resolve('linkedin', 'url', 'https://example.test/p')->payload['resolved']);
        self::assertCount(2, $fake->requests);
        self::assertSame([1000], $delays);
    }

    public function testItDoesNotRetryValidationFailures(): void
    {
        $fake = new FakeTransport([]);
        $client = new IdentityResolverClient($fake, 'https://example.test/resolve');

        $this->expectException(ResolverException::class);
        try {
            $client->resolve('unknown', 'username', 'creator');
        } finally {
            self::assertCount(0, $fake->requests);
        }
    }
}
vendor/bin/phpunit --testdox tests
php -l public/index.php
php -l src/Identity/IdentityResolverClient.php
php -l src/Identity/ResolvedIdentity.php

Sigurnost, opažljivost i implementacija

Društvene reference tretirajte kao nepouzdan unos iako resolver obrađuje javne identitete. Zadržite popise dopuštenih platformi i naziva parametara, ograničite veličinu unosa, escapeajte izlaz, koristite pripremljeni SQL, zaštitite upise CSRF-om i primijenite ulazno ograničenje stope na web-poslužitelju ili obrnutom proxyju. Nemojte proizvoljan korisnički unos pretvarati u neograničeno dohvaćanje URL-a na strani poslužitelja.

Zapisnici trebaju sadržavati ID korelacije, kategoriju neuspjeha, ishod pokušaja i latenciju, ali ne i profile payloadove ili pune poslane URL-ove. Pratite stope za rate_limited, transport, invalid_response i authentication_contract_changed. Iznenadni 401 ili 403 važan je jer se dokumentirani ugovor o javnom pristupu možda promijenio.

U produkciji ubrizgajte varijable okruženja preko hosting platforme, poslužujte samo public/, omogućite HTTPS, onemogućite opširan prikaz pogrešaka i osigurajte da se u var/ može pisati, ali da nije dostupan putem weba. SQLite treba trajnu pohranu i implementaciju svjesnu jednog zapisivača. Više replika aplikacije treba koristiti zajedničku transakcijsku bazu podataka.

Nemojte vanjsku uslugu učiniti dijelom provjere živosti. Lokalni endpoint zdravlja treba potvrditi da PHP i baza podataka rade; dostupnost uzvodne usluge pripada metrikama i pravilima spremnosti. Predmemorirajte nedavna uspješna razrješenja gdje zahtjevi proizvoda to dopuštaju i osvježavajte ih namjerno, umjesto razrješavanja pri svakom prikazu stranice.

Uobičajeni neuspjesi za koje vrijedi dizajnirati

  • HTTP 400 ili 422: platforma, vrsta parametra ili dostavljena referenca nisu valjani. Ispravite unos; nemojte ponovno pokušavati.
  • HTTP 404: javni identitet možda nije moguće razriješiti. Sačuvajte skicu kontakta i pozovite na ispravak.
  • HTTP 429: poštujte brojčani Retry-After unutar sigurne granice, a zatim stanite nakon proračuna ponovnih pokušaja.
  • HTTP 401 ili 403: provjerite službenu dokumentaciju. Nemojte izmišljati zaglavlja za autentikaciju.
  • Neispravan JSON ili korijen polja: klasificirajte to kao neuspjeh ugovora uzvodne usluge i ne pohranjujte ništa.
  • Vremenska ograničenja i privremeni odgovori 5xx: nakratko pokušajte ponovno s odgodom i jitterom, a zatim vratite stanje oporavljivog neuspjeha.

Završni kontrolni popis za provjeru

  • Aplikacija šalje točan GET zahtjev dokumentiranom endpointu /v1/resolve.
  • Svaki zahtjev sadrži jednu podržanu platformu i točno jedan podržani parametar reference.
  • Nema tokena, API ključa ni izmišljenog zaglavlja za autorizaciju.
  • Vremenska ograničenja povezivanja i odgovora su ograničena.
  • Ponovno se pokušavaju samo transportni neuspjesi, ograničenja stope i privremeni uzvodni neuspjesi.
  • Normalizirani JSON objekt prolazi kroz jednu provjerenu granicu aplikacije i escapea se prije prikaza.
  • Testovi koriste deterministički lažni transport i nikada ne pozivaju aktivnu uslugu.
  • Zapisnici isključuju društvene reference i vraćene payloadove identiteta.
  • Uspješno razrješenje i trajna pohrana u bazu podataka dovršavaju se prije nego što se pojavi kartica profila.

Trajna pouka veća je od jednog upravitelja kontaktima: normalizacija pripada granici sustava. Kada nedosljedne društvene reference postanu provjereni objekt identiteta, ostatak aplikacije može ostati ugodno običan. Kartice se prikazuju jednim putem, neuspjesi imaju korisna imena, ponovni pokušaji su kontrolirani, a buduće promjene API-ja ostaju ograničene na jednog malog klijenta koji se može testirati.

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.