Туториали

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

Native PHP 8.3: Обединување на врските од директориумот на социјалните мрежи со AI разрешувач на идентитети

Директориумот на заедницата често започнува со неколку безопасни текстуални полиња. Потоа соработниците вметнуваат мобилни Facebook URL-адреси, врски до Instagram профили со параметри за следење, варијанти на LinkedIn и понекогаш само идентификатори. Ако тие вредности се зачуваат непроменети, пребарувањето, отстранувањето дупликати и прикажувањето профили стануваат сè понесигурни.

Овој туторијал создава Native PHP 8.3 крајна точка што прифаќа врски до профили на Facebook, Instagram и LinkedIn, ги разрешува преку Identity Resolver и зачувува конзистентен доменски објект во SQLite. Интеграцијата користи вграден cURL, ограничени повторни обиди, дефанзивно мапирање одговори, структурирани логови и детерминистички PHPUnit тестови.

Добијте пристап пред да напишете код за интеграција

Започнете со официјалната документација за Identity Resolver. Таа ги дефинира поддржаните влезови и е авторитативното место за проверка дали се промениле условите за пристап.

Тековната јавна крајна точка не бара токен за сметка или API клуч. Следствено, нема акредитив што треба да се копира во PHP, заглавие за авторизација што треба да се состави или чекор за избор на план пред првото барање. Редоследот за воведување е:

  1. Отворете ја документацијата и потврдете дека крајната точка сè уште е јавна.
  2. Прегледајте ја страницата за услугата и плановите за тековни детали за услугата.
  3. Бидејќи во моментов не е потребна сметка, документацијата служи како официјално упатство за регистрација: нема дејство за регистрација за оваа крајна точка.
  4. Исто така, консултирајте ја страницата за најава и статус на сметка, но не чекајте токен ниту измислувајте API клуч.

Точниот повик е GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Прифаќа platform плус поддржан параметар username, id, identifier, profile или url. Нашиот директориум веќе собира врски, па доследно ќе испраќа platform и url.

Направете минимален тест пред да ја изградите функционалноста:

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'

Нема заглавие Authorization. Проверете го живиот одговор во однос на документацијата, наместо да претпоставувате недокументирани полиња.

Изберете мала, сигурна архитектура

Апликацијата има четири граници: HTTP влезна точка, локална валидација на URL, клиент за Identity Resolver и трајно складирање. Разрешувањето се случува пред трансакцијата со базата, со што заклучувањето за запишување во SQLite останува кратко додека надворешното барање е во тек.

Одговорот од надворешната услуга намерно се зачувува како непрозирен JSON објект. Нашата апликација додава сопствени полиња—platform, source_url, identity_key и public_identity—без да тврди дека тие имиња постојат во одговорот на услугата. Оваа граница преживува дополнителни промени во одговорот и избегнува поврзување на деловниот код со полиња што не се гарантирани од дадениот договор.

Користете го следниов распоред на проектот:

community-directory/
├── composer.json
├── .env.example
├── public/
│   └── index.php
├── src/
│   └── IdentityResolver.php
├── tests/
│   └── IdentityResolverTest.php
└── var/
    └── directory.sqlite

Создадете composer.json со PHP 8.3, потребните екстензии, classmap autoloading и PHPUnit 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

Конфигурирајте ја околината без да измислувате акредитив за услугата

Native PHP не вчитува автоматски датотека .env. Користете ја локално преку вашиот управувач со процеси или shell и конфигурирајте ги истите променливи директно во PHP-FPM или вашата платформа за распоредување во продукција.

Создадете .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 ја заштитува вашата сопствена крајна точка за поднесување; тоа не е токен за Identity Resolver. Намерно нема променлива за API клуч за надворешната услуга. Генерирајте силен апликациски токен надвор од контрола на изворниот код, чувајте го вистинскиот .env надвор од складиштето и внесувајте ги неговите вредности при извршување.

Изградете ја cURL границата и маперот на доменот

Ставете го следново во src/IdentityResolver.php. Транспортот наметнува HTTPS, ги оневозможува пренасочувањата, го проверува TLS користејќи ги стандардните поставки на cURL, го ограничува времето за поврзување и вкупното време и прекинува одговори поголеми од 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');
    }
}

Се повторуваат само неуспеси во транспортот, HTTP 429 и серверски грешки. Валидациските, пристапните и другите клиентски грешки веднаш не успеваат. Одложувањата се ограничени на 200 и 400 милисекунди бидејќи интерактивното поднесување во директориум не треба да чека неограничено.

Прифатете и зачувајте поднесување во директориумот

Распоредете ја шемата еднаш:

CREATE TABLE directory_submissions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    identities_json TEXT NOT NULL,
    created_at TEXT NOT NULL
);

Потоа создајте public/index.php. Бара bearer токен за апликацијата, ја ограничува големината на барањето, локално ја валидира секоја URL-адреса, ги разрешува сите три идентитети и зачувува само откако секое разрешување ќе успее.

<?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']);
}

Тестирајте без да ја повикувате јавната услуга

Лажен транспорт ги прави повторните обиди и класификацијата на неуспеси детерминистички. Зачувајте го ова како 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

Безбедност, набљудливост и распоредување

Завршете HTTPS на веб-серверот, изложете само public/ како корен на документи и извршувајте PHP-FPM како корисник што може да запишува само во директориумот на SQLite базата. Чувајте ги развојните пакети на Composer и датотеките со околински променливи надвор од јавната структура.

Крајната точка проверува точни домени и поддомени, што ги блокира хостовите како facebook.com.attacker.example. Пренасочувањата се оневозможени на границата со надворешната услуга и е дозволен само HTTPS. Услугата прима URL-адреси на јавни профили, но тие вредности сепак може да бидат чувствителни во агрегат; затоа логовите бележат платформа, статус, обид и вид на неуспех без да бележат поднесени URL-адреси или тела на одговори.

Испраќајте структурирани логови до вашиот вообичаен собирач на логови и поставете известувања за трајни identity_resolver_transport_failure, identity_resolver_retry или зголемени одговори 503. HTTP 429 треба да се третира како притисок врз капацитетот, а не како доказ дека барањето е невалидно. При поголем обем, преместете го разрешувањето во ограничена редица во позадина и направете поднесувањата изречно да бидат на чекање, наместо да го зголемувате бројот на синхрони повторни обиди.

Распоредете со репродуцируеми зависности, извршете ја миграцијата на шемата пред да префрлите сообраќај и потврдете дека продукцискиот процес ги прима сите три околински променливи:

composer install --no-dev --classmap-authoritative
vendor/bin/phpunit tests
php -l src/IdentityResolver.php
php -l public/index.php

Вообичаени неуспеси и конечна проверка

  • Секое барање враќа 401: токенот за запишување во директориумот недостига или повикувачот изоставил Authorization: Bearer YOUR_DIRECTORY_WRITE_TOKEN. Ова е безбедност на локалната апликација, а не автентикација на услугата.
  • Врска што изгледа валидно враќа 422: потврдете HTTPS, спарувањето платформа-домен и моментално поддржаните формати на референци во официјалната документација.
  • Одговорите стануваат 503: прегледајте ги структурираните настани за истекувања на време, HTTP 429 или серверски грешки од надворешната услуга. Не ги претворајте овие во трајни неуспеси на валидацијата.
  • SQLite пријавува грешка при запишување: потврдете дека директориумот на базата постои и е запишлив за PHP-FPM, додека останува недостапен од веб-коренот.
  • Тестовите случајно ја достигнуваат мрежата: конструирајте го resolver-от со FakeTransport; интеграциските тестови против јавната крајна точка треба да бидат одделни и изречно овозможени.

Пред објавување, потврдете дека:

  • Документацијата сè уште вели дека на крајната точка не ѝ е потребен токен или API клуч.
  • Барањето точно користи GET, документираната крајна точка, platform и url.
  • Врските на Facebook, Instagram и LinkedIn секоја се разрешуваат успешно.
  • Неправилен домен се отфрла пред каков било надворешен повик.
  • HTTP 400 не се повторува, додека 429 и серверските неуспеси добиваат ограничени повторни обиди.
  • Во логовите не се појавуваат поднесена URL-адреса, тело на одговор, bearer токен или непостоечки клуч за услуга.
  • Базата ги зачувува сите три нормализирани јавни објекти за идентитет атомски.

Важниот резултат не се само почисти URL-адреси. Директориумот сега има намерна граница на идентитетот: неуредните јавни референци влегуваат од едната страна, додека стабилните апликациски објекти излегуваат од другата. Токму таа граница ги спречува утрешните функции за пребарување, отстранување дупликати и профили да го наследат денешниот неконзистентен влез.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.