Native PHP 8.3: Изградете менаџер за контакти на креатори со разрешување на идентитет преку социјални врски
Списокот со контакти на креатори изненадувачки брзо станува ненадежен. Едно лице може да пристигне како URL на Instagram, друго како профил на LinkedIn, а трето како гол идентификатор на Facebook. Ако апликацијата директно ги складира тие низи, секоја патека за увоз создава различна форма и секоја картичка на профил бара рендерирање за посебен случај.
Овој проект го решава тој граничен проблем во Native PHP 8.3. Испраќа јавни референци од социјални мрежи до Identity Resolver, го задржува нормализираниот објект за идентитет без да ги нагаѓа неговите недокументирани полиња и го претвора во конзистентни, безбедно рендерирани картички за креатори. Резултатот е мала апликација, но нејзиниот модел за истек на време, повторни обиди, валидација, тестирање, евидентирање и распоредување е соодветна основа за продукциска работа.
Добијте пристап пред да пишувате код за интеграција
Почнете со официјалната страница на услугата Identity Resolver, а потоа прочитајте ја официјалната документација. Тековната јавна крајна точка не бара ниту токен за сметка ниту API клуч. Следствено, нема акредитив што треба да се копира во овој проект.
- Прегледајте ја страницата на услугата за да потврдите дека Facebook, Instagram или LinkedIn го покриваат вашиот планиран влез.
- Отворете ја документацијата и потврдете го тековниот договор за барања пред распоредување.
- Платформата нуди и страници за регистрација и најава за функции на сметка, но ниту регистрацијата ниту најавата во моментов не се потребни за оваа јавна крајна точка.
- Не измислувајте API клуч и не испраќајте празно заглавие
Authorization. Ако подоцна се воведе автентикација, следете ја документацијата и складирајте ја издадената акредитива во конфигурација поддржана од околински променливи.
Точното барање е GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Прифаќа platform плус еден поддржан параметар username, id, identifier, profile или url. Направете го првиот тест со неосетлива јавна референца:
curl --fail-with-body --get \
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve' \
--data-urlencode 'platform=instagram' \
--data-urlencode 'username=example_creator'
Заменете го местодржачот со вистинска јавна референца која имате дозвола да ја обработувате. Успешниот одговор е JSON што го содржи нормализираниот јавен идентитет. Ќе го валидираме како објект наместо да претпоставуваме полиња во одговорот што не се дел од доставениот договор.
Нема акредитив што треба да се стави во .env. Складирајте само конфигурација подготвена за распоредување:
IDENTITY_RESOLVER_URL=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
DATABASE_PATH=var/contacts.sqlite
Предуслови и структура на проектот
Потребни ви се PHP 8.3 или понов, Composer, природни екстензии cURL, JSON, PDO и SQLite. SQLite го прави упатството извршливо на една машина; распоредување со повеќе инстанци треба да го замени со споделена база на податоци, притоа задржувајќи ја истата граница на резолверот.
{
"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
Важното раздвојување е мало и намерно:
CurlTransportе одговорен за мрежната механика и ограничените истеци на време.IdentityResolverClientе одговорен за договорот за далечинското барање и политиката за повторни обиди.ResolvedIdentityго валидира и носи непрозирниот нормализиран објект.public/index.phpсе справува со влезот, перзистенцијата, евидентирањето и презентацијата.
Овој дизајн чини неколку класи, но спречува деталите за cURL и несигурните далечински податоци да се распространат низ апликацијата.
Изградете ја HTTP границата
Создадете src/Infrastructure/Http.php. Транспортот дозволува само HTTPS, не следи пренасочувања, има одделни истеци на време за поврзување и вкупно време, и никогаш не додава автентикација:
<?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
);
}
}
Дефанзивно мапирајте го нормализираниот идентитет
Апликацијата не смее тивко да зависи од својства на одговорот што не се гарантирани. Затоа DTO бара JSON објект, го зачувува без загуби и обезбедува ограничена листа од скаларни листови за картичката. Секоја ознака и вредност сепак ќе биде екранувана при рендерирање.
Создадете 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;
}
}
Отпечатокот е детерминистички отпечаток на содржината, а не тврдење за одредено поле на далечински идентификатор. Базата на податоци исто така ги чува оригиналната платформа и референца, за да може постоечкиот контакт да се ажурира кога се менуваат јавните податоци на профилот.
Додајте повторни обиди без да создадете бура од повторни обиди
Создадете src/Identity/IdentityResolverClient.php. Тој повторно се обидува при грешки во транспортот, HTTP 429 и привремени грешки на серверот. Неуспесите на валидација, резолуција и автентикација веднаш враќаат одговор бидејќи повторувањето на истото барање не може да ги поправи.
<?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);
}
}
Претворете ги разрешените податоци во картички за профили
Контролерот треба да ги валидира CSRF токените, да разреши пред запишување, да складира суров нормализиран JSON и да евидентира само оперативни метаподатоци. Никогаш не го евидентирајте испратениот URL или вратениот објект за идентитет: јавните податоци сè уште можат да бидат чувствителни во агрегат.
Во public/index.php, иницијализирајте го клиентот, создајте SQLite табела и обработете ја POST рутата:
<?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);
Рендерирајте обична HTML форма со полиња именувани display_name, platform, reference_type, reference и скриено csrf. За секој ред, декодирајте identity_json, конструирајте ResolvedIdentity и итерирајте низ fields(). Екранирајте ги и ознаките и вредностите со затворањето $escape на контролерот. Ова создава еден распоред на картичка без оглед на тоа која поддржана мрежа ја доставила референцата.
Тестирајте ги повторните обиди и однесувањето на границата детерминистички
Лажен транспорт ги одржува тестовите брзи и спречува случајни продукциски повици. Ставете го овој репрезентативен пакет во 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
Безбедност, набљудливост и распоредување
Третирајте ги референците од социјалните мрежи како недоверлив влез, иако резолверот обработува јавни идентитети. Задржете ги листите на дозволени платформи и имиња на параметри, ограничете ја големината на влезот, екранувајте го излезот, користете подготвен SQL, заштитете ги запишувањата со CSRF и применете ограничување на дојдовниот број барања на веб-серверот или обратниот прокси. Не претворајте произволен кориснички влез во неограничено серверско преземање URL-адреси.
Дневниците треба да содржат ID за корелација, категорија на неуспех, исход од обидот и латентност, но не и носивост на профилите или целосни испратени URL-адреси. Следете ги стапките на rate_limited, transport, invalid_response и authentication_contract_changed. Ненадеен 401 или 403 е важен бидејќи документираниот договор за јавен пристап можеби се променил.
Во продукција, внесете ги околинските променливи преку хостинг-платформата, послужувајте само public/, овозможете HTTPS, оневозможете прикажување на детални грешки и осигурете се дека var/ е запишлив, но недостапен преку веб. SQLite има потреба од трајно складиште и распоредување свесно за еден запишувач. Повеќе реплики на апликацијата треба да користат споделена трансакциска база на податоци.
Не правете ја надворешната услуга дел од проверка на живост. Локалната здравствена крајна точка треба да потврди дека PHP и базата на податоци работат; достапноста на надворешната услуга припаѓа во метриките и политиката на подготвеност. Кеширајте ги неодамнешните успешни резолуции каде што тоа го дозволуваат барањата на производот и освежувајте намерно, наместо да разрешувате при секое прикажување страница.
Вообичаени неуспеси за кои вреди да се дизајнира
- HTTP 400 или 422: платформата, типот на параметарот или доставената референца се неважечки. Поправете го влезот; не обидувајте се повторно.
- HTTP 404: јавниот идентитет можеби не може да се разреши. Зачувајте ја нацрт-верзијата на контактот и побарајте исправка.
- HTTP 429: почитувајте нумерички
Retry-Afterво безбедна граница, а потоа запрете по буџетот за повторни обиди. - HTTP 401 или 403: проверете ја официјалната документација. Не измислувајте заглавија за автентикација.
- Невалиден JSON или корен во форма на низа: класифицирајте го како неуспех на договорот со надворешниот извор и не складирајте ништо.
- Истеци на време и привремени 5xx одговори: накратко обидете се повторно со backoff и jitter, а потоа вратете состојба на поправлив неуспех.
Контролна листа за конечна проверка
- Апликацијата испраќа точно GET барање до документираната крајна точка
/v1/resolve. - Секое барање содржи една поддржана платформа и точно еден поддржан референтен параметар.
- Не е присутен токен, API клуч или измислено заглавие за авторизација.
- Истеците на време за поврзување и одговор се ограничени.
- Се повторуваат само неуспеси во транспортот, ограничување на бројот барања и привремени неуспеси на надворешниот извор.
- Нормализираниот JSON објект преминува една валидирана граница на апликацијата и се екранува пред прикажување.
- Тестовите користат детерминистички лажен транспорт и никогаш не ја повикуваат услугата во живо.
- Дневниците ги исклучуваат референците од социјалните мрежи и вратените носивости на идентитетот.
- Успешната резолуција и перзистенцијата во базата на податоци завршуваат пред да се појави картичка на профил.
Трајната поука е поголема од еден менаџер за контакти: нормализацијата припаѓа на границата. Откако неконзистентните референци од социјалните мрежи ќе станат валидиран објект за идентитет, остатокот од апликацијата може да остане пријатно обичен. Картичките се рендерираат низ една патека, неуспесите имаат корисни имиња, повторните обиди се контролирани, а идните промени во API остануваат ограничени на еден мал клиент што може да се тестира.