Laravel Onboarding: Изградете нацрти за целни страници со API за извлекување на комплетот за бренд
Воведувањето често запнува во истиот незгоден момент: клиентот има веб-страница, но на вашата апликација сѐ уште ѝ се потребни лого, палета на бои, типографија и доволно визуелен контекст за да создаде веродостоен прв нацрт. Барањето од луѓето повторно да ги внесат сите тие информации создава триење. Нивното рачно копирање создава работа за поддршката.
Подобар работен тек е да се извлечат докази од јавната веб-страница на клиентот, да се валидираат на границата на апликацијата и да се зачуваат како неодобрен нацрт за темата на целната страница. Клиентот добива корисна почетна точка без да се дозволи оддалечените податоци да станат извршлив HTML или CSS.
Ова упатство го гради тој работен тек со PHP 8.3+, HTTP клиентот на Laravel, задача за извлекување во редица, дефанзивно мапирање на одговори и детерминистички тестови. Brand Kit Extractor API останува централната интеграција: тој го претвора визуелниот идентитет на јавна веб-страница во структурирани податоци за брендот што ги опфаќаат името на брендот, логоата, боите, фонтовите, сликите, социјалните профили и CSS променливите.
Обезбедете пристап пред да пишувате код за интеграција
Оваа услуга бара автентикација; не е API без токен. Создадете сметка на страницата за регистрација, или користете ја страницата за најава ако веќе имате.
- Отворете ја страницата на услугата Brand Kit Extractor.
- Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.
- Поставете го токенот во конфигурацијата на околината на вашиот проект. Никогаш не го предавајте во контрола на изворниот код.
Регенерирањето на сервисниот токен го поништува претходно активниот токен. Третирајте ја ротацијата како промена при распоредување: ажурирајте ја секоја околина што го користи токенот, повторно изградете ја кешираната конфигурација, рестартирајте ги работниците и потврдете ја интеграцијата.
Потврдете ја крајната точка со минимално барање
Точното барање е POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit, со JSON тело што содржи url. Услугата прифаќа Bearer токен, заглавие X-API-Token или параметар за барање token. Овој проект користи Bearer автентикација бидејќи акредитивите во низа за барање може да протечат во дневниците за пристап и системите за мониторинг.
curl --request POST \
--url https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Accept: application/json" \
--header "Content-Type: application/json" \
--data '{"url":"https://customer.example"}'
Извршете го ова само со јавна веб-страница за која сте овластени да ја обработувате. Успешен одговор треба да ги содржи седумте документирани области со податоци за брендот. Не претпоставувајте дека самиот успех ги прави вредностите безбедни за прикажување.
Зачувајте ја акредитивата во конфигурацијата на Laravel
Додајте ја тајната и изборот на редица во .env:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database
Додајте го овој запис во низата што ја враќа config/services.php:
'brand_kit' => [
'base_url' => 'https://ai.mihajlo.mk/api/brand-kit-extractor',
'token' => env('BRAND_KIT_TOKEN'),
],
Задржувањето на фиксниот URL на услугата во конфигурацијата ги олеснува тестовите, истовремено обезбедувајќи дека продукцискиот код ја повикува дадената крајна точка. Само акредитивата доаѓа од околината.
Архитектура: извлекувањето е доказ, а не објавен дизајн
Прелистувачот испраќа URL на веб-страница до автентицирана Laravel рута. Контролерот применува основни проверки за јавен URL, создава нацрт што чека обработка и испраќа задача во редица. Задачата повикува посветен API клиент, го мапира одговорот во доменски објект и зачувува валидирани докази. Подоцнежно дејство за преглед може да одобри избрани вредности за објавување.
Извршувањето во позадина е корисно овде бидејќи мора да се испита оддалечена веб-страница, а барањето може да наиде на ограничување на бројот на барања или привремени неуспеси на надворешната услуга. Враќањето 202 Accepted го одржува воведувањето одзивно, додека записот во базата на податоци обезбедува експлицитна состојба pending, processing, retrying, completed или failed.
Важниот компромис е намерен: оваа имплементација го зачувува целото валидирано стебло на докази, но само посебно валидираните CSS променливи се подобни за преглед на тема. Таа никогаш не вметнува оддалечена ознака, не презема вратени средства и не го објавува резултатот автоматски.
app/
Data/BrandKitData.php
Exceptions/BrandKitApiException.php
Http/Controllers/OnboardingBrandDraftController.php
Jobs/ExtractBrandKit.php
Models/BrandThemeDraft.php
Services/BrandKitClient.php
config/services.php
database/migrations/..._create_brand_theme_drafts_table.php
routes/web.php
tests/Feature/ExtractBrandKitTest.php
Создадете траен нацрт
Генерирајте ги моделот, миграцијата, контролерот и задачата. Ако табелите за редици во вашата база на податоци веќе не постојат, генерирајте ги и нив.
php artisan make:model BrandThemeDraft -m
php artisan make:controller OnboardingBrandDraftController
php artisan make:job ExtractBrandKit
php artisan make:queue-table
php artisan migrate
Миграцијата на нацртот ја евидентира и состојбата на животниот циклус и санираниот излез. Чувањето на деталите за неуспех одделно спречува тело на грешка од надворешната услуга да биде изложено на клиентите.
<?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('brand_theme_drafts', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('status', 24)->default('pending');
$table->string('source_url', 2048);
$table->string('brand_name', 200)->nullable();
$table->json('evidence')->nullable();
$table->json('css_variables')->nullable();
$table->string('failure_code', 64)->nullable();
$table->text('failure_message')->nullable();
$table->timestamp('approved_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('brand_theme_drafts');
}
};
Во app/Models/BrandThemeDraft.php, додајте JSON и временски casts:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class BrandThemeDraft extends Model
{
protected function casts(): array
{
return [
'evidence' => 'array',
'css_variables' => 'array',
'approved_at' => 'immutable_datetime',
];
}
}
Валидирајте го API одговорот на границата
Документираните категории се задолжителни, но нивните вгнездени докази сѐ уште треба да се третираат како недоверлив JSON. Маперот подолу ја ограничува длабочината и вкупната големина, ги бара очекуваните типови на највисоко ниво и на CSS променливите им дава построг третман бидејќи евентуално може да влезат во декларација за стил.
<?php
namespace App\Data;
use UnexpectedValueException;
final readonly class BrandKitData
{
public function __construct(
public string $brandName,
public array $logos,
public array $colors,
public array $fonts,
public array $imagery,
public array $socialProfiles,
public array $cssVariables,
) {}
public static function fromApi(mixed $payload): self
{
if (! is_array($payload)) {
throw new UnexpectedValueException('Response is not a JSON object.');
}
$requiredArrays = [
'logos', 'colors', 'fonts', 'imagery',
'social_profiles', 'css_variables',
];
if (! isset($payload['brand_name'])
|| ! is_string($payload['brand_name'])
|| trim($payload['brand_name']) === ''
|| mb_strlen($payload['brand_name']) > 200) {
throw new UnexpectedValueException('Invalid brand name.');
}
foreach ($requiredArrays as $field) {
if (! array_key_exists($field, $payload) || ! is_array($payload[$field])) {
throw new UnexpectedValueException("Invalid {$field} field.");
}
}
$nodes = 0;
foreach ($requiredArrays as $field) {
self::assertBoundedJson($payload[$field], 0, $nodes);
}
$css = [];
foreach ($payload['css_variables'] as $name => $value) {
if (! is_string($name)
|| ! preg_match('/^--[a-zA-Z0-9_-]{1,80}$/', $name)
|| ! is_string($value)
|| mb_strlen($value) > 200
|| preg_match('/url\s*\(|expression\s*\(|[<>;{}]/i', $value)) {
throw new UnexpectedValueException('Unsafe CSS variable.');
}
$css[$name] = $value;
}
return new self(
trim($payload['brand_name']),
$payload['logos'],
$payload['colors'],
$payload['fonts'],
$payload['imagery'],
$payload['social_profiles'],
$css,
);
}
private static function assertBoundedJson(
mixed $value,
int $depth,
int &$nodes
): void {
if ($depth > 6 || ++$nodes > 1000) {
throw new UnexpectedValueException('Brand evidence is too large.');
}
if (is_array($value)) {
foreach ($value as $child) {
self::assertBoundedJson($child, $depth + 1, $nodes);
}
return;
}
if (! is_null($value)
&& ! is_string($value)
&& ! is_int($value)
&& ! is_float($value)
&& ! is_bool($value)) {
throw new UnexpectedValueException('Unsupported evidence value.');
}
if (is_string($value) && mb_strlen($value) > 4096) {
throw new UnexpectedValueException('Evidence value is too long.');
}
}
}
Ова е валидација, а не семантичко одобрување. Синтаксички валиден URL на лого сѐ уште може да упатува на неочекуван хост, а името на фонт можеби нема лиценца за прераспределување. Чувајте ги прегледите escape-ирани, стандардно не посредувајте преку средства и барајте потврда од клиентот пред објавување.
Изградете ограничен API клиент што е свесен за повторни обиди
Создадете исклучок што носи стабилна причина на апликацијата, HTTP статус, одложување за повторен обид и ознака за можност за повторен обид. Потоа изолирајте го целото однесување при транспорт во BrandKitClient.
<?php
namespace App\Exceptions;
use RuntimeException;
final class BrandKitApiException extends RuntimeException
{
public function __construct(
public readonly string $reason,
public readonly ?int $status = null,
public readonly ?int $retryAfter = null,
public readonly bool $retryable = false,
) {
parent::__construct($reason);
}
}
<?php
namespace App\Services;
use App\Data\BrandKitData;
use App\Exceptions\BrandKitApiException;
use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use UnexpectedValueException;
final class BrandKitClient
{
public function extract(string $url): BrandKitData
{
$token = config('services.brand_kit.token');
if (! is_string($token) || $token === '') {
throw new BrandKitApiException('configuration_error');
}
try {
$response = Http::baseUrl(config('services.brand_kit.base_url'))
->withToken($token)
->acceptJson()
->asJson()
->connectTimeout(3)
->timeout(25)
->retry(
[250, 750, 1500],
function (Exception $exception): bool {
if ($exception instanceof ConnectionException) {
return true;
}
return $exception instanceof RequestException
&& in_array(
$exception->response->status(),
[429, 500, 502, 503, 504],
true
);
},
throw: false
)
->post('/v1/extract-brand-kit', ['url' => $url]);
} catch (ConnectionException) {
throw new BrandKitApiException(
'connection_failure',
retryable: true
);
}
if ($response->status() === 429) {
$header = $response->header('Retry-After');
$delay = ctype_digit((string) $header) ? (int) $header : 60;
throw new BrandKitApiException(
'rate_limited',
429,
min(max($delay, 30), 900),
true
);
}
if (in_array($response->status(), [401, 403], true)) {
throw new BrandKitApiException(
'authentication_failed',
$response->status()
);
}
if ($response->status() === 422) {
throw new BrandKitApiException('request_rejected', 422);
}
if (! $response->successful()) {
throw new BrandKitApiException(
'upstream_failure',
$response->status(),
retryable: $response->serverError()
);
}
try {
return BrandKitData::fromApi($response->json());
} catch (UnexpectedValueException $exception) {
throw new BrandKitApiException(
'invalid_response',
$response->status()
);
}
}
}
Клиентот повторува обиди само при неуспеси на врската, ограничување на бројот на барања и избрани серверски грешки. Неуспесите на автентикација и валидација се детерминистички; нивното повторување троши квота и го одложува корисниот одговор. Временските ограничувања се ограничени, а телото на одговорот никогаш не влегува во дневник или грешка видлива за клиентот.
Ставете го извлекувањето во редица и изложете ја рутата за воведување
Задачата е единствена за секој нацрт, поддржува одложено закрепнување од ограничувања на бројот на барања и зачувува генеричка терминална грешка. За вообичаени минливи неуспеси, политиката за повторен обид на Laravel во редицата обезбедува подолго повратно одложување.
<?php
namespace App\Jobs;
use App\Exceptions\BrandKitApiException;
use App\Models\BrandThemeDraft;
use App\Services\BrandKitClient;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;
final class ExtractBrandKit implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public int $tries = 5;
public int $uniqueFor = 1800;
public array $backoff = [60, 300, 900];
public function __construct(public readonly int $draftId) {}
public function uniqueId(): string
{
return (string) $this->draftId;
}
public function handle(BrandKitClient $client): void
{
$draft = BrandThemeDraft::findOrFail($this->draftId);
if ($draft->status === 'completed') {
return;
}
$draft->status = 'processing';
$draft->save();
try {
$kit = $client->extract($draft->source_url);
} catch (BrandKitApiException $exception) {
Log::warning('Brand extraction failed', [
'draft_id' => $draft->id,
'source_host' => parse_url($draft->source_url, PHP_URL_HOST),
'reason' => $exception->reason,
'status' => $exception->status,
]);
if ($exception->reason === 'rate_limited'
&& $this->attempts() < $this->tries) {
$draft->status = 'retrying';
$draft->save();
$this->release($exception->retryAfter ?? 60);
return;
}
if ($exception->retryable && $this->attempts() < $this->tries) {
throw $exception;
}
$draft->status = 'failed';
$draft->failure_code = $exception->reason;
$draft->failure_message = 'Brand extraction could not be completed.';
$draft->save();
return;
}
$draft->brand_name = $kit->brandName;
$draft->evidence = [
'logos' => $kit->logos,
'colors' => $kit->colors,
'fonts' => $kit->fonts,
'imagery' => $kit->imagery,
'social_profiles' => $kit->socialProfiles,
];
$draft->css_variables = $kit->cssVariables;
$draft->status = 'completed';
$draft->failure_code = null;
$draft->failure_message = null;
$draft->save();
}
public function failed(?Throwable $exception): void
{
BrandThemeDraft::whereKey($this->draftId)->update([
'status' => 'failed',
'failure_code' => 'retry_exhausted',
'failure_message' => 'Brand extraction could not be completed.',
]);
}
}
Контролерот ги отфрла локалните и приватните IP литерали. Во зрел тек на воведување, исто така споредете го испратениот хост со веќе потврдениот домен на веб-страницата на клиентот. Не дозволувајте оваа крајна точка да стане површина за испраќање URL за општа намена.
<?php
namespace App\Http\Controllers;
use App\Jobs\ExtractBrandKit;
use App\Models\BrandThemeDraft;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
final class OnboardingBrandDraftController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'url' => ['required', 'url:http,https', 'max:2048'],
]);
$host = strtolower((string) parse_url($validated['url'], PHP_URL_HOST));
$reservedName = $host === 'localhost' || str_ends_with($host, '.local');
$privateIp = filter_var($host, FILTER_VALIDATE_IP)
&& ! filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if ($host === '' || $reservedName || $privateIp) {
throw ValidationException::withMessages([
'url' => 'Use an authorized public website URL.',
]);
}
$draft = new BrandThemeDraft();
$draft->user_id = $request->user()->id;
$draft->source_url = $validated['url'];
$draft->status = 'pending';
$draft->save();
ExtractBrandKit::dispatch($draft->id)->afterCommit();
return response()->json([
'id' => $draft->id,
'status' => $draft->status,
], 202);
}
}
Регистрирајте ја автентицираната рута со ограничен број барања во routes/web.php:
use App\Http\Controllers\OnboardingBrandDraftController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth', 'throttle:10,1'])->post(
'/onboarding/brand-drafts',
[OnboardingBrandDraftController::class, 'store']
);
Тестирајте без да ја повикувате вистинската услуга
Http::fake() на Laravel го прави транспортот детерминистички. Овој функционален тест ги проверува точниот URL, Bearer автентикацијата, JSON барањето, мапирањето на доменот и трајното зачувување.
<?php
namespace Tests\Feature;
use App\Jobs\ExtractBrandKit;
use App\Models\BrandThemeDraft;
use App\Models\User;
use App\Services\BrandKitClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class ExtractBrandKitTest extends TestCase
{
use RefreshDatabase;
public function test_it_stores_a_validated_theme_draft(): void
{
config(['services.brand_kit.token' => 'test-token']);
Http::fake([
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'
=> Http::response([
'brand_name' => 'Example Studio',
'logos' => ['https://customer.example/logo.svg'],
'colors' => ['#123456'],
'fonts' => ['Inter'],
'imagery' => [],
'social_profiles' => [],
'css_variables' => [
'--brand-primary' => '#123456',
],
], 200),
]);
$draft = new BrandThemeDraft();
$draft->user_id = User::factory()->create()->id;
$draft->source_url = 'https://customer.example';
$draft->status = 'pending';
$draft->save();
(new ExtractBrandKit($draft->id))
->handle(app(BrandKitClient::class));
$draft->refresh();
$this->assertSame('completed', $draft->status);
$this->assertSame('Example Studio', $draft->brand_name);
$this->assertSame(
'#123456',
$draft->css_variables['--brand-primary']
);
Http::assertSent(fn ($request) =>
$request->url() ===
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'
&& $request->hasHeader(
'Authorization',
'Bearer test-token'
)
&& $request->data() === [
'url' => 'https://customer.example',
]
);
}
}
Додајте фокусирани тестови на клиентот за неправилен одговор, 401 што се обидува еднаш, повторен 503, ограничено доцнење за 429 и исклучок за врска. Исто така, тестирајте го отфрлањето од контролерот на localhost, приватни IP литерали, неавтентицирани барања и преголеми URL-адреси.
Безбедност, набљудливост и распоредување
Никогаш не го поставувајте токенот во JavaScript, фикстури, слики од екранот, пораки за исклучоци или дневници. Редактирајте ги заглавијата за авторизација во вашата платформа за набљудливост. Запишувајте го идентификаторот на нацртот, изворниот хост, стабилната причина за неуспех, статусот, бројот на обиди и времетраењето — не токенот, целосниот одговор или низата за барање на URL-адресата.
Прикажете го името на брендот преку escape-иран излез на Blade. Не претворајте ги доказите во суров HTML. Применувајте CSS променливи само на изолиран преглед по валидација, по можност во однос на allowlist во сопственост на производот со поддржани имиња на променливи. Вратените URL-адреси за лого, слики и социјални мрежи треба да останат неактивни врски сѐ додека не бидат посебно проверени и одобрени.
При распоредување, обезбедете BRAND_KIT_TOKEN преку менаџерот за тајни на платформата, извршете php artisan migrate --force, а потоа извршете php artisan config:cache. Стартувајте надгледуван работник како php artisan queue:work --tries=5 --timeout=60. По ротација на токенот или распоредување промени во клиентот, извршете php artisan queue:restart за долготрајните работници повторно да ја вчитаат конфигурацијата.
Поставете предупредувања за трајни зголемувања на authentication_failed, rate_limited, invalid_response и retry_exhausted. Едно ограничено барање е вообичаено; неуспех на автентикацијата во целата флота обично сигнализира ротација на токен или застарена кеширана конфигурација.
Вообичаени неуспеси
- 401 или 403: потврдете ја активацијата на планот, токенот ограничен на услугата и кешираната конфигурација. Не обидувајте повторно наслепо.
- 422: проверете дали телото содржи валиден јавен
urlи дали веб-страницата на клиентот е достапна. - 429: почитувајте го
Retry-After, задржете го нацртот и продолжете преку редицата. - Истекувања на време или 5xx одговори: користете ограничени повторни обиди и повратно одложување на редицата; никогаш не го држете отворено барањето на прелистувачот за воведување.
- Невалиден одговор: откажете безбедно. Не зачувувајте делумна тема и истражете го отстапувањето од договорот без да го запишувате целосното корисно оптоварување.
- Нацртот никогаш не напредува: потврдете дека работникот на редицата работи и ја проверува истата редица и околина како веб-апликацијата.
Конечна контролна листа за верификација
- Сметката и Free, Plus или Pro планот се активни, а сервисниот токен доаѓа од панелот Service token на страницата за документација.
- Апликацијата испраќа точно еден JSON
urlдо документираната POST крајна точка со Bearer автентикација. - Временските ограничувања на врската и одговорот се ограничени, а се повторуваат само минливи неуспеси.
- Името на брендот, логоата, боите, фонтовите, сликите, социјалните профили и CSS променливите се валидираат пред зачувување.
- Оддалечената содржина останува неодобрен нацрт; ниту една ознака или средство не се објавува автоматски.
- Тестовите користат
Http::fake()и никогаш не трошат квота ниту содржат вистински токен. - Работниците, дневниците, предупредувањата, ротацијата на тајни и кешираната конфигурација се опфатени со операциите за распоредување.
Најдобрата автоматизација на воведувањето не се преправа дека извлекувањето е расудување. Таа ја отстранува работата со празна страница, додека овластувањето за објавување го задржува кај клиентот. Со третирање на API одговорот како доказ, наметнување тесна граница и правење на состојбите на неуспех видливи, Laravel може да претвори постојна веб-страница во корисен нацрт за целна страница без погодноста да ја претвори во безбедносна кратенка.