Laravel Identity Resolver: Нормализирајте ги социјалните врски за вашиот директориум на заедницата
Директориумот на заедницата изгледа уредно сè додека членовите не го внесат истиот социјален идентитет во половина дузина облици: мобилни врски од Facebook, URL-адреси на Instagram со низи за пребарување или регионални адреси на LinkedIn. Директното споредување на тие низи создава дупликати, кршливо пребарување и незгодна модерација.
Вистинската граница не е уште еден куп регуларни изрази. Овој туторијал гради Laravel интеграција што прифаќа врски до профили на Facebook, Instagram и LinkedIn, ги испраќа до Identity Resolver и го складира добиениот нормализиран јавен идентитет како стабилен JSON објект. Поднесувањата влегуваат преку брз контролер; нормализацијата се извршува на Laravel редицата со ограничени временски ограничувања, намерни повторни обиди, структурирани неуспеси и детерминистички тестови.
Предуслови
Ви треба PHP 8.3 или понов, Composer, Laravel апликација со автентикација, поддржана база на податоци и функционален backend за редица. Примерот ја користи Laravel database редицата за проектот да остане самостоен, но Redis може да ја замени без промена на кодот на доменот.
Апликацијата ќе ги складира поднесените URL-адреси бидејќи на членовите на директориумот им треба преглед на она што го внеле. Третирајте ги тие URL-адреси како лични податоци: ограничете го пристапот, дефинирајте правила за задржување и никогаш не ги вклучувајте во оперативните дневници.
Добијте пристап пред да пишувате интеграциски код
Започнете на страницата за услугата и планот Identity Resolver, а потоа прочитајте ја официјалната документација за услугата. Документацијата е и авторитативна контролна точка за регистрација и контролна точка за најава за оваа интеграција.
Тековната јавна крајна точка не бара токен за сметка или API клуч. Следствено, нема екран за ингеренции, нема токен за копирање и нема заглавие за автентикација што треба да се испрати пред првото барање. Не измислувајте празен bearer токен: некои проксија различно го третираат празното заглавие Authorization од неговото отсуство.
Точната операција е GET https://ai.mihajlo.mk/api/identity-resolver/v1/resolve. Таа прифаќа platform плус поддржан параметар username, id, identifier, profile или url. Нашиот директориум прифаќа врски, па доследно испраќа platform и url.
Направете минимален тест со URL-адреса на јавен профил што имате дозвола да ја обработувате:
curl --get \
--data-urlencode "platform=instagram" \
--data-urlencode "url=https://www.instagram.com/example/" \
"https://ai.mihajlo.mk/api/identity-resolver/v1/resolve"
Успешниот повик го враќа одговорот со нормализираниот јавен идентитет. Јавниот договор даден тука не гарантира поединечни имиња на JSON полиња, па апликацијата ќе потврди дека примила непразен JSON објект и ќе го зачува тој објект без да ја нагаѓа неговата шема.
Нема ингеренција што треба да се стави во .env. Складирајте ја самата крајна точка во конфигурација поддржана од околински променливи и оставете ја автентикацијата отсутна:
IDENTITY_RESOLVER_ENDPOINT=https://ai.mihajlo.mk/api/identity-resolver/v1/resolve
QUEUE_CONNECTION=database
Додајте го следниов запис во config/services.php:
'identity_resolver' => [
'endpoint' => env(
'IDENTITY_RESOLVER_ENDPOINT',
'https://ai.mihajlo.mk/api/identity-resolver/v1/resolve'
),
],
Архитектура и компромиси
Патеката на барањето не треба да чека надворешна услуга. Контролерот ги валидира платформата и хостот, создава запис во чекање, испраќа задача и враќа HTTP 202. Задачата повикува посветен API клиент и го менува записот во resolved или failed.
Ова воведува евентуална конзистентност: новоподнесениот профил кратко време е во чекање. За возврат, бавен resolver не може да ги потроши сите PHP работници ниту да направи формуларот да изгледа расипан. Записот во базата на податоци исто така станува експлицитна машина на состојби, наместо делумно завршените барања да останат невидливи.
Релевантната структура на проектот е:
app/Domain/Identity/ResolvedIdentity.phpapp/Services/IdentityResolverClient.phpapp/Jobs/ResolveSocialProfile.phpapp/Http/Controllers/StoreCommunityProfileController.phpapp/Models/SocialProfile.phpdatabase/migrations/..._create_social_profiles_table.phptests/Unit/IdentityResolverClientTest.phptests/Feature/StoreCommunityProfileTest.php
Зачувајте го животниот циклус на нормализацијата
Создајте ги моделот, миграцијата, контролерот и задачата со Artisan. Ако апликацијата сè уште ги нема табелите за database редицата, генерирајте ги и нив:
php artisan make:model SocialProfile -m
php artisan make:controller StoreCommunityProfileController
php artisan make:job ResolveSocialProfile
php artisan make:queue-table
php artisan migrate
Дефинирајте ја табелата за профили само со полиња во сопственост на апликацијата. Одговорот на resolver останува JSON бидејќи неговите недокументирани внатрешни детали не смеат да протечат во колони или деловни правила.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('social_profiles', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('platform', 20);
$table->text('submitted_url');
$table->json('normalized_identity')->nullable();
$table->string('status', 20)->default('pending');
$table->string('failure_kind', 40)->nullable();
$table->timestamps();
$table->index(['user_id', 'platform', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('social_profiles');
}
};
Во SocialProfile, дозволете ги само полињата што ги запишува овој работен тек и кастирајте го објектот на идентитетот:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class SocialProfile extends Model
{
protected $fillable = [
'user_id',
'platform',
'submitted_url',
'normalized_identity',
'status',
'failure_kind',
];
protected $casts = [
'normalized_identity' => 'array',
];
}
Изградете дефанзивна API граница
Мапирањето на доменот намерно ветува многу малку: позната платформа и целосниот нормализиран објект на јавниот идентитет. Тоа го избегнува поврзувањето на остатокот од апликацијата со полиња од одговорот што не се утврдени со договорот на услугата.
<?php
namespace App\Domain\Identity;
final readonly class ResolvedIdentity
{
public function __construct(
public string $platform,
public array $publicIdentity,
) {}
}
Создајте app/Services/IdentityResolverException.php и клиентот. Клиентот се обидува со барање најмногу двапати. Ги повторува неуспесите на конекцијата, HTTP 429 и грешките на серверот, но никогаш слепо не ги повторува другите 4xx одговори. Retry-After се почитува кога содржи секунди, со ограничување за работниците да останат ограничени.
<?php
namespace App\Services;
use App\Domain\Identity\ResolvedIdentity;
use Closure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class IdentityResolverException extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $status = null,
public readonly ?int $retryAfter = null,
) {
parent::__construct("Identity resolution failed: {$kind}");
}
}
final class IdentityResolverClient
{
private Closure $sleep;
public function __construct(?Closure $sleep = null)
{
$this->sleep = $sleep ?? static fn (int $seconds) => sleep($seconds);
}
public function resolve(string $platform, string $url): ResolvedIdentity
{
$endpoint = config('services.identity_resolver.endpoint');
for ($attempt = 1; $attempt <= 2; $attempt++) {
try {
$response = Http::acceptJson()
->connectTimeout(2)
->timeout(6)
->get($endpoint, [
'platform' => $platform,
'url' => $url,
]);
} catch (ConnectionException) {
if ($attempt < 2) {
($this->sleep)(1);
continue;
}
throw new IdentityResolverException('connection');
}
if ($response->successful()) {
$data = $response->json();
if (! is_array($data) || $data === [] || array_is_list($data)) {
throw new IdentityResolverException(
'invalid_response',
$response->status()
);
}
return new ResolvedIdentity($platform, $data);
}
$status = $response->status();
$retryAfter = $this->retryAfter($response);
$retryable = $status === 429 || $status >= 500;
if ($retryable && $attempt < 2) {
($this->sleep)(min($retryAfter ?? 1, 5));
continue;
}
$kind = match (true) {
$status === 429 => 'rate_limited',
$status >= 500 => 'upstream',
default => 'rejected',
};
throw new IdentityResolverException($kind, $status, $retryAfter);
}
throw new IdentityResolverException('unexpected');
}
private function retryAfter(Response $response): ?int
{
$value = $response->header('Retry-After');
return is_string($value) && ctype_digit($value)
? max(1, min(300, (int) $value))
: null;
}
}
Валидирајте ги поднесувањата и испратете ја задачата
Валидацијата на URL на ниво на синтакса не е доволна. Усогласете го хостот на URL-адресата со избраната платформа пред да ја испратите надвор од вашиот систем. Проверката на суфиксот прифаќа обични поддомени, додека отфрла измамнички хостови како instagram.com.attacker.example.
<?php
namespace App\Http\Controllers;
use App\Jobs\ResolveSocialProfile;
use App\Models\SocialProfile;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
final class StoreCommunityProfileController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$data = $request->validate([
'platform' => ['required', Rule::in([
'facebook', 'instagram', 'linkedin',
])],
'url' => ['required', 'url:http,https', 'max:2048'],
]);
$expected = [
'facebook' => 'facebook.com',
'instagram' => 'instagram.com',
'linkedin' => 'linkedin.com',
][$data['platform']];
$host = strtolower(parse_url($data['url'], PHP_URL_HOST) ?? '');
$matches = $host === $expected
|| str_ends_with($host, ".{$expected}");
if (! $matches) {
throw ValidationException::withMessages([
'url' => 'The URL host does not match the selected platform.',
]);
}
$profile = SocialProfile::create([
'user_id' => $request->user()->id,
'platform' => $data['platform'],
'submitted_url' => $data['url'],
'status' => 'pending',
]);
ResolveSocialProfile::dispatch($profile);
return response()->json([
'id' => $profile->id,
'status' => $profile->status,
], 202);
}
}
Регистрирајте ја автентицираната рута со ограничување на стапката во routes/web.php. Задржувањето во групата web middleware исто така ја обезбедува вообичаената CSRF заштита на Laravel:
use App\Http\Controllers\StoreCommunityProfileController;
use Illuminate\Support\Facades\Route;
Route::post('/directory/profiles', StoreCommunityProfileController::class)
->middleware(['auth', 'throttle:20,1']);
Задачата применува подолг backoff на ниво на редицата по малиот прозорец за непосреден повторен обид на клиентот. Таа евидентира идентификатори и категории на неуспех, а не поднесени URL-адреси или тела на одговори.
<?php
namespace App\Jobs;
use App\Models\SocialProfile;
use App\Services\IdentityResolverClient;
use App\Services\IdentityResolverException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
final class ResolveSocialProfile implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public bool $deleteWhenMissingModels = true;
public function __construct(public SocialProfile $profile) {}
public function handle(IdentityResolverClient $client): void
{
try {
$identity = $client->resolve(
$this->profile->platform,
$this->profile->submitted_url,
);
$this->profile->update([
'normalized_identity' => $identity->publicIdentity,
'status' => 'resolved',
'failure_kind' => null,
]);
} catch (IdentityResolverException $exception) {
Log::warning('identity_resolver.failure', [
'profile_id' => $this->profile->id,
'platform' => $this->profile->platform,
'kind' => $exception->kind,
'status' => $exception->status,
'attempt' => $this->attempts(),
]);
$retryable = in_array(
$exception->kind,
['connection', 'rate_limited', 'upstream'],
true
);
if ($retryable && $this->attempts() < $this->tries) {
$this->release($exception->retryAfter ?? 60);
return;
}
$this->profile->update([
'status' => 'failed',
'failure_kind' => $exception->kind,
]);
}
}
}
Тестирајте успех, повторни обиди и испраќање барања
Http::fake() спречува тестовите да зависат од јавната услуга. Фикстурата за одговор користи експлицитен тест-сентинел наместо да се преправа дека е официјално поле на одговорот.
<?php
namespace Tests\Unit;
use App\Services\IdentityResolverClient;
use App\Services\IdentityResolverException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class IdentityResolverClientTest extends TestCase
{
public function test_it_retries_a_server_error_then_maps_json(): void
{
config(['services.identity_resolver.endpoint' => 'https://resolver.test']);
Http::preventStrayRequests();
Http::fakeSequence()
->pushStatus(503)
->push(['test_sentinel' => true], 200);
$client = new IdentityResolverClient(
static function (int $seconds): void {}
);
$result = $client->resolve(
'instagram',
'https://www.instagram.com/example/'
);
$this->assertSame('instagram', $result->platform);
$this->assertSame(
['test_sentinel' => true],
$result->publicIdentity
);
Http::assertSentCount(2);
}
public function test_it_rejects_a_json_list_as_an_invalid_response(): void
{
Http::preventStrayRequests();
Http::fake(['*' => Http::response(['unexpected'], 200)]);
$this->expectException(IdentityResolverException::class);
(new IdentityResolverClient())->resolve(
'facebook',
'https://www.facebook.com/example'
);
}
}
Додајте feature тест за да докажете дека HTTP барањето создава работа во чекање без да контактира со resolver:
<?php
namespace Tests\Feature;
use App\Jobs\ResolveSocialProfile;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
final class StoreCommunityProfileTest extends TestCase
{
use RefreshDatabase;
public function test_an_authenticated_member_can_submit_a_profile(): void
{
Queue::fake();
$user = User::factory()->create();
$this->actingAs($user)->postJson('/directory/profiles', [
'platform' => 'linkedin',
'url' => 'https://www.linkedin.com/in/example',
])->assertStatus(202)
->assertJson(['status' => 'pending']);
$this->assertDatabaseHas('social_profiles', [
'user_id' => $user->id,
'platform' => 'linkedin',
'status' => 'pending',
]);
Queue::assertPushed(ResolveSocialProfile::class);
}
}
Безбедност, набљудливост и распоредување
Дозволете само автентицирани поднесувања, задржете ја CSRF заштитата, ограничете ја стапката по корисник и авторизирајте го уредувањето на директориумот одделно од прегледувањето. Никогаш не го прикажувајте нормализираниот JSON како доверлив HTML. Ако се прикажуваат врски, escape-ирајте ги ознаките и повторно валидирајте ги шемите во моментот на прикажување.
Следете ги бројките и латентноста по исход: resolved, rejected, rate-limited, upstream failure, invalid response и connection failure. Поставете предупредувања за трајни соодноси на неуспеси или растечка редица на чекање. Избегнувајте да евидентирате URL-адреси, тела на одговори, колачиња или идни ингеренции.
Распоредете ја миграцијата и кешираната конфигурација пред да ги рестартирате работниците:
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --tries=3 --timeout=30 --max-time=3600
Во продукција, извршувајте го работникот под процесен супервизор за да се рестартира по излези и распоредувања. Одржувајте го временското ограничување на работникот над ограниченото најлошо времетраење на клиентот и под интервалот retry-after на конекцијата на редицата.
Вообичаени режими на неуспех
- HTTP 422 или друго отфрлање: проверете го правописот на платформата и уверете се дека барањето испраќа еден поддржан параметар за идентификатор. Овој проект секогаш користи
url. - HTTP 429: зачувајте го записот како во чекање додека остануваат ограничени повторни обиди, почитувајте нумерички
Retry-Afterи избегнувајте додавање повеќе слоеви на непосредни повторни обиди. - HTTP 401 или 403: не повторувајте. На јавната крајна точка во моментов не ѝ треба токен, па прво проверете дали е воведено непотребно заглавие за авторизација или правило на gateway.
- Записите во чекање никогаш не се менуваат: потврдете дека работникот на редицата работи со истата околина и кеширана конфигурација како web процесот.
- Неуспеси со невалиден одговор: приватно проверете ја здравствената состојба на услугата, но не ја ослабувајте валидацијата ниту масовно евидентирајте ги payload-ите на јавниот идентитет.
Контролна листа за конечна верификација
- Потврдете дека официјалната документација сè уште наведува дека крајната точка не бара токен.
- Извршете го минималното GET барање со секоја поддржана платформа.
- Поднесете URL-адреси на Facebook, Instagram и LinkedIn преку автентицираната рута.
- Потврдете дека секој запис напредува од
pendingвоresolved. - Потврдете дека невалидните хостови се отфрлаат пред испраќањето.
- Извршете го тест-пакетот со
php artisan test. - Симулирајте одговори 429 и 5xx и потврдете дека повторните обиди остануваат ограничени.
- Проверете дека дневниците содржат ID на профили и категории на неуспех, никогаш поднесени URL-адреси или тела на одговори.
Трајната лекција за дизајнот е едноставна: нормализацијата припаѓа на намерно дефинирана граница. Откако суровите социјални врски ќе влезат како недоверливи поднесувања, ќе поминат низ тесно дефиниран resolver клиент и ќе излезат како валидирани објекти на идентитет со набљудливи состојби, директориумот престанува да биде збирка од кршливи низи и станува доверлив податок на апликацијата.