Native PHP: Стандардизирајте ги врските од директориумот на заедницата со разрешување на идентитет со ВИ
Директориумот на заедницата ретко добива чисти податоци за профили на социјални мрежи. Еден член лепи целосна URL-адреса на Instagram, друг внесува идентификатор на LinkedIn, а некој друг доставува Facebook профил во формат копиран од мобилен прелистувач. Зачувувањето на тие вредности дословно создава дупликат записи, неконзистентни врски и кршлива логика за прикажување.
Identity Resolver го решава тој граничен проблем. Овој туторијал гради продукциски ориентиран Native PHP 8.3 endpoint што прифаќа референци за Facebook, Instagram и LinkedIn, ги разрешува преку една надворешна услуга, дефанзивно го валидира одговорот и зачувува стабилен идентитетски објект во мал директориум на заедницата.
Добијте пристап пред да пишувате интеграциски код
Започнете со страницата за услугата и плановите на Identity Resolver, па потоа прочитајте ја официјалната документација за услугата. Тековниот јавен endpoint не бара сметка, претплатнички токен или API клуч.
- Регистрација: регистрацијата не е потребна за тековниот јавен endpoint.
- Најава: нема чекор за најава пред првото барање.
- Локација на токенот: нема токен за копирање и нема заглавие за авторизација за конфигурирање.
Оваа разлика е важна од оперативен аспект. Не измислувајте празен bearer токен и не зачувувајте placeholder заглавие за авторизација во commit. Ако подоцна се воведе автентикација, следете ја тогаш важечката документација и сместете ја акредитацијата во конфигурација поддржана од променливи на околината наместо во изворниот код.
Точното барање е GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Испратете platform плус еден поддржан референтен параметар: username, id, identifier, profile, или url.
Направете минимален тест користејќи вистинска URL-адреса на јавен профил што ви е дозволено да ја обработувате:
curl --get \
"https://ai.mihajlo.mk/api/identity-resolver/v1/resolve" \
--data-urlencode "platform=instagram" \
--data-urlencode "url=YOUR_PUBLIC_PROFILE_URL"
Не треба да се зачувува никаква акредитација пред да продолжите. Зачувајте само runtime поставки што не се тајни во .env:
IDENTITY_RESOLVER_ENDPOINT=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
IDENTITY_RESOLVER_CONNECT_TIMEOUT_MS=1500
IDENTITY_RESOLVER_TIMEOUT_MS=5000
Архитектура и компромиси
Апликацијата има четири граници: HTTP контролер ги валидира поднесувањата, клиентот за resolver го поседува однесувањето на оддалечената услуга, transport го изолира cURL, а DTO го мапира оддалечениот JSON во доменскиот модел на директориумот. SQLite го прави примерот применлив за фриленсер или мал тим на заедницата, додека барањето во repository подоцна може да се префрли на PostgreSQL без да се менува resolver-от.
Разрешувањето се случува синхроно за подносителот да добие моментален резултат. Тоа е соодветно додека сообраќајот е умерен и горната граница од пет секунди е прифатлива. Подинамичен директориум треба да зачува поднесување во чекање и да го разреши во worker, но прераното додавање редица би ја замаглило мааната семантика на неуспесите.
Користете PHP 8.3 со екстензиите cURL, JSON, PDO и PDO SQLite, како и Composer и PHPUnit 11. Распоредот на проектот е:
community-directory/
├── .env
├── composer.json
├── database/schema.sql
├── public/normalize.php
├── src/Http/{CurlTransport,HttpResponse,Transport,TransportException}.php
├── src/Identity/{IdentityResolver,ResolverFailure,ResolvedIdentity}.php
├── tests/IdentityResolverTest.php
└── var/
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"Directory\\": "src/"
}
}
}
composer install
set -a
. ./.env
set +a
Изградете ограничен cURL transport
Transport-от поставува фиксни временски ограничувања за поврзување и вкупно траење, ги оневозможува пренасочувањата, дозволува само HTTPS и враќа статус, заглавија и тело без да се обидува да разбере податоци за идентитет. Ставете ја секоја класа подолу во соодветно именуваната датотека под src/Http.
<?php
// HttpResponse.php
namespace Directory\Http;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
// Transport.php
namespace Directory\Http;
interface Transport
{
public function get(
string $url,
array $query,
int $connectTimeoutMs,
int $timeoutMs,
): HttpResponse;
}
// TransportException.php
namespace Directory\Http;
final class TransportException extends \RuntimeException {}
// CurlTransport.php
namespace Directory\Http;
final class CurlTransport implements Transport
{
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_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_HEADERFUNCTION => static function (
$curl,
string $line
) use (&$headers): int {
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$headers[strtolower(trim($name))] = trim($value);
}
return strlen($line);
},
]);
$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);
}
}
Дефанзивно разрешувајте и мапирајте идентитети
Договорот за услугата ветува нормализиран одговор за јавен идентитет, но кодот на апликацијата не треба да нагаѓа недокументирани полиња. Затоа DTO го обвива целосниот вратен објект во обвивка во сопственост на апликацијата. Потрошувачите можат да го зачуваат и вратат без да се поврзуваат со претпоставени имиња на својства.
Клиентот повторува само при мрежни грешки, HTTP 429 и грешки на серверот. Грешките при валидација и автентикација не се повторуваат. Максимумот е три обиди, со кратко експоненцијално повлекување или ограничена нумеричка вредност Retry-After.
<?php
namespace Directory\Identity;
use Directory\Http\Transport;
use Directory\Http\TransportException;
final readonly class ResolvedIdentity
{
public function __construct(
public string $platform,
public string $submitted,
public array $identity,
) {}
public function toArray(): array
{
return [
'platform' => $this->platform,
'submitted' => $this->submitted,
'identity' => $this->identity,
];
}
}
final class ResolverFailure extends \RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly bool $retryable,
public readonly ?int $status = null,
) {
parent::__construct($kind);
}
}
final class IdentityResolver
{
private const PLATFORMS = ['facebook', 'instagram', 'linkedin'];
private const REFERENCES = [
'username', 'id', 'identifier', 'profile', 'url'
];
private \Closure $sleep;
public function __construct(
private readonly Transport $transport,
private readonly string $endpoint,
private readonly int $connectTimeoutMs = 1500,
private readonly int $timeoutMs = 5000,
?\Closure $sleep = null,
) {
$this->sleep = $sleep
?? static fn (int $microseconds) => usleep($microseconds);
}
public function resolve(
string $platform,
string $referenceType,
string $value,
): ResolvedIdentity {
$platform = strtolower(trim($platform));
$value = trim($value);
if (!in_array($platform, self::PLATFORMS, true)) {
throw new \InvalidArgumentException('Unsupported platform');
}
if (!in_array($referenceType, self::REFERENCES, true)) {
throw new \InvalidArgumentException('Unsupported reference type');
}
if ($value === '' || strlen($value) > 2048) {
throw new \InvalidArgumentException('Invalid reference value');
}
for ($attempt = 0; $attempt < 3; $attempt++) {
try {
$response = $this->transport->get(
$this->endpoint,
['platform' => $platform, $referenceType => $value],
$this->connectTimeoutMs,
$this->timeoutMs,
);
} catch (TransportException) {
if ($attempt === 2) {
throw new ResolverFailure('network_failure', true);
}
($this->sleep)(100000 * (2 ** $attempt));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$data = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException) {
throw new ResolverFailure('invalid_response', false);
}
if (!is_array($data) || array_is_list($data)) {
throw new ResolverFailure('invalid_response', false);
}
return new ResolvedIdentity($platform, $value, $data);
}
$retryable = $response->status === 429
|| $response->status >= 500;
if ($retryable && $attempt < 2) {
$retryAfter = $response->headers['retry-after'] ?? '';
$delay = ctype_digit($retryAfter)
? min((int) $retryAfter, 2) * 1000000
: 100000 * (2 ** $attempt);
($this->sleep)($delay);
continue;
}
$kind = match (true) {
$response->status === 429 => 'rate_limited',
in_array($response->status, [401, 403], true)
=> 'authentication_failure',
$response->status >= 500 => 'upstream_unavailable',
default => 'rejected',
};
throw new ResolverFailure(
$kind,
$retryable,
$response->status
);
}
throw new ResolverFailure('upstream_unavailable', true);
}
}
Зачувајте нормализиран запис во директориумот
Креирајте ја шемата на базата на податоци со ограничување за единственост што ги опфаќа платформата, типот на референца и поднесената вредност. Повторените поднесувања го ажурираат разрешениот објект наместо да создаваат дупликати.
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS directory_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
reference_type TEXT NOT NULL,
submitted_value TEXT NOT NULL,
identity_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (platform, reference_type, submitted_value)
);
mkdir -p var
sqlite3 var/directory.sqlite < database/schema.sql
Контролерот прифаќа JSON во сопственост на нашата апликација, го повикува resolver-от и го зачувува нормализираниот одговор. Тој евидентира категории на неуспех и статусни кодови, но никогаш вредности на профили или upstream тела.
<?php
declare(strict_types=1);
use Directory\Http\CurlTransport;
use Directory\Identity\IdentityResolver;
use Directory\Identity\ResolverFailure;
require dirname(__DIR__) . '/vendor/autoload.php';
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'method_not_allowed']);
exit;
}
try {
$input = json_decode(
file_get_contents('php://input'),
true,
512,
JSON_THROW_ON_ERROR
);
foreach (['platform', 'referenceType', 'value'] as $field) {
if (!isset($input[$field]) || !is_string($input[$field])) {
throw new InvalidArgumentException('Invalid input');
}
}
$resolver = new IdentityResolver(
new CurlTransport(),
getenv('IDENTITY_RESOLVER_ENDPOINT')
?: 'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
(int) (getenv('IDENTITY_RESOLVER_CONNECT_TIMEOUT_MS') ?: 1500),
(int) (getenv('IDENTITY_RESOLVER_TIMEOUT_MS') ?: 5000),
);
$identity = $resolver->resolve(
$input['platform'],
$input['referenceType'],
$input['value'],
);
$pdo = new PDO(
'sqlite:' . dirname(__DIR__) . '/var/directory.sqlite',
null,
null,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$pdo->exec('PRAGMA busy_timeout = 3000');
$statement = $pdo->prepare(
'INSERT INTO directory_identities
(platform, reference_type, submitted_value, identity_json, updated_at)
VALUES (:platform, :type, :value, :identity, :updated)
ON CONFLICT(platform, reference_type, submitted_value)
DO UPDATE SET identity_json = excluded.identity_json,
updated_at = excluded.updated_at'
);
$statement->execute([
'platform' => $identity->platform,
'type' => $input['referenceType'],
'value' => $identity->submitted,
'identity' => json_encode(
$identity->identity,
JSON_THROW_ON_ERROR
),
'updated' => gmdate('c'),
]);
http_response_code(201);
echo json_encode($identity->toArray(), JSON_THROW_ON_ERROR);
} catch (InvalidArgumentException | JsonException $exception) {
http_response_code(422);
echo json_encode(['error' => 'invalid_submission']);
} catch (ResolverFailure $exception) {
error_log(json_encode([
'event' => 'identity_resolution_failed',
'kind' => $exception->kind,
'status' => $exception->status,
'retryable' => $exception->retryable,
]));
http_response_code($exception->retryable ? 503 : 502);
echo json_encode(['error' => $exception->kind]);
} catch (Throwable $exception) {
error_log(json_encode(['event' => 'directory_write_failed']));
http_response_code(500);
echo json_encode(['error' => 'internal_error']);
}
Тестирајте повторни обиди без мрежни повици
Детерминистички лажен transport ги одржува тестовите брзи и ги докажува и мапирањето на одговорот и политиката за повторни обиди. Инјектираниот sleeper спречува вистински доцнења.
<?php
use Directory\Http\HttpResponse;
use Directory\Http\Transport;
use Directory\Identity\IdentityResolver;
use Directory\Identity\ResolverFailure;
use PHPUnit\Framework\TestCase;
final class IdentityResolverTest extends TestCase
{
public function testRetriesServerFailureThenMapsObject(): void
{
$fake = new class([
new HttpResponse(503, [], '{}'),
new HttpResponse(200, [], '{"stable":"identity"}'),
]) implements Transport {
public int $calls = 0;
public function __construct(private array $responses) {}
public function get(
string $url,
array $query,
int $connectTimeoutMs,
int $timeoutMs
): HttpResponse {
$this->calls++;
return array_shift($this->responses);
}
};
$resolver = new IdentityResolver(
$fake,
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve',
sleep: static fn (int $delay) => null,
);
$result = $resolver->resolve(
'instagram',
'url',
'https://www.instagram.com/example/'
);
self::assertSame(['stable' => 'identity'], $result->identity);
self::assertSame(2, $fake->calls);
}
public function testDoesNotRetryClientRejection(): void
{
$fake = new class implements Transport {
public int $calls = 0;
public function get(
string $url,
array $query,
int $connectTimeoutMs,
int $timeoutMs
): HttpResponse {
$this->calls++;
return new HttpResponse(400, [], '{}');
}
};
try {
(new IdentityResolver(
$fake,
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
))->resolve('facebook', 'username', 'example');
self::fail('Expected ResolverFailure');
} catch (ResolverFailure $failure) {
self::assertSame('rejected', $failure->kind);
self::assertSame(1, $fake->calls);
}
}
}
vendor/bin/phpunit tests
php -S 127.0.0.1:8080 -t public
curl -X POST "http://127.0.0.1:8080/normalize.php" \
-H "Content-Type: application/json" \
-d '{"platform":"linkedin","referenceType":"url","value":"YOUR_PUBLIC_PROFILE_URL"}'
Безбедност, набљудливост и распоредување
Иако resolver-от е јавен, поднесените референци на профили сè уште се кориснички податоци. Применете ограничувања за големината на барањата на веб-серверот, авторизирајте уредувања на директориумот, додадете CSRF заштита ако сесија во прелистувач го повикува овој endpoint и избегнувајте евидентирање на поднесени URL-адреси или вратени идентитетски документи. Фиксниот endpoint на resolver-от исто така спречува server-side барања контролирани од корисникот.
Изложете метрики за обиди, успешни разрешувања, латентност, ограничувања на стапка, upstream неуспеси, невалидни одговори и неуспеси при запишување во директориумот. Користете ознаки со мала кардиналност, како вид на неуспех и платформа; никогаш не користете кориснички имиња или URL-адреси како ознаки на метрики.
При распоредување, инсталирајте ги продукциските зависности со composer install --no-dev --classmap-authoritative, обезбедете го запишливиот директориум var, извршете ја шемата пред да го префрлите сообраќајот, инјектирајте ги трите поставки на околината и послужете го public како document root. Повеќе application hosts треба да користат споделена продукциска база на податоци наместо одделни SQLite датотеки.
Вообичаени неуспеси
- HTTP 400 или друго одбивање од клиентот: проверете ја платформата и дека се испраќа точно еден поддржан референтен параметар.
- HTTP 401 или 403: документираниот јавен endpoint не бара токен, па проверете ги endpoint-от, proxy-то и тековната официјална документација. Не повторувајте наслепо.
- HTTP 429: почитувајте ограничено повлекување и вратете retryable application failure по достигнување на ограничувањето на обиди.
- HTTP 5xx или мрежен timeout: повторете накратко, евидентирајте ја категоријата на неуспех и избегнувајте неограничено задржување на PHP worker.
- Успешен статус со неисправен JSON: одбијте го на API границата наместо да зачувате делумни или претпоставени полиња.
- SQLite заклучување: одржувајте ги запишувањата кратки, овозможете WAL и busy timeout или преминете на споделена база на податоци како што расте паралелноста.
Конечна листа за проверка
- Документацијата за услугата е прегледана и не е конфигуриран непотребен токен.
- Поднесувањата за Facebook, Instagram и LinkedIn го користат точниот HTTPS GET endpoint.
- До надворешната услуга стигнуваат само имиња на поддржани референтни параметри.
- Временските ограничувања и ограничувањето од три обиди за повторување се активни.
- Неуспесите на клиентот и автентикацијата не се повторуваат.
- Неисправните одговори не можат да влезат во директориумот.
- Логовите содржат метаподатоци за неуспех, но немаат референци на профили или тела на одговори.
- PHPUnit поминува без надворешен мрежен пристап.
- Повторено поднесување ажурира еден запис во директориумот.
- Вистински дозволен јавен профил се разрешува правилно во распоредена околина.
Нормализацијата е највредна кога станува гранично правило наместо задача за чистење. Штом секоја социјална референца влегува во директориумот преку еден дефанзивен resolver, остатокот од апликацијата може да работи со стабилни идентитетски објекти наместо повторно да открива секој чуден начин на кој едно лице може да залепи врска.