Vodiči

Laravel Brand Kit Extraction for Instant Landing Page Drafts

Izdvajanje Laravel kompleta brenda za trenutačne nacrte odredišne stranice

Kupac tijekom onboardinga unosi svoju web-stranicu. Nekoliko trenutaka kasnije vaša aplikacija ima nacrt teme odredišne stranice koji se može pregledati: naziv brenda, vizualne resurse, dokaze o bojama, tipografiju, slike, društvene poveznice i CSS varijable. Primamljiva implementacija jest kontroler koji poziva API i sprema što god se vrati. Produkcijska implementacija je promišljenija.

Ovaj vodič gradi potonju implementaciju u Laravelu: ekstrakciju u redu čekanja, strogu validaciju granica, ograničene ponovne pokušaje, strukturirane neuspjehe, sigurno spremanje, determinističke testove i izričitu granicu odobrenja prije nego što ekstrahirane vrijednosti mogu utjecati na prikazani CSS.

Dobijte pristup alatu Brand Kit Extractor

Najprije registrirajte račun ili upotrijebite stranicu za prijavu ako ga već imate.

  1. Otvorite stranicu usluge Brand Kit Extractor.
  2. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte token ograničen na uslugu.
  5. Pohranite ga u Laravel konfiguraciju podržanu varijablama okruženja, kako će uskoro biti prikazano.

Ponovno generiranje tokena usluge opoziva prethodno aktivni token, stoga uskladite rotaciju s implementacijom. Ova usluga nije bez tokena: pozivi se moraju autentificirati Bearer tokenom, zaglavljem X-API-Token ili parametrom upita token. Upotrijebit ćemo oblik Bearer jer zadržava vjerodajnice izvan URL-ova i zapisnika pristupa.

Potvrdite točan HTTP ugovor

Operacija je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Njezino JSON tijelo zahtjeva sadržava jedno polje, url. Prije pisanja Laravel koda napravite minimalni zahtjev iz sigurnog terminala:

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"}'

Usporedite odgovor sa službenom dokumentacijom. Granica aplikacije mora zahtijevati dokumentirani naziv brenda, logotipe, boje, fontove, slike, društvene profile i CSS varijable. Trebala bi odbiti nedostajuće, nepravilno oblikovane ili neočekivano velike podatke umjesto da tiho spremi djelomični komplet.

Sada smjestite vjerodajnicu u okruženje projekta. Nikada ne predajte stvarnu vrijednost u repozitorij:

# .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),
    ],
];

Odaberite malu, otpornu arhitekturu

Ekstrakcija brenda obavlja udaljeni mrežni rad i može premašiti ugodan proračun za zahtjev preglednika. Posao u redu čekanja stoga ima stvarnu svrhu: onboarding se vraća odmah, dok radnik obavlja ekstrakciju.

Značajka ima četiri granice:

  • Kontroler validira poslanu javnu web-stranicu i stvara zapis na čekanju.
  • Posao u redu čekanja upravlja životnim ciklusom, ponovnim pokušajima, zapisivanjem i statusom neuspjeha.
  • Namjenski klijent upravlja autentifikacijom, vremenskim ograničenjima i klasifikacijom HTTP pogrešaka.
  • Mapper validira vanjski JSON i stvara podatke nacrta u vlasništvu aplikacije.

Nacrt ostaje neodobren. Ekstrahirani CSS je dokaz, a ne pouzdan izvršni sadržaj. Kasniji korak pregleda može promaknuti odabrane, sanitizirane dizajnerske tokene u objavljenu temu.

Kompaktna struktura projekta je dovoljna: app/Http/Controllers/BrandDraftController.php, app/Jobs/ExtractBrandKit.php, app/Services/BrandKitClient.php, app/Services/BrandKitMapper.php i app/Models/BrandDraft.php.

Pohranite stanje životnog ciklusa i dokaze

Stvorite model i migraciju pomoću php artisan make:model BrandDraft -m. JSON stupac drži validirane kategorije odgovora zajedno, dok status i kôd pogreške ostaju dostupni za upite.

<?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',
        ];
    }
}

Validirajte odgovor na granici

Dostavljeni ugovor definira potrebne kategorije, ali ne opravdava nagađanje nedokumentiranih unutarnjih polja. Mapper stoga provodi ugovor na najvišoj razini, ograničava veličine kolekcija i ugniježđivanje, odbija opasne URL sheme i CSS varijable tretira kao pohranjene dokaze, a ne CSS koji se može prikazati.

<?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.',
                ]);
            }
        }
    }
}

Ako službena dokumentacija navodi omotnicu oko tih polja, raspakirajte tu dokumentiranu omotnicu u klijentu prije pozivanja mappera. Nemojte dodavati spekulativne alijase koji bi mogli pretvoriti nekompatibilnu promjenu uzvodno u oštećene podatke.

Izgradite HTTP klijent s ograničenim ponovnim pokušajima

Laravelov ugrađeni HTTP klijent pruža deterministička vremenska ograničenja, ponovne pokušaje i testne lažnjake. Ponovno pokušajte kod neuspjelih veza, HTTP 429 odgovora i privremenih pogrešaka poslužitelja. Nemojte ponovno pokušavati kod neuspjele autentifikacije, uobičajenih pogrešaka klijenta ili neuspjele validacije.

<?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);
    }
}

Odgode daju najviše tri pokušaja tijekom jednog izvršavanja posla. Laravel ponovno procjenjuje predikat ponovnog pokušaja za svaki neuspjeh, stoga se 401 ili 422 vraća odmah. Aplikacija također izbjegava zapisivanje tijela odgovora, koja mogu sadržavati dokaze specifične za kupca.

Povežite onboarding s redom čekanja

Kontroler odbija vjerodajnice u URL-ovima, ne-HTTP sheme, localhost i ciljeve s IP literala. Uzvodna usluga i dalje je odgovorna za sigurno dohvaćanje javnih web-stranica; lokalna validacija dodatna je zaštita pri onboardingu, a ne zamjena za obrane od krivotvorenja zahtjeva na strani poslužitelja u dohvaćaču.

<?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,
        ]);
    }
}

Testirajte bez pozivanja usluge

Http::fake() održava testove brzim i dokazuje točnu metodu, krajnju točku, autentifikaciju i tijelo zahtjeva. Dodajte zasebne testove mappera za nedostajuće kategorije, prekomjerno ugniježđivanje i nesigurne sheme.

<?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);
        }
    }
}

Implementirajte, promatrajte i upravljajte njime

Implementirajte migraciju i predmemoriranu konfiguraciju, zatim pokrenite radnika reda čekanja pod svojim nadzornikom procesa:

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

Držite vremensko ograničenje radnika iznad HTTP vremenskog ograničenja, ali ispod intervala ponovnog pokušaja veze reda čekanja. Tijekom rotacije tokena ažurirajte tajnu okruženja, ponovno izgradite predmemoriju konfiguracije i ponovno pokrenite radnike. Budući da ponovno generiranje opoziva prethodni token, izbjegavajte ostavljanje starih radnika aktivnima s predmemoriranim vjerodajnicama.

Pratite brojanja i trajanja za ishode na čekanju, spremne, neuspjele, ograničene stopom, autentifikacijske, mrežne i nevaljane odgovore. Upozoravajte na trajne neuspjehe, a ne na jednu privremenu uzvodnu pogrešku. Zapisnici trebaju sadržavati identifikatore nacrta i klasifikacije neuspjeha, nikada tokene, autorizacijska zaglavlja, potpuna tijela odgovora ili nepotrebne podatke o kupcima.

Česti neuspjesi

  • Pogreške autentifikacije: provjerite aktivaciju plana, opseg tokena, predmemoriranu konfiguraciju i je li token ponovno generiran.
  • Ograničavanje stope: dopustite ograničenom povratnom čekanju da se izvrši, smanjite istodobnost onboardinga i pregledajte aktivni plan. Nemojte stvarati neograničenu petlju ponovnih pokušaja.
  • Nevaljani odgovori: usporedite trenutačnu dokumentiranu shemu s mapperom. Odbijte odstupanje dok ga namjerno ne podržite.
  • Poslovi reda čekanja nikada se ne pokreću: potvrdite da su radnik, veza reda čekanja, pohrana neuspjelih poslova i nadzornik procesa aktivni.
  • Nesigurni pregledi: escapajte tekst brenda i atribute resursa, posredujte ili ograničite udaljene resurse kako vaša aplikacija zahtijeva i nikada nemojte spajati ekstrahirane CSS varijable u blok stila prije odobrenja i sanitizacije specifične za svojstvo.

Završni kontrolni popis za provjeru

  • Točna POST krajnja točka prima JSON koji sadržava url.
  • Token usluge dolazi isključivo iz konfiguracije podržane varijablama okruženja.
  • Vremena povezivanja i ukupnog odgovora su ograničena.
  • Ponovno se pokušavaju samo neuspjele veze, odgovori 429 i pogreške poslužitelja.
  • Svaka potrebna kategorija brenda validira se prije pohrane.
  • Vlasništvo nad nacrtom provodi se pri čitanju statusa.
  • Ekstrahirani sadržaj ostaje neodobren i ne može se izravno izvršiti kao CSS.
  • Testovi koriste Http::fake() i ne upućuju vanjske zahtjeve.
  • Radnici, predmemorija konfiguracije, metrike, zapisnici i rotacija tokena pokriveni su operativno.

Važan rezultat nije samo to da onboarding može prepoznati brend. Riječ je o tome da pogrešivo vanjsko opažanje postaje kontrolirani artefakt aplikacije: autentificiran, ograničen, validiran, pripisiv, podložan pregledu i siguran za razvoj. To je razlika između impresivnog demoa i značajke nacrta odredišne stranice koju možete s pouzdanjem predstaviti kupcima.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.