Vodiči

Laravel Proposals: Automate Brand Asset Imports with the Brand Kit Extractor API

Laravel prijedlozi: Automatizirajte uvoz imovine robne marke pomoću API-ja Brand Kit Extractor

Generator ponuda može izraditi besprijekorne cjenike, a ipak izgledati nedovršeno kada logotip, boje i tipografija korisnika stignu kao raštrkani privici. Ručno kopiranje je sporo, nedosljedno i posebno nezgrapno kada se isti brend mora pojaviti u ponudama, PDF izvještajima i dokumentima za daljnje praćenje.

Ovaj vodič izrađuje produkcijski Laravelov uvozni proces oko API-ja Brand Kit Extractor. Korisnik šalje URL javne web-stranice, Laravel stavlja ekstrakciju u red čekanja, provjerava svaku obaveznu kategoriju podataka o brendu i pohranjuje verzionirani snimak koji generator ponuda ili izvještaja može sigurno koristiti.

Ovdje provjereno znači da je odgovor prošao dokumentiranu granicu aplikacije i ostao povezan sa svojim izvornim URL-om. To ne znači da su utvrđeni vlasništvo nad žigom ili pravno odobrenje.

Dobijte pristup i testirajte API

Dovršite uvođenje u uslugu prije pisanja integracijskog koda:

  1. Registrirajte se na https://ai.mihajlo.mk/register ili se prijavite na https://ai.mihajlo.mk/login.
  2. Otvorite stranicu usluge Brand Kit Extractor.
  3. Odaberite dostupan Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
  4. Otvorite službenu dokumentaciju usluge.
  5. Pronađite ploču Service token i kopirajte njezin token ograničen na uslugu.

Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Implementacija u nastavku koristi oblik Bearer jer vjerodajnice ostaju izvan URL-ova, zapisnika pristupa i povijesti preglednika.

Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Rotaciju tretirajte kao promjenu implementacije: ažurirajte spremište tajni, ponovno implementirajte radnike i web-procese, provjerite promet i tek tada zaključite rotaciju.

Točan zahtjev je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Pošaljite JSON objekt koji sadrži url. Prije izrade značajke, pošaljite jedan minimalni zahtjev iz pouzdanog terminala:

curl --fail-with-body \
  --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://example.com"}'

Nemojte uvrstiti token u repozitorij. Stavite ga u Laravelovu konfiguraciju okruženja:

# .env
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

Dodajte definiciju usluge u config/services.php. Pristup okruženju ostaje u konfiguraciji kako bi predmemorirana produkcijska konfiguracija radila predvidljivo.

'brand_kit' => [
    'token' => env('BRAND_KIT_TOKEN'),
    'base_url' => 'https://ai.mihajlo.mk/api/brand-kit-extractor',
],

Odaberite malu, trajnu arhitekturu

Uvoz bi trebao biti asinkron. Ekstrakcija web-stranice ovisi o vanjskoj mreži, može trajati dulje od uobičajenog zahtjeva preglednika i može biti ograničena. Web-zahtjev zato bilježi namjeru i šalje posao u red čekanja. Namjenski klijent upravlja HTTP ponašanjem, dok mapper domene odbacuje nepotpune ili prevelike odgovore prije pohrane.

Rezultirajući tijek je:

  1. Kontroler prihvaća URL javne web-stranice i stvara uvoz na čekanju.
  2. Posao reda čekanja poziva krajnju točku za ekstrakciju.
  3. Mapper provjerava naziv brenda, logotipe, boje, fontove, slike, društvene profile i CSS varijable.
  4. Posao atomski pohranjuje spreman snimak ili strukturirani neuspjeh.
  5. Generator ponuda čita samo snimke čiji je status ready.

Identifikator zahtjeva sprječava stariji posao u redu čekanja da prepiše noviji uvoz za istu web-stranicu. Ta mala zaštita važna je kada korisnik dvaput klikne „osvježi brend”.

Stvorite granicu perzistencije

Započnite s Laravel aplikacijom konfiguriranom s podržanom bazom podataka i pozadinom reda čekanja. PHP 8.3 ili noviji, Composer i funkcionalan radnik reda čekanja preduvjeti su. Generirajte osnovne klase naredbama prve strane:

php artisan make:model BrandKit -m
php artisan make:controller BrandKitImportController
php artisan make:job ExtractBrandKit
php artisan make:test BrandKitClientTest
php artisan queue:table
php artisan migrate

Za jedinstvenost koristite hash umjesto indeksiranja potencijalno dugog URL-a. JSON snimak čuva dokaze koje vraća usluga bez prisiljavanja nestabilnih ugniježđenih podataka u relacijske stupce.

<?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_kits', function (Blueprint $table): void {
            $table->id();
            $table->string('source_hash', 64)->unique();
            $table->text('source_url');
            $table->uuid('request_id');
            $table->string('status', 20)->index();
            $table->string('brand_name')->nullable();
            $table->json('assets')->nullable();
            $table->string('failure_code', 40)->nullable();
            $table->text('failure_message')->nullable();
            $table->timestamps();
        });
    }

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

U app/Models/BrandKit.php dopustite samo ova polja u vlasništvu aplikacije i pretvorite snimak:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class BrandKit extends Model
{
    protected $fillable = [
        'source_hash', 'source_url', 'request_id', 'status',
        'brand_name', 'assets', 'failure_code', 'failure_message',
    ];

    protected function casts(): array
    {
        return ['assets' => 'array'];
    }
}

Izradite strogi API klijent

Neuspjehe prijenosa držite odvojenima od trajnih neuspjeha. Istek vremena, HTTP 429 i pogreške poslužitelja mogu uspjeti kasnije. Neuspjehe autentikacije i odbijene zahtjeve ne treba naslijepo ponovno pokušavati.

<?php

namespace App\Services;

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

final class RetryableBrandKitFailure extends RuntimeException {}
final class PermanentBrandKitFailure extends RuntimeException {}

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

        if ($token === '') {
            throw new PermanentBrandKitFailure('Token usluge nije konfiguriran.');
        }

        try {
            $response = Http::baseUrl(config('services.brand_kit.base_url'))
                ->withToken($token)
                ->acceptJson()
                ->asJson()
                ->connectTimeout(5)
                ->timeout(25)
                ->post('/v1/extract-brand-kit', ['url' => $url]);
        } catch (ConnectionException $exception) {
            throw new RetryableBrandKitFailure(
                'Povezivanje s uslugom ekstrakcije nije uspjelo.',
                previous: $exception
            );
        }

        if ($response->status() === 429 || $response->serverError()) {
            throw new RetryableBrandKitFailure(
                'Usluga ekstrakcije privremeno nije dostupna.'
            );
        }

        if ($response->status() === 401 || $response->status() === 403) {
            throw new PermanentBrandKitFailure(
                'Token usluge je odbijen.'
            );
        }

        if ($response->clientError()) {
            throw new PermanentBrandKitFailure(
                'Zahtjev za ekstrakciju je odbijen.'
            );
        }

        $payload = $response->json();

        if (! is_array($payload)) {
            throw new RetryableBrandKitFailure(
                'Usluga ekstrakcije vratila je neispravan JSON.'
            );
        }

        return BrandKitData::fromApi($payload)->toArray();
    }
}

Provjerite odgovor domene

Donja granica zahtijeva sedam isporučenih kategorija odgovora i primjenjuje lokalna sigurnosna ograničenja. Ne nagađa nedokumentirana podpolja logotipa ili fontova. Ako službena dokumentacija definira dublje sheme stavki, dodajte ta pravila ovdje umjesto raspršivanja pretpostavki po predlošcima.

<?php

namespace App\Services;

use Illuminate\Support\Facades\Validator;
use JsonException;

final readonly class BrandKitData
{
    private 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(array $payload): self
    {
        $validator = Validator::make($payload, [
            'brand_name' => ['required', 'string', 'max:200'],
            'logos' => ['required', 'array', 'max:100'],
            'colors' => ['required', 'array', 'max:100'],
            'fonts' => ['required', 'array', 'max:100'],
            'imagery' => ['required', 'array', 'max:200'],
            'social_profiles' => ['required', 'array', 'max:100'],
            'css_variables' => ['required', 'array', 'max:300'],
        ]);

        if ($validator->fails()) {
            throw new PermanentBrandKitFailure(
                'Odgovor kompleta brenda nije prošao provjeru ugovora.'
            );
        }

        $data = $validator->validated();

        try {
            $encoded = json_encode($data, JSON_THROW_ON_ERROR);
        } catch (JsonException $exception) {
            throw new PermanentBrandKitFailure(
                'Odgovor kompleta brenda nije valjan JSON podatak.',
                previous: $exception
            );
        }

        if (strlen($encoded) > 1_000_000) {
            throw new PermanentBrandKitFailure(
                'Odgovor kompleta brenda premašuje lokalno ograničenje pohrane.'
            );
        }

        return new self(
            $data['brand_name'],
            $data['logos'],
            $data['colors'],
            $data['fonts'],
            $data['imagery'],
            $data['social_profiles'],
            $data['css_variables'],
        );
    }

    public function toArray(): array
    {
        return [
            'brand_name' => $this->brandName,
            'logos' => $this->logos,
            'colors' => $this->colors,
            'fonts' => $this->fonts,
            'imagery' => $this->imagery,
            'social_profiles' => $this->socialProfiles,
            'css_variables' => $this->cssVariables,
        ];
    }
}

Sigurno stavite ekstrakciju u red čekanja

Posao ponovno pokušava samo prolazne neuspjehe i koristi ograničeno odgađanje. Bilježi konačni neuspjeh bez izlaganja tokena, tijela odgovora ili potencijalno osjetljivih zaglavlja u zapisnicima.

<?php

namespace App\Jobs;

use App\Models\BrandKit;
use App\Services\BrandKitClient;
use App\Services\PermanentBrandKitFailure;
use App\Services\RetryableBrandKitFailure;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

final class ExtractBrandKit implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;
    public int $timeout = 40;

    public function __construct(
        public int $brandKitId,
        public string $requestId,
    ) {}

    public function handle(BrandKitClient $client): void
    {
        $kit = BrandKit::findOrFail($this->brandKitId);

        if ($kit->request_id !== $this->requestId) {
            return;
        }

        try {
            $assets = $client->extract($kit->source_url);
        } catch (RetryableBrandKitFailure $exception) {
            Log::warning('Ekstrakcija kompleta brenda odgođena', [
                'brand_kit_id' => $kit->id,
                'attempt' => $this->attempts(),
            ]);

            if ($this->attempts() >= $this->tries) {
                $this->markFailed($kit, 'temporary_failure');
                return;
            }

            $delays = [10, 30, 90];
            $this->release($delays[$this->attempts() - 1]);
            return;
        } catch (PermanentBrandKitFailure $exception) {
            Log::notice('Ekstrakcija kompleta brenda odbijena', [
                'brand_kit_id' => $kit->id,
            ]);
            $this->markFailed($kit, 'permanent_failure');
            return;
        }

        $kit->refresh();

        if ($kit->request_id !== $this->requestId) {
            return;
        }

        $kit->update([
            'status' => 'ready',
            'brand_name' => $assets['brand_name'],
            'assets' => $assets,
            'failure_code' => null,
            'failure_message' => null,
        ]);
    }

    private function markFailed(BrandKit $kit, string $code): void
    {
        $kit->update([
            'status' => 'failed',
            'failure_code' => $code,
            'failure_message' => 'Resursi brenda nisu se mogli uvesti.',
        ]);
    }
}

Prihvatite uvoze iz aplikacije za ponude

Kontroler dopušta samo HTTP i HTTPS URL-ove, blokira očite lokalne ciljeve i zamjenjuje identifikator zahtjeva pri osvježavanju. Za privatni proizvod s više zakupaca također autorizirajte pristup ponudi i razmislite o ograničavanju uvoza na domene koje je odobrio korisnik.

<?php

namespace App\Http\Controllers;

use App\Jobs\ExtractBrandKit;
use App\Models\BrandKit;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

final class BrandKitImportController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $url = $request->validate([
            'url' => ['required', 'url:http,https', 'max:2048'],
        ])['url'];

        $host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
        $blockedName = $host === 'localhost' || str_ends_with($host, '.local');
        $blockedIp = filter_var($host, FILTER_VALIDATE_IP)
            && ! filter_var(
                $host,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            );

        abort_if($host === '' || $blockedName || $blockedIp, 422);

        $requestId = (string) Str::uuid();

        $kit = BrandKit::updateOrCreate(
            ['source_hash' => hash('sha256', $url)],
            [
                'source_url' => $url,
                'request_id' => $requestId,
                'status' => 'pending',
                'brand_name' => null,
                'assets' => null,
                'failure_code' => null,
                'failure_message' => null,
            ]
        );

        ExtractBrandKit::dispatch($kit->id, $requestId)->afterCommit();

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

Registrirajte autentificiranu rutu u routes/web.php ili routes/api.php:

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

Route::post('/brand-kits/import', [BrandKitImportController::class, 'store'])
    ->middleware('auth');

Prikazivač ponuda treba upitati komplet sa status = ready, escapirati tekst i URL-ove te mapirati samo izričito podržane oblike resursa u svoj prikazni model. Nikada nemojte vraćene CSS varijable izravno umetati u stylesheet. Prije prikazivanja provjerite nazive prilagođenih svojstava i ograničite vrijednosti; vanjski sadržaj inače može postati površina za CSS injekciju.

Testirajte ugovor bez pozivanja produkcije

Laravelov HTTP fake čini autentikaciju, oblik zahtjeva i klasifikaciju neuspjeha determinističkima.

<?php

namespace Tests\Feature;

use App\Services\BrandKitClient;
use App\Services\PermanentBrandKitFailure;
use App\Services\RetryableBrandKitFailure;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class BrandKitClientTest extends TestCase
{
    public function test_it_imports_a_complete_brand_kit(): 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',
                    'logos' => [], 'colors' => [], 'fonts' => [],
                    'imagery' => [], 'social_profiles' => [],
                    'css_variables' => [],
                ], 200),
        ]);

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

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

        Http::assertSent(fn ($request) =>
            $request->method() === 'POST'
            && $request['url'] === 'https://example.com'
            && $request->hasHeader(
                'Authorization',
                'Bearer test-token'
            )
        );
    }

    public function test_rate_limits_are_retryable(): void
    {
        config(['services.brand_kit.token' => 'test-token']);
        Http::fake(fn () => Http::response([], 429));

        $this->expectException(RetryableBrandKitFailure::class);

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

    public function test_missing_fields_are_rejected(): void
    {
        config(['services.brand_kit.token' => 'test-token']);
        Http::fake(fn () => Http::response([
            'brand_name' => 'Incomplete',
        ], 200));

        $this->expectException(PermanentBrandKitFailure::class);

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

Implementirajte i upravljajte uvoznikom

Pokrenite migracije prije dopuštanja uvoza, predmemorirajte produkcijsku konfiguraciju nakon umetanja tokena i ponovno pokrenite dugotrajne radnike kako bi primili novo okruženje:

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan test

Nadzirite php artisan queue:work upraviteljem procesa platforme. Postavite vremenska ograničenja radnika iznad vremenskog ograničenja posla od 40 sekundi, ali neka ostanu ograničena. Pratite broj uvoza na čekanju, spremnih, s privremenim neuspjehom i s trajnim neuspjehom, kao i trajanje posla i starost reda čekanja. Upozorite na kontinuirane odgovore 429 ili rastuće neuspjehe poslužitelja umjesto zapisivanja potpunih odgovora pružatelja usluge.

Uobičajeni obrasci neuspjeha

  • Svaki zahtjev vraća 401 ili 403: potvrdite aktivaciju plana i token ograničen na uslugu. Ponovno generirani token odmah poništava stari.
  • Razvoj radi, ali produkcija odbija autentikaciju: očistite i ponovno izgradite Laravelovu predmemoriju konfiguracije nakon ažuriranja tajne.
  • Uvozi ostaju na čekanju: provjerite radi li konfigurirani radnik reda čekanja i prati li ispravnu vezu reda čekanja.
  • Odgovori ne prolaze provjeru: usporedite granični mapper sa službenom dokumentacijom. Nemojte tiho pohranjivati djelomične podatke.
  • Česti odgovori 429: smanjite broj istodobnih radnika ili učestalost uvoza. Zadržite ograničeno odgađanje umjesto stvaranja agresivne petlje ponovnih pokušaja.
  • PDF prikazivanje ne uspijeva: držite podatke ekstrakcije odvojene od prikaznog modela za renderiranje i prije upotrebe provjerite pojedinačne URL-ove resursa, formate fontova, boje i CSS vrijednosti.

Završni kontrolni popis za provjeru

  • Točna POST krajnja točka prima JSON koji sadrži samo predviđeni javni url.
  • Token usluge postoji samo u konfiguraciji koja se oslanja na okruženje i upravljanju tajnama.
  • Uspješni odgovori sadrže i provjeravaju svih sedam obaveznih kategorija podataka o brendu.
  • Neuspjesi autentikacije i provjere ne pokušavaju se ponovno.
  • Veze, odgovori, ponovni pokušaji, veličina payloada i izvršavanje reda čekanja ograničeni su.
  • Zastarjeli poslovi ne mogu prepisati novije uvoze.
  • Zapisnici sadrže identifikatore i klase neuspjeha, nikada vjerodajnice ili tijela odgovora.
  • Generator ponuda i izvještaja čita samo spremne snimke i sigurno mapira vanjske resurse.
  • Testovi prolaze s Http::fake(), bez trošenja kvote ili kontaktiranja aktivne usluge.

Vidljivi rezultat je jednostavan: unesite web-stranicu korisnika i primite koherentan komplet brenda za sljedeću ponudu. Inženjering ispod toga namjerno je manje glamurozan — stroge granice, trajni poslovi, odmjereni ponovni pokušaji, sigurno prikazivanje i uočljivi neuspjesi. Ti detalji pretvaraju privlačnu demonstraciju API-ja u pouzdanu svakodnevnu značajku.

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.