Vodiči

Native PHP 8.3: Automate Brand Asset Import for Proposals with Brand Kit API

Izvorni PHP 8.3: Automatizirajte uvoz resursa robne marke za ponude pomoću Brand Kit API-ja

Generator ponuda djeluje uglađeno dok netko ne mora pretraživati klijentovu web-stranicu kako bi pronašao ispravan logotip, boje brenda, fontove i poveznice na društvene mreže. Taj ručni korak spor je, nedosljedan i iznenađujuće ga je lako pogrešno izvršiti.

Ovaj vodič izrađuje produkcijski orijentiranu integraciju u Native PHP 8.3 koja prihvaća javni URL web-stranice, izdvaja njezin vizualni identitet utemeljen na dokazima, validira odgovor i pohranjuje nepromjenjivu snimku brenda za ponude i periodična izvješća. Dizajn zadržava vanjski API iza male granice, koristi ograničene ponovne pokušaje i ostaje testabilan bez mrežnih poziva.

Dobijte pristup i kopirajte servisni token

Započnite stvaranjem računa na https://ai.mihajlo.mk/register, ili upotrijebite https://ai.mihajlo.mk/login 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 konfiguraciju podržanu varijablama okruženja, nikada u PHP izvorni kod.

Ponovno generiranje servisnog tokena opoziva prethodno aktivni token. Tretirajte ponovno generiranje kao rotaciju vjerodajnica: odmah ažurirajte svako implementirano okruženje, provjerite novi token i uklonite sve zastarjele reference na tajnu.

Usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar tokena u upitu. Ovaj projekt koristi Bearer token jer je manja vjerojatnost da će zaglavlja, za razliku od URL-ova, procuriti kroz povijest preglednika, zapise proxyja i sustave za nadzor.

Potvrdite krajnju točku prije izrade funkcionalnosti

Točan zahtjev je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Njegovo JSON tijelo sadrži url. Izvršite jedan minimalni zahtjev prije uvođenja aplikacijskog koda:

export BRAND_KIT_TOKEN='YOUR_SERVICE_TOKEN'

curl --fail-with-body \
  --connect-timeout 3 \
  --max-time 20 \
  -X POST \
  'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit' \
  -H "Authorization: Bearer ${BRAND_KIT_TOKEN}" \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  --data '{"url":"https://example.com"}'

Uspješan odgovor trebao bi sadržavati naziv brenda, logotipe, boje, fontove, slike, profile na društvenim mrežama i CSS varijable. Još nemojte slati stvarnu klijentovu ponudu kroz proces. Najprije pregledajte odgovor prema službenoj dokumentaciji i potvrdite da su vaš plan i token aktivni.

Za lokalni razvoj smjestite vjerodajnicu u nepredanu .env datoteku:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_CONNECT_TIMEOUT_MS=3000
BRAND_KIT_RESPONSE_TIMEOUT_MS=20000
BRAND_KIT_MAX_ATTEMPTS=3
BRAND_SNAPSHOT_DIR=var/brands

Dodajte .env i var/brands/ u .gitignore. U produkciji ubrizgajte iste varijable putem upravitelja tajni platforme za implementaciju, umjesto isporuke datoteke okruženja.

Odaberite namjerno malu arhitekturu

Generatoru ponuda potrebna je stabilna snimka, a ne ovisnost uživo o klijentovoj web-stranici svaki put kada se renderira PDF. Izdvajanje se stoga odvija tijekom uvođenja brenda ili eksplicitnog osvježavanja.

  • CurlTransport upravlja HTTP-om, vremenskim ograničenjima, ograničenjima veličine odgovora i zaglavljima odgovora.
  • BrandKitClient upravlja autentikacijom, pravilima ponovnih pokušaja, klasifikacijom statusa i JSON dekodiranjem.
  • BrandKitMapper validira vanjsku reprezentaciju i stvara objekt domene.
  • BrandRepository zapisuje atomsku JSON snimku za generator ponuda.
  • import-brand.php pruža operativnu naredbu prikladnu za lokalnu upotrebu ili zakazani tijek rada.

Praktičan raspored projekta je:

proposal-generator/
├── bin/import-brand.php
├── src/BrandKit.php
├── src/BrandKitClient.php
├── src/BrandKitMapper.php
├── src/BrandRepository.php
├── src/CurlTransport.php
├── src/HttpResponse.php
├── src/Transport.php
├── tests/BrandKitClientTest.php
├── var/brands/
├── .env
├── .gitignore
├── composer.json
└── phpunit.xml

Konfigurirajte PSR-4 automatsko učitavanje i instalirajte PHPUnit 11, koji podržava PHP 8.3:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "App\\": "src/"
    }
  }
}
composer install
composer dump-autoload
set -a
. ./.env
set +a

Izradite ograničeni izvorni cURL transport

Transport ne smije čekati neograničeno niti prihvatiti proizvoljno velik odgovor. Ograničenje od dva megabajta u nastavku zaštitna je mjera aplikacije; prilagodite ga tek nakon što uočite legitimne veličine podataka.

<?php
// src/Transport.php, src/HttpResponse.php, src/CurlTransport.php
namespace App;

interface Transport
{
    public function postJson(string $url, string $token, array $payload): HttpResponse;
}

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public string $body,
        public array $headers,
        public int $durationMs,
    ) {}
}

final class CurlTransport implements Transport
{
    public function __construct(
        private int $connectTimeoutMs,
        private int $responseTimeoutMs,
        private int $maxBytes = 2_097_152,
    ) {}

    public function postJson(string $url, string $token, array $payload): HttpResponse
    {
        $body = '';
        $headers = [];
        $started = hrtime(true);
        $curl = curl_init($url);

        curl_setopt_array($curl, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => false,
            CURLOPT_CONNECTTIMEOUT_MS => $this->connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $this->responseTimeoutMs,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $token,
                'Accept: application/json',
                'Content-Type: application/json',
            ],
            CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
            CURLOPT_HEADERFUNCTION => static function ($handle, string $line) use (&$headers): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
            CURLOPT_WRITEFUNCTION => function ($handle, string $chunk) use (&$body): int {
                if (strlen($body) + strlen($chunk) > $this->maxBytes) {
                    return 0;
                }
                $body .= $chunk;
                return strlen($chunk);
            },
        ]);

        if (curl_exec($curl) === false) {
            throw new \RuntimeException('Brand service transport failed: ' . curl_error($curl));
        }

        return new HttpResponse(
            curl_getinfo($curl, CURLINFO_RESPONSE_CODE),
            $body,
            $headers,
            (int) ((hrtime(true) - $started) / 1_000_000),
        );
    }
}

Nemojte uključivati token, cjelovito tijelo odgovora ni niz upita klijentova URL-a u iznimke i zapise. Naziv hosta, lokalni identifikator korelacije, HTTP status, trajanje i broj pokušaja obično su dovoljni.

Preslikajte vanjski JSON u pouzdani objekt domene

Granica API-ja mjesto je na kojem nepouzdani JSON postaje aplikacijski podatak. Mapper u nastavku zahtijeva svih sedam isporučenih mogućnosti, odbacuje prevelike ili duboko ugniježđene strukture i primjenjuje strože provjere na CSS varijable. Aplikacija ne pohranjuje ništa dok ovo preslikavanje ne uspije.

<?php
// src/BrandKit.php and src/BrandKitMapper.php
namespace App;

final readonly class BrandKit
{
    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 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,
        ];
    }
}

final class BrandKitMapper
{
    public function map(array $data): BrandKit
    {
        $name = $data['brand_name'] ?? null;
        if (!is_string($name) || trim($name) === '' || strlen($name) > 200) {
            throw new \UnexpectedValueException('Invalid brand_name');
        }

        foreach (['logos', 'colors', 'fonts', 'imagery', 'social_profiles'] as $field) {
            if (!isset($data[$field]) || !is_array($data[$field])) {
                throw new \UnexpectedValueException("Invalid {$field}");
            }
            $this->assertTree($data[$field], $field);
        }

        $css = $data['css_variables'] ?? null;
        if (!is_array($css)) {
            throw new \UnexpectedValueException('Invalid css_variables');
        }

        foreach ($css as $property => $value) {
            if (!is_string($property)
                || preg_match('/^--[A-Za-z0-9_-]{1,80}$/', $property) !== 1
                || !is_string($value)
                || strlen($value) > 512
                || preg_match('/[{};<>]/', $value) === 1
                || stripos($value, 'url(') !== false) {
                throw new \UnexpectedValueException('Unsafe CSS variable');
            }
        }

        return new BrandKit(
            trim($name),
            $data['logos'],
            $data['colors'],
            $data['fonts'],
            $data['imagery'],
            $data['social_profiles'],
            $css,
        );
    }

    private function assertTree(mixed $value, string $path, int $depth = 0): void
    {
        if ($depth > 8 || (is_array($value) && count($value) > 500)) {
            throw new \UnexpectedValueException("Oversized {$path}");
        }

        if (is_string($value) && strlen($value) > 4096) {
            throw new \UnexpectedValueException("Oversized string in {$path}");
        }

        if (is_array($value)) {
            foreach ($value as $child) {
                $this->assertTree($child, $path, $depth + 1);
            }
        } elseif (!is_null($value) && !is_scalar($value)) {
            throw new \UnexpectedValueException("Unsupported value in {$path}");
        }
    }
}

Ove provjere uspostavljaju strukturno povjerenje, a ne vlasništvo nad žigom ili dopuštenje za upotrebu resursa. Ako ponude mogu stvarati proizvoljni korisnici, dodajte korak odobravanja i ograničite uvoze na domene koje su ovlašteni predstavljati.

Obrađujte ponovne pokušaje bez umnožavanja neuspjeha

Mrežni neuspjesi, HTTP 429 i privremeni neuspjesi pristupnika mogu opravdati još jedan pokušaj. Neuspjesi autentikacije i validacije to ne opravdavaju. Tri ukupna pokušaja s eksponencijalnim odmakom drže operaciju ograničenom.

<?php
// src/BrandKitClient.php
namespace App;

final class ApiFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $status,
        string $message,
    ) {
        parent::__construct($message);
    }
}

final class BrandKitClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit';

    public function __construct(
        private Transport $transport,
        private BrandKitMapper $mapper,
        private string $token,
        private int $maxAttempts,
        private \Closure $sleep,
        private \Closure $log,
    ) {
        if ($token === '') {
            throw new \InvalidArgumentException('BRAND_KIT_TOKEN is missing');
        }
    }

    public function extract(string $website): BrandKit
    {
        $this->assertPublicHttpsUrl($website);

        for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::ENDPOINT,
                    $this->token,
                    ['url' => $website],
                );
            } catch (\RuntimeException $error) {
                ($this->log)(['event' => 'brand.transport_failure', 'attempt' => $attempt]);
                if ($attempt === $this->maxAttempts) {
                    throw new ApiFailure('transport', null, 'Brand extraction is unavailable');
                }
                ($this->sleep)(200 * (2 ** ($attempt - 1)));
                continue;
            }

            ($this->log)([
                'event' => 'brand.response',
                'status' => $response->status,
                'duration_ms' => $response->durationMs,
                'attempt' => $attempt,
            ]);

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    return $this->mapper->map(
                        json_decode($response->body, true, 32, JSON_THROW_ON_ERROR)
                    );
                } catch (\JsonException|\UnexpectedValueException $error) {
                    throw new ApiFailure('invalid_response', $response->status, $error->getMessage());
                }
            }

            if (in_array($response->status, [401, 403], true)) {
                throw new ApiFailure('authentication', $response->status, 'Check the service token');
            }

            $retryable = $response->status === 429
                || in_array($response->status, [502, 503, 504], true);

            if (!$retryable || $attempt === $this->maxAttempts) {
                $kind = $response->status === 429 ? 'rate_limit' : 'service';
                throw new ApiFailure($kind, $response->status, 'Brand extraction failed');
            }

            $retryAfter = ctype_digit($response->headers['retry-after'] ?? '')
                ? min(5000, (int) $response->headers['retry-after'] * 1000)
                : 200 * (2 ** ($attempt - 1));

            ($this->sleep)($retryAfter);
        }

        throw new ApiFailure('service', null, 'Brand extraction failed');
    }

    private function assertPublicHttpsUrl(string $url): void
    {
        $host = parse_url($url, PHP_URL_HOST);
        if (filter_var($url, FILTER_VALIDATE_URL) === false
            || parse_url($url, PHP_URL_SCHEME) !== 'https'
            || !is_string($host)
            || strtolower($host) === 'localhost'
            || filter_var($host, FILTER_VALIDATE_IP) !== false) {
            throw new \InvalidArgumentException('A public HTTPS website URL is required');
        }
    }
}

Poštujte numeričke vrijednosti Retry-After, ali ograničite odgodu kako naredba ne bi mogla čekati neograničeno. Dostupnost kvote ovisi o aktiviranom planu; operaterima prikažite neuspjehe ograničenja stope umjesto da ih prikrivate kao neispravne podatke brenda.

Sačuvajte atomsku snimku za renderiranje ponude

Zajedno pohranite izvorni URL, vrijeme uvoza i validirani komplet. Generator ponuda može se pozivati na dobiveni ID snimke, čime se osigurava da se stara ponuda ne promijeni neprimjetno kada se web-stranica redizajnira.

<?php
// src/BrandRepository.php
namespace App;

final class BrandRepository
{
    public function __construct(private string $directory) {}

    public function save(string $sourceUrl, BrandKit $kit): string
    {
        if (!is_dir($this->directory)
            && !mkdir($this->directory, 0750, true)
            && !is_dir($this->directory)) {
            throw new \RuntimeException('Cannot create brand snapshot directory');
        }

        $id = hash('sha256', $sourceUrl . "\0" . microtime(true));
        $target = $this->directory . '/' . $id . '.json';
        $temporary = tempnam($this->directory, 'brand-');

        if ($temporary === false) {
            throw new \RuntimeException('Cannot create temporary snapshot');
        }

        $document = [
            'source_url' => $sourceUrl,
            'imported_at' => gmdate(DATE_ATOM),
            'brand' => $kit->toArray(),
        ];

        try {
            $bytes = file_put_contents(
                $temporary,
                json_encode($document, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
                LOCK_EX,
            );
            if ($bytes === false || !chmod($temporary, 0640) || !rename($temporary, $target)) {
                throw new \RuntimeException('Cannot commit brand snapshot');
            }
        } finally {
            if (is_file($temporary)) {
                unlink($temporary);
            }
        }

        return $id;
    }
}

Naredba sastavlja komponente i ispisuje samo identifikator snimke:

<?php
// bin/import-brand.php
require dirname(__DIR__) . '/vendor/autoload.php';

use App\{BrandKitClient, BrandKitMapper, BrandRepository, CurlTransport};

$url = $argv[1] ?? '';
$logger = static fn(array $event) =>
    error_log(json_encode($event, JSON_THROW_ON_ERROR));

$client = new BrandKitClient(
    new CurlTransport(
        (int) (getenv('BRAND_KIT_CONNECT_TIMEOUT_MS') ?: 3000),
        (int) (getenv('BRAND_KIT_RESPONSE_TIMEOUT_MS') ?: 20000),
    ),
    new BrandKitMapper(),
    (string) getenv('BRAND_KIT_TOKEN'),
    (int) (getenv('BRAND_KIT_MAX_ATTEMPTS') ?: 3),
    static fn(int $milliseconds) => usleep($milliseconds * 1000),
    $logger,
);

$repository = new BrandRepository(
    (string) (getenv('BRAND_SNAPSHOT_DIR') ?: dirname(__DIR__) . '/var/brands')
);

try {
    $id = $repository->save($url, $client->extract($url));
    fwrite(STDOUT, $id . PHP_EOL);
    exit(0);
} catch (\Throwable $error) {
    $logger(['event' => 'brand.import_failed', 'type' => $error::class]);
    fwrite(STDERR, "Brand import failed\n");
    exit(1);
}
php bin/import-brand.php 'https://example.com'

Renderer izvješća trebao bi učitati tu snimku prema ID-u i preslikati odobrene unose logotipa, boja, fontova, slika i profila na društvenim mrežama u postojeći model predloška. Escapeajte tekstualne vrijednosti, koristite proxy ili izričito dopustite udaljene hostove slika i nikada nemojte izravno umetati vraćeni CSS u dokument. Čak i strukturno valjani vanjski podaci ostaju nepouzdan ulaz za renderiranje.

Testirajte ponovne pokušaje i validaciju bez mreže

Deterministički lažni transport čini putanje neuspjeha brzima i ponovljivima:

<?php
// tests/BrandKitClientTest.php
use App\{ApiFailure, BrandKitClient, BrandKitMapper, HttpResponse, Transport};
use PHPUnit\Framework\TestCase;

final class FakeTransport implements Transport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function postJson(string $url, string $token, array $payload): HttpResponse
    {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class BrandKitClientTest extends TestCase
{
    public function testRetriesRateLimitThenMapsBrand(): void
    {
        $valid = json_encode([
            'brand_name' => 'Example',
            'logos' => [], 'colors' => ['#112233'],
            'fonts' => ['Inter'], 'imagery' => [],
            'social_profiles' => [],
            'css_variables' => ['--brand-primary' => '#112233'],
        ], JSON_THROW_ON_ERROR);

        $fake = new FakeTransport([
            new HttpResponse(429, '{}', ['retry-after' => '1'], 5),
            new HttpResponse(200, $valid, [], 8),
        ]);
        $delays = [];

        $client = new BrandKitClient(
            $fake,
            new BrandKitMapper(),
            'test-token',
            3,
            static function (int $ms) use (&$delays): void { $delays[] = $ms; },
            static fn(array $event) => null,
        );

        self::assertSame('Example', $client->extract('https://example.com')->brandName);
        self::assertSame(2, $fake->calls);
        self::assertSame([1000], $delays);
    }

    public function testAuthenticationFailureIsNotRetried(): void
    {
        $fake = new FakeTransport([new HttpResponse(401, '{}', [], 4)]);
        $client = new BrandKitClient(
            $fake, new BrandKitMapper(), 'bad-token', 3,
            static fn(int $ms) => null,
            static fn(array $event) => null,
        );

        try {
            $client->extract('https://example.com');
            self::fail('Expected ApiFailure');
        } catch (ApiFailure $failure) {
            self::assertSame('authentication', $failure->kind);
            self::assertSame(1, $fake->calls);
        }
    }
}
vendor/bin/phpunit --testdox

Implementirajte uz operativne zaštitne mjere

Spremnost za produkciju uglavnom je disciplinirano ponašanje oko uspješnog puta. Ubrizgajte token iz upravitelja tajni, provjerite jesu li ekstenzije cURL i JSON omogućene, dopustite pisanje u var/brands samo identitetu aplikacije i spremite snimke na trajnu pohranu ako implementacije koriste efemerne datotečne sustave.

Emitirajte strukturirane događaje za latenciju, status, broj pokušaja, odbijanje validacije i konačni ishod. Upozorite na trajne neuspjehe autentikacije jer oni često upućuju na nepotpunu rotaciju tokena. Odvojeno pratite neuspjehe ograničenja stope od neuspjeha usluge kako bi iscrpljenost plana bila vidljiva.

Česti neuspjesi

  • 401 ili 403: potvrdite aktivaciju i token ograničen na uslugu; ponovno generiranje je možda opozvalo implementiranu vrijednost.
  • 429: poštujte ograničenu odgodu ponovnog pokušaja, zatim odgodite uvoz ili pregledajte dostupnost plana.
  • Neispravan odgovor: ne zadržavajte djelomičnu snimku; usporedite dokumentirani odgovor s mapperom prije promjene validacije.
  • Vremenska ograničenja: ponavljajte pokušaj samo unutar konfiguriranog proračuna pokušaja i istražite DNS, TLS ili dostupnost uzvodne usluge.
  • Neuspjeh zapisa snimke: provjerite vlasništvo nad direktorijem, slobodan prostor i je li datotečni sustav tijekom izvršavanja trajan.

Završni kontrolni popis provjere

  • Račun i odabrani plan su aktivni.
  • Token dolazi iz konfiguracije podržane varijablama okruženja te nije prisutan u kontroli izvornog koda ni zapisima.
  • Aplikacija poziva točnu POST krajnju točku s JSON poljem url.
  • Naziv brenda, logotipi, boje, fontovi, slike, profili na društvenim mrežama i CSS varijable validiraju se prije pohrane.
  • Neuspjesi autentikacije i validacije nikada se ne ponavljaju naslijepo.
  • Ograničenja stope i privremeni neuspjesi pristupnika koriste ograničeni odmak.
  • Testovi prolaze bez vanjskog HTTP prometa.
  • Stvarni uvoz stvara atomsku snimku koju generator ponuda ili izvješća može učitati prema ID-u.

Vrijedan rezultat nije samo uspješan API poziv. To je kontrolirani prijenos s promjenjive javne web-stranice na stabilnu, provjerljivu temu ponude. Kada je ta granica eksplicitna, uvozi brenda prestaju biti zbirka kopiranih datoteka i postaju pouzdan dio tijeka rada s dokumentima.

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.