Туториали

Laravel Brand Kit Extraction for Instant Landing Page Drafts

Извлекување на бренд-комплет за Laravel за инстант-нацрти на целни страници

Клиент внесува своја веб-страница во процесот на воведување. Неколку моменти подоцна, вашата апликација има нацрт на тема за целна страница што може да се прегледа: име на брендот, визуелни средства, докази за бои, типографија, слики, врски до социјални мрежи и CSS-променливи. Примамливата имплементација е контролер што повикува API и зачувува што и да се врати. Продукциската имплементација е попромислена.

Овој туторијал ја гради втората варијанта во Laravel: екстракција преку редица, строга валидација на границата, ограничени повторни обиди, структурирани неуспеси, безбедно складирање, детерминистички тестови и експлицитна граница за одобрување пред извлечените вредности да можат да влијаат врз прикажаниот CSS.

Добијте пристап до Brand Kit Extractor

Најпрво, регистрирајте сметка, или користете ја страницата за најава ако веќе имате.

  1. Отворете ја страницата на услугата Brand Kit Extractor.
  2. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
  3. Отворете ја официјалната документација за услугата.
  4. Најдете го панелот Service token и копирајте го токенот со опсег на услугата.
  5. Зачувајте го во Laravel-конфигурација поткрепена со променливи на околината, како што е прикажано наскоро.

Повторното генерирање на токенот за услугата го поништува претходно активниот токен, затоа координирајте ја ротацијата со распоредувањето. Оваа услуга не е без токени: повиците мора да се автентицираат со Bearer токен, заглавие X-API-Token или параметар за пребарување token. Ќе ја користиме Bearer-формата бидејќи ги задржува акредитивите надвор од URL-адресите и дневниците за пристап.

Потврдете го точниот HTTP-договор

Операцијата е POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Нејзиното JSON-тело на барањето содржи едно поле, url. Пред да напишете Laravel-код, направете минимално барање од безбеден терминал:

export BRAND_KIT_TOKEN="YOUR_SERVICE_TOKEN"

curl --fail-with-body \
  --request POST \
  --url "https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit" \
  --header "Authorization: Bearer ${BRAND_KIT_TOKEN}" \
  --header "Accept: application/json" \
  --header "Content-Type: application/json" \
  --data '{"url":"https://example.com"}'

Споредете го одговорот со официјалната документација. Границата на апликацијата мора да ги бара документираното име на брендот, логоата, боите, фонтoвите, сликите, социјалните профили и CSS-променливите. Треба да отфрла податоци што недостигаат, се неправилно обликувани или неочекувано големи, наместо тивко да зачува делумен пакет.

Сега поставете ја акредитивата во околината на проектот. Никогаш не ја предавајте вистинската вредност во систем за контрола на верзии:

# .env
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_CONNECT_TIMEOUT=3
BRAND_KIT_TIMEOUT=20
<?php
// config/services.php

return [
    // Existing services...

    'brand_kit' => [
        'endpoint' => 'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
        'token' => env('BRAND_KIT_TOKEN'),
        'connect_timeout' => (int) env('BRAND_KIT_CONNECT_TIMEOUT', 3),
        'timeout' => (int) env('BRAND_KIT_TIMEOUT', 20),
    ],
];

Изберете мала, отпорна архитектура

Екстракцијата на брендот извршува оддалечена мрежна работа и може да го надмине удобниот буџет на барање од прелистувач. Затоа, задача во редица има вистинска намена: воведувањето се враќа веднаш, додека worker извршува екстракција.

Функционалноста има четири граници:

  • Контролерот ја валидаира поднесената јавна веб-страница и создава запис во чекање.
  • Задача во редица управува со животниот циклус, повторните обиди, евиденцијата и статусот на неуспех.
  • Посветен клиент управува со автентикацијата, временските ограничувања и класификацијата на HTTP-грешки.
  • Маперот валидира надворешен JSON и создава податоци за нацрт во сопственост на апликацијата.

Нацртот останува неодобрен. Извлечениот CSS е доказ, а не доверлива извршна содржина. Подоцнежен чекор на преглед може да унапреди избрани, санирани токени за дизајн во објавена тема.

Доволна е компактна структура на проектот: app/Http/Controllers/BrandDraftController.php, app/Jobs/ExtractBrandKit.php, app/Services/BrandKitClient.php, app/Services/BrandKitMapper.php и app/Models/BrandDraft.php.

Зачувајте ја состојбата на животниот циклус и доказите

Создадете ги моделот и миграцијата со php artisan make:model BrandDraft -m. JSON-колоната ги задржува валидираните категории на одговорот заедно, додека статусот и кодот на грешка остануваат достапни за пребарување.

<?php
// database/migrations/..._create_brand_drafts_table.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_drafts', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('source_url', 2048);
            $table->string('status', 20)->default('pending');
            $table->json('kit')->nullable();
            $table->string('error_code', 50)->nullable();
            $table->boolean('approved')->default(false);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('brand_drafts');
    }
};

// app/Models/BrandDraft.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class BrandDraft extends Model
{
    protected $fillable = [
        'user_id', 'source_url', 'status', 'kit', 'error_code', 'approved',
    ];

    protected function casts(): array
    {
        return [
            'kit' => 'array',
            'approved' => 'boolean',
        ];
    }
}

Валидирајте го одговорот на границата

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

<?php
// app/Services/BrandKitMapper.php

namespace App\Services;

use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use RuntimeException;

final class BrandKitMapper
{
    public function map(array $payload): array
    {
        $validated = Validator::make($payload, [
            'brand_name' => ['required', 'string', 'max:160'],
            'logos' => ['required', 'array', 'max:20'],
            'colors' => ['required', 'array', 'max:100'],
            'fonts' => ['required', 'array', 'max:50'],
            'imagery' => ['required', 'array', 'max:50'],
            'social_profiles' => ['required', 'array', 'max:30'],
            'css_variables' => ['required', 'array', 'max:100'],
        ])->validate();

        foreach ($validated as $field => $value) {
            $this->assertBoundedJson($value, $field);
        }

        return $validated;
    }

    private function assertBoundedJson(
        mixed $value,
        string $path,
        int $depth = 0
    ): void {
        if ($depth > 5) {
            throw ValidationException::withMessages([
                $path => 'Brand data is nested too deeply.',
            ]);
        }

        if (is_array($value)) {
            foreach ($value as $key => $child) {
                $this->assertBoundedJson(
                    $child,
                    $path.'.'.(string) $key,
                    $depth + 1
                );
            }

            return;
        }

        if (! is_string($value) && ! is_int($value)
            && ! is_float($value) && ! is_bool($value)
            && $value !== null) {
            throw new RuntimeException("Unsupported value at {$path}");
        }

        if (is_string($value)) {
            if (strlen($value) > 2048 || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $value)) {
                throw ValidationException::withMessages([
                    $path => 'Brand data contains an invalid string.',
                ]);
            }

            $scheme = parse_url($value, PHP_URL_SCHEME);

            if (is_string($scheme)
                && ! in_array(strtolower($scheme), ['http', 'https'], true)) {
                throw ValidationException::withMessages([
                    $path => 'Brand data contains an unsafe URL scheme.',
                ]);
            }
        }
    }
}

Ако официјалната документација наведува обвивка околу овие полиња, оттурнете ја таа документирана обвивка во клиентот пред да го повикате маперот. Не додавајте шпекулативни алијаси што би можеле да претворат некомпатибилна промена нагоре во оштетени податоци.

Изградете HTTP-клиент со ограничени повторни обиди

Вградениот HTTP-клиент на Laravel обезбедува детерминистички временски ограничувања, повторни обиди и лажни одговори за тестирање. Повторувајте при неуспеси на поврзувањето, HTTP 429 одговори и привремени грешки на серверот. Не повторувајте при неуспеси на автентикацијата, вообичаени клиентски грешки или неуспеси на валидацијата.

<?php
// app/Services/BrandKitClient.php

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;

final class BrandKitFailure extends RuntimeException
{
    public function __construct(
        public readonly string $failureCode,
        public readonly bool $retryable,
        string $message
    ) {
        parent::__construct($message);
    }
}

final class BrandKitClient
{
    public function __construct(private BrandKitMapper $mapper) {}

    public function extract(string $url): array
    {
        $token = config('services.brand_kit.token');

        if (! is_string($token) || $token === '') {
            throw new BrandKitFailure(
                'configuration_error',
                false,
                'Brand Kit service token is not configured.'
            );
        }

        try {
            $response = Http::withToken($token)
                ->acceptJson()
                ->asJson()
                ->connectTimeout(config('services.brand_kit.connect_timeout'))
                ->timeout(config('services.brand_kit.timeout'))
                ->retry(
                    [250, 750],
                    when: function (Throwable $exception): bool {
                        if ($exception instanceof ConnectionException) {
                            return true;
                        }

                        return $exception instanceof RequestException
                            && ($exception->response->status() === 429
                                || $exception->response->serverError());
                    },
                    throw: false
                )
                ->post(config('services.brand_kit.endpoint'), ['url' => $url]);
        } catch (ConnectionException $exception) {
            throw new BrandKitFailure(
                'network_error',
                true,
                'Brand Kit service could not be reached.'
            );
        }

        if ($response->status() === 401 || $response->status() === 403) {
            throw new BrandKitFailure('authentication_error', false, 'Service authentication failed.');
        }

        if ($response->status() === 429) {
            throw new BrandKitFailure('rate_limited', true, 'Service rate limit reached.');
        }

        if ($response->serverError()) {
            throw new BrandKitFailure('upstream_error', true, 'Service returned a temporary error.');
        }

        if (! $response->successful()) {
            throw new BrandKitFailure('request_rejected', false, 'Service rejected the request.');
        }

        $payload = $response->json();

        if (! is_array($payload)) {
            throw new BrandKitFailure('invalid_response', false, 'Service returned invalid JSON.');
        }

        return $this->mapper->map($payload);
    }
}

Одложувањата произведуваат најмногу три обиди за време на едно извршување на задачата. Laravel повторно го оценува предикатот за повторување при секој неуспех, така што 401 или 422 се враќа веднаш. Апликацијата исто така избегнува да ги евидентира телата на одговорите, кои може да содржат докази специфични за клиентот.

Поврзете го воведувањето со редицата

Контролерот отфрла акредитиви во URL-адреси, не-HTTP шеми, localhost и цели со IP-литерали. Надворешната услуга сè уште е одговорна за безбедно преземање јавни веб-страници; локалната валидација е дополнителна заштита при воведувањето, а не замена за одбрани од фалсификување барања на серверска страна кај преземачот.

<?php
// app/Http/Controllers/BrandDraftController.php

namespace App\Http\Controllers;

use App\Jobs\ExtractBrandKit;
use App\Models\BrandDraft;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class BrandDraftController
{
    public function store(Request $request): JsonResponse
    {
        $data = $request->validate(['url' => ['required', 'url', 'max:2048']]);
        $parts = parse_url($data['url']);

        abort_unless(
            isset($parts['scheme'], $parts['host'])
            && in_array(strtolower($parts['scheme']), ['http', 'https'], true)
            && ! isset($parts['user'], $parts['pass'])
            && strtolower($parts['host']) !== 'localhost'
            && filter_var($parts['host'], FILTER_VALIDATE_IP) === false,
            422,
            'Enter a public website hostname.'
        );

        $draft = BrandDraft::create([
            'user_id' => $request->user()->id,
            'source_url' => $data['url'],
            'status' => 'pending',
        ]);

        ExtractBrandKit::dispatch($draft->id);

        return response()->json(['id' => $draft->id, 'status' => 'pending'], 202);
    }

    public function show(Request $request, BrandDraft $draft): JsonResponse
    {
        abort_unless($draft->user_id === $request->user()->id, 404);

        return response()->json($draft->only(
            'id', 'status', 'kit', 'error_code', 'approved'
        ));
    }
}

// routes/web.php

use App\Http\Controllers\BrandDraftController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function (): void {
    Route::post('/onboarding/brand-draft', [BrandDraftController::class, 'store']);
    Route::get('/onboarding/brand-draft/{draft}', [BrandDraftController::class, 'show']);
});
<?php
// app/Jobs/ExtractBrandKit.php

namespace App\Jobs;

use App\Models\BrandDraft;
use App\Services\BrandKitClient;
use App\Services\BrandKitFailure;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;

final class ExtractBrandKit implements ShouldQueue
{
    use Queueable;

    public int $tries = 2;
    public array $backoff = [60];

    public function __construct(public readonly int $draftId) {}

    public function handle(BrandKitClient $client): void
    {
        $draft = BrandDraft::findOrFail($this->draftId);
        $draft->update(['status' => 'processing', 'error_code' => null]);

        try {
            $kit = $client->extract($draft->source_url);
            $draft->update(['status' => 'ready', 'kit' => $kit]);
        } catch (BrandKitFailure $failure) {
            if ($failure->retryable) {
                throw $failure;
            }

            $draft->update([
                'status' => 'failed',
                'error_code' => $failure->failureCode,
            ]);
        }
    }

    public function failed(?Throwable $failure): void
    {
        BrandDraft::whereKey($this->draftId)->update([
            'status' => 'failed',
            'error_code' => $failure instanceof BrandKitFailure
                ? $failure->failureCode
                : 'internal_error',
        ]);

        Log::warning('Brand extraction exhausted retries', [
            'draft_id' => $this->draftId,
            'failure_type' => $failure ? $failure::class : null,
        ]);
    }
}

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

Http::fake() ги одржува тестовите брзи и ги докажува точниот метод, крајната точка, автентикацијата и телото на барањето. Додајте посебни тестови за маперот за категории што недостигаат, прекумерно вгнездување и небезбедни шеми.

<?php
// tests/Feature/BrandKitClientTest.php

namespace Tests\Feature;

use App\Services\BrandKitClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class BrandKitClientTest extends TestCase
{
    public function test_it_extracts_and_validates_a_brand_kit(): void
    {
        config()->set('services.brand_kit.token', 'test-token');

        Http::fake([
            config('services.brand_kit.endpoint') => Http::response([
                'brand_name' => 'Example',
                'logos' => [],
                'colors' => ['#112233'],
                'fonts' => ['Inter'],
                'imagery' => [],
                'social_profiles' => [],
                'css_variables' => ['--brand-primary' => '#112233'],
            ], 200),
        ]);

        $kit = app(BrandKitClient::class)->extract('https://example.com');

        $this->assertSame('Example', $kit['brand_name']);

        Http::assertSent(function (Request $request): bool {
            return $request->method() === 'POST'
                && $request->url() === config('services.brand_kit.endpoint')
                && $request->hasHeader('Authorization', 'Bearer test-token')
                && $request['url'] === 'https://example.com';
        });
    }

    public function test_it_does_not_retry_authentication_failures(): void
    {
        config()->set('services.brand_kit.token', 'expired-token');
        Http::fake([
            config('services.brand_kit.endpoint') => Http::response([], 401),
        ]);

        try {
            app(BrandKitClient::class)->extract('https://example.com');
        } finally {
            Http::assertSentCount(1);
        }
    }
}

Распоредете, набљудувајте и управувајте со неа

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

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --queue=default --tries=2 --timeout=45

Задржете го временското ограничување на worker-от над HTTP-временското ограничување, но под интервалот за повторен обид на врската со редицата. За време на ротација на токенот, ажурирајте ја тајната променлива на околината, повторно изградете го кешот на конфигурацијата и рестартирајте ги worker-ите. Бидејќи повторното генерирање го поништува претходниот токен, избегнувајте да оставите стари worker-и активни со кеширани акредитиви.

Следете ги броевите и времетраењата за исходи во чекање, подготвени, неуспешни, ограничени по стапка, автентикациски, мрежни и невалиден одговор. Испраќајте предупредување при трајни неуспеси, наместо при една привремена грешка нагоре. Дневниците треба да содржат идентификатори на нацртите и класификации на неуспехот, никогаш токени, заглавија за авторизација, целосни тела на одговорите или непотребни податоци за клиентите.

Вообичаени неуспеси

  • Грешки при автентикација: проверете ја активацијата на планот, опсегот на токенот, кешираната конфигурација и дали токенот бил повторно генериран.
  • Ограничување на стапката: дозволете ограниченото одложување да се изврши, намалете ја паралелноста при воведувањето и прегледајте го активниот план. Не создавајте неограничена јамка за повторни обиди.
  • Невалидни одговори: споредете ја тековната документирана шема со маперот. Отфрлајте отстапувања додека намерно не се поддржат.
  • Задачите во редицата никогаш не се извршуваат: потврдете дека worker-от, врската со редицата, складиштето за неуспешни задачи и процесниот супервизор се активни.
  • Небезбедни прегледи: избегнувајте бренд-текст и атрибути на средства, проксирајте или ограничете ги оддалечените средства како што бара вашата апликација и никогаш не ги спојувајте извлечените CSS-променливи во стилски блок пред одобрување и санација специфична за својството.

Конечна контролна листа за потврда

  • Точната POST-крајна точка прима JSON што содржи url.
  • Токенот за услугата доаѓа само од конфигурација поткрепена со променливи на околината.
  • Времињата за поврзување и за целосен одговор се ограничени.
  • Се повторуваат само неуспеси на поврзувањето, 429 одговори и серверски грешки.
  • Секоја задолжителна категорија на бренд се валидира пред складирањето.
  • Сопственоста на нацртот се спроведува при читање на статусот.
  • Извлечената содржина останува неодобрена и не може директно да се извршува како CSS.
  • Тестовите користат Http::fake() и не прават надворешни барања.
  • Worker-ите, кешот на конфигурацијата, метриките, дневниците и ротацијата на токенот се опфатени оперативно.

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

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

Mihajlo

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