Vodiči

Symfony: Inject Verified Brand Assets into Proposals with Brand Kit API

Symfony: Umetnite provjerene resurse robne marke u prijedloge pomoću API-ja Brand Kit

Generator ponuda postaje iznenađujuće krhak kada brendiranje stigne kao „plava boja s web-stranice” i logotip kopiran iz potpisa e-pošte. Resursi se mijenjaju, boje se nagađaju, a svaki novi klijent stvara još jedan ručni zadatak čišćenja.

Ovaj vodič zamjenjuje takav tijek rada Symfony integracijom spremnom za produkciju. Konzolna naredba šalje javnu web-stranicu klijenta API-ju Brand Kit Extractor, provjerava odgovor na granici aplikacije i sprema normaliziranu snimku za svakodnevni generator HTML ponuda ili izvještaja. Dizajn daje prednost eksplicitnim stanjima neuspjeha, ograničenim mrežnim pozivima, determinističkim testovima i sloju za pohranu koji se može razvijati bez vezivanja ostatka aplikacije uz vanjski odgovor.

Dobijte pristup i izradite servisni token

Registrirajte se putem stranice za registraciju ili upotrijebite stranicu za prijavu ako već imate račun.

  1. Otvorite stranicu usluge Brand Kit Extractor.
  2. Odaberite dostupni Free, Plus ili Pro plan i dovršite aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte token ograničen na uslugu.

Usluga zahtijeva autentifikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Ova implementacija koristi oblik Bearer jer zadržava vjerodajnice izvan URL-ova, zapisnika pristupa, povijesti preglednika i proxy analitike.

Ponovno generiranje servisnog tokena opoziva prethodno aktivni token. Rotaciju tretirajte kao operaciju uvođenja: ažurirajte spremište tajni, uvedite ili ponovno pokrenite potrošače, provjerite novi token i tek tada smatrajte promjenu dovršenom.

Potvrdite pristup s točnom krajnjom točkom

API poziv je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Njegovo JSON tijelo sadrži url. Testirajte ga prije dodavanja koda okvira:

curl --request POST \
  '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"}'

Ne lijepite stvarni token u primjere za kontrolu izvornog koda ili povijest ljuske. U Symfony projektu stavite ga u .env.local za lokalni razvoj:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

Produkcija bi trebala unijeti istu varijablu putem upravitelja tajni platforme za hosting. U .env predajte samo zamjensku vrijednost bez tajne, kao što je BRAND_KIT_TOKEN=.

Arhitektura za praktični generator ponuda

Ova implementacija pretpostavlja PHP 8.3 ili noviji i postojeću Symfony aplikaciju s dostupnim komponentama Console, HttpClient i Filesystem. Komponente koje nedostaju instalirajte pomoću:

composer require symfony/http-client symfony/console symfony/filesystem
composer require --dev symfony/test-pack

Uvoz ostaje sinkron jer je to konzolni zadatak koji pokreće operater, a ne dio interaktivnog zahtjeva. Time se izbjegava uvođenje Messengera samo radi premještanja kratke naredbe negdje drugdje. Ako uvozi kasnije potječu iz HTTP zahtjeva usmjerenih korisnicima, Messenger postaje koristan: stavite URL u red čekanja, odmah vratite odgovor i pokrenite istu aplikacijsku uslugu u radniku.

Projekt ima četiri granice:

  • BrandKitMapper odbacuje neispravne ili nepotpune vanjske podatke.
  • BrandKitExtractor upravlja autentifikacijom, vremenskim ograničenjima, ponovnim pokušajima i klasifikacijom HTTP neuspjeha.
  • BrandKitStore trajno sprema stabilan prikaz aplikacije.
  • ImportBrandKitCommand povezuje uvoznik s tijekom rada ponude.

Spremište temeljeno na datotekama dovoljno je za jednu instancu aplikacije ili zajednički trajni volumen. Više replika treba zamijeniti samo ovaj repozitorij postojanošću baze podataka ili objektne pohrane; ni API klijent ni prikazivač ponuda ne trebaju se mijenjati.

Preslikajte vanjski odgovor u domenski objekt

Ugovor pruža naziv brenda, logotipe, boje, fontove, slike, društvene profile i CSS varijable. Vanjski JSON nikada ne bi trebao teći izravno u Twig ili trajnu pohranu. Mapper u nastavku kanonizira samo ponuđene nazive polja najviše razine, zahtijeva svako polje i provodi konzervativne tipove.

Smjestite ove klase u src/BrandKit/BrandKit.php i src/BrandKit/BrandKitMapper.php:

<?php
// src/BrandKit/BrandKit.php
namespace App\BrandKit;

final readonly class BrandKit implements \JsonSerializable
{
    public function __construct(
        public string $sourceUrl,
        public string $brandName,
        public array $logos,
        public array $colors,
        public array $fonts,
        public array $imagery,
        public array $socialProfiles,
        public array $cssVariables,
        public array $logoUrls,
    ) {}

    public function jsonSerialize(): array
    {
        return [
            'source_url' => $this->sourceUrl,
            'brand_name' => $this->brandName,
            'logos' => $this->logos,
            'colors' => $this->colors,
            'fonts' => $this->fonts,
            'imagery' => $this->imagery,
            'social_profiles' => $this->socialProfiles,
            'css_variables' => $this->cssVariables,
            'logo_urls' => $this->logoUrls,
        ];
    }
}
<?php
// src/BrandKit/BrandKitMapper.php
namespace App\BrandKit;

final class BrandKitMapper
{
    public function map(string $sourceUrl, array $payload): BrandKit
    {
        $fields = [];

        foreach ($payload as $name => $value) {
            if (is_string($name)) {
                $canonical = strtolower(
                    preg_replace('/[^a-z0-9]+/i', '', $name) ?? ''
                );
                $fields[$canonical] = $value;
            }
        }

        $brandName = $this->required($fields, 'brandname');
        if (!is_string($brandName) || trim($brandName) === '') {
            throw new \UnexpectedValueException('Invalid brand name');
        }

        $arrays = [];
        foreach ([
            'logos', 'colors', 'fonts', 'imagery',
            'socialprofiles', 'cssvariables',
        ] as $field) {
            $value = $this->required($fields, $field);
            if (!is_array($value)) {
                throw new \UnexpectedValueException(
                    sprintf('Invalid brand-kit field: %s', $field)
                );
            }
            $arrays[$field] = $value;
        }

        return new BrandKit(
            $sourceUrl,
            trim($brandName),
            $arrays['logos'],
            $arrays['colors'],
            $arrays['fonts'],
            $arrays['imagery'],
            $arrays['socialprofiles'],
            $arrays['cssvariables'],
            $this->collectHttpUrls($arrays['logos']),
        );
    }

    private function required(array $fields, string $name): mixed
    {
        if (!array_key_exists($name, $fields)) {
            throw new \UnexpectedValueException(
                sprintf('Missing brand-kit field: %s', $name)
            );
        }

        return $fields[$name];
    }

    private function collectHttpUrls(array $values): array
    {
        $urls = [];

        array_walk_recursive($values, static function (mixed $value) use (&$urls): void {
            if (!is_string($value) || filter_var($value, FILTER_VALIDATE_URL) === false) {
                return;
            }

            $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
            if ($scheme === 'http' || $scheme === 'https') {
                $urls[] = $value;
            }
        });

        return array_values(array_unique($urls));
    }
}

Sakupljač URL-ova ne pretpostavlja nedokumentirani ugniježđeni oblik logotipa. Izvodi prikazive HTTP URL-ove iz bilo kojih strukturiranih dokaza o logotipu koje odgovor sadrži. Prazne kolekcije ostaju valjane jer neke javne web-stranice možda ne izlažu svaku kategoriju resursa.

Izgradite ograničen API klijent svjestan ponovnih pokušaja

Klijent ponavlja pokušaj samo kod ograničenja broja zahtjeva i privremenih pogrešaka poslužitelja. Pogreške autentifikacije i neispravni zahtjevi deterministički su dok se konfiguracija ili ulaz ne promijene, pa njihovo ponavljanje samo troši kvotu i odgađa dijagnostiku.

<?php
// src/BrandKit/BrandKitExtractor.php
namespace App\BrandKit;

use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

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

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

    private \Closure $sleep;

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly string $token,
        private readonly BrandKitMapper $mapper,
        ?\Closure $sleep = null,
    ) {
        if (trim($token) === '') {
            throw new \InvalidArgumentException('BRAND_KIT_TOKEN is empty');
        }

        $this->sleep = $sleep
            ?? static fn (int $milliseconds) => usleep($milliseconds * 1000);
    }

    public function extract(string $url): BrandKit
    {
        if (filter_var($url, FILTER_VALIDATE_URL) === false
            || !in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)
        ) {
            throw new \InvalidArgumentException('A public HTTP or HTTPS URL is required');
        }

        for ($attempt = 0; $attempt < 3; $attempt++) {
            try {
                $response = $this->http->request('POST', self::ENDPOINT, [
                    'headers' => [
                        'Authorization' => 'Bearer '.$this->token,
                        'Accept' => 'application/json',
                    ],
                    'json' => ['url' => $url],
                    'timeout' => 5.0,
                    'max_duration' => 20.0,
                ]);

                $status = $response->getStatusCode();
            } catch (TransportExceptionInterface $exception) {
                if ($attempt < 2) {
                    ($this->sleep)(250 * (2 ** $attempt));
                    continue;
                }

                throw new BrandKitImportException(
                    'transport', true, 'Brand-kit service was unreachable'
                );
            }

            if ($status >= 200 && $status < 300) {
                try {
                    $payload = json_decode(
                        $response->getContent(false),
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );

                    if (!is_array($payload)) {
                        throw new \JsonException('Expected a JSON object');
                    }

                    return $this->mapper->map($url, $payload);
                } catch (\JsonException|\UnexpectedValueException $exception) {
                    throw new BrandKitImportException(
                        'invalid_response', false, $exception->getMessage()
                    );
                }
            }

            if ($status === 401 || $status === 403) {
                throw new BrandKitImportException(
                    'authentication', false, 'Service token was rejected'
                );
            }

            if ($status === 400 || $status === 422) {
                throw new BrandKitImportException(
                    'invalid_request', false, 'The website URL was rejected'
                );
            }

            if ($status === 429 || $status >= 500) {
                if ($attempt < 2) {
                    $headers = $response->getHeaders(false);
                    $retryAfter = $headers['retry-after'][0] ?? null;
                    $delay = ctype_digit((string) $retryAfter)
                        ? min(2000, (int) $retryAfter * 1000)
                        : 250 * (2 ** $attempt);

                    ($this->sleep)($delay);
                    continue;
                }

                throw new BrandKitImportException(
                    $status === 429 ? 'rate_limit' : 'upstream',
                    true,
                    'Temporary brand-kit service failure'
                );
            }

            throw new BrandKitImportException(
                'http', false, sprintf('Unexpected HTTP status %d', $status)
            );
        }

        throw new \LogicException('Retry loop ended unexpectedly');
    }
}

Vremensko ograničenje od pet sekundi ograničava vezu i mrežnu neaktivnost, dok max_duration ograničava cijeli zahtjev. Kašnjenja ponovnih pokušaja također su ograničena. Tijela odgovora namjerno nisu uključena u iznimke jer nadređeno tijelo može sadržavati osjetljivu dijagnostiku ili prekomjerne podatke.

Trajno spremite snimke i izložite naredbu za uvoz

Konfigurirajte ubrizgavanje ovisnosti bez predavanja vjerodajnice:

# config/services.yaml
services:
    App\BrandKit\BrandKitExtractor:
        arguments:
            $token: '%env(BRAND_KIT_TOKEN)%'

    App\BrandKit\BrandKitStore:
        arguments:
            $directory: '%kernel.project_dir%/var/brand-kits'

Repozitorij koristi Filesystem::dumpFile() kako čitatelji ne bi vidjeli djelomično zapisan JSON dokument.

<?php
// src/BrandKit/BrandKitStore.php
namespace App\BrandKit;

use Symfony\Component\Filesystem\Filesystem;

final class BrandKitStore
{
    public function __construct(
        private readonly string $directory,
        private readonly Filesystem $filesystem,
    ) {}

    public function save(string $customer, BrandKit $kit): void
    {
        $this->assertCustomer($customer);
        $this->filesystem->mkdir($this->directory, 0750);
        $this->filesystem->dumpFile(
            $this->directory.'/'.$customer.'.json',
            json_encode(
                $kit,
                JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
            )
        );
    }

    public function find(string $customer): ?array
    {
        $this->assertCustomer($customer);
        $path = $this->directory.'/'.$customer.'.json';

        if (!is_file($path)) {
            return null;
        }

        $value = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);

        return is_array($value) ? $value : null;
    }

    private function assertCustomer(string $customer): void
    {
        if (!preg_match('/^[a-z0-9][a-z0-9-]{0,63}$/', $customer)) {
            throw new \InvalidArgumentException('Invalid customer identifier');
        }
    }
}
<?php
// src/Command/ImportBrandKitCommand.php
namespace App\Command;

use App\BrandKit\BrandKitExtractor;
use App\BrandKit\BrandKitImportException;
use App\BrandKit\BrandKitStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(name: 'app:brand-kit:import')]
final class ImportBrandKitCommand extends Command
{
    public function __construct(
        private readonly BrandKitExtractor $extractor,
        private readonly BrandKitStore $store,
        private readonly LoggerInterface $logger,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('customer', InputArgument::REQUIRED)
            ->addArgument('url', InputArgument::REQUIRED);
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $customer = (string) $input->getArgument('customer');
        $url = (string) $input->getArgument('url');

        try {
            $kit = $this->extractor->extract($url);
            $this->store->save($customer, $kit);
        } catch (BrandKitImportException $exception) {
            $this->logger->error('Brand-kit import failed', [
                'customer' => $customer,
                'host' => parse_url($url, PHP_URL_HOST),
                'kind' => $exception->kind,
                'retryable' => $exception->retryable,
            ]);
            $output->writeln('<error>Import failed: '.$exception->kind.'</error>');

            return Command::FAILURE;
        }

        $this->logger->info('Brand-kit import completed', [
            'customer' => $customer,
            'brand_name' => $kit->brandName,
        ]);
        $output->writeln('Imported brand kit for '.$kit->brandName);

        return Command::SUCCESS;
    }
}

Pokrenite php bin/console app:brand-kit:import acme https://www.example.com. Dobiveni var/brand-kits/acme.json postaje stabilan ulaz generatora ponuda.

Umetnite sigurne vrijednosti u ponude

Prikazivač ne bi trebao pretvoriti proizvoljni izdvojeni CSS u izvršivi tekst stilskog lista. Namjerno odaberite vrijednosti, provjerite ih i zadržite neutralnu zamjensku vrijednost. Isto načelo vrijedi za URL-ove logotipa: Twig escaping štiti HTML sintaksu, ali PDF pokretač na poslužitelju ne smije dohvaćati udaljene resurse bez zasebnih SSRF kontrola i odobrenog programa za preuzimanje.

<?php
$brand = $brandKitStore->find($customer)
    ?? throw $this->createNotFoundException('Brand kit not imported');

$accent = $brand['css_variables']['--primary-color'] ?? '#1f2937';
if (!is_string($accent) || !preg_match('/^#[0-9a-f]{6}$/i', $accent)) {
    $accent = '#1f2937';
}

return $this->render('proposal/show.html.twig', [
    'proposal' => $proposal,
    'brandName' => $brand['brand_name'],
    'logoUrl' => $brand['logo_urls'][0] ?? null,
    'accent' => $accent,
]);
<article style="--proposal-accent: {{ accent }}">
  {% if logoUrl %}
    <img src="{{ logoUrl }}" alt="{{ brandName }} logo">
  {% endif %}
  <h1>Proposal for {{ brandName }}</h1>
  {# Render the existing proposal or report content here. #}
</article>

„Provjereno” ovdje znači da je aplikacija prihvatila strukturno valjano izdvajanje utemeljeno na dokazima, povezano sa zatraženom javnom web-stranicom. To ne utvrđuje prava na zaštitni znak, dopuštenje za licenciranje ni sigurnost udaljenih datoteka za preuzimanje.

Testirajte uspjeh, ponovne pokušaje i neispravne podatke

MockHttpClient održava testove determinističkima i sprječava slučajni pristup mreži. Ubrizgavanje uspavljivača bez operacije ubrzava testove ponovnih pokušaja.

<?php
namespace App\Tests\BrandKit;

use App\BrandKit\BrandKitExtractor;
use App\BrandKit\BrandKitImportException;
use App\BrandKit\BrandKitMapper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class BrandKitExtractorTest extends TestCase
{
    private function payload(): string
    {
        return json_encode([
            'brand_name' => 'Example',
            'logos' => ['https://example.com/logo.svg'],
            'colors' => ['#123456'],
            'fonts' => [],
            'imagery' => [],
            'social_profiles' => [],
            'css_variables' => ['--primary-color' => '#123456'],
        ], JSON_THROW_ON_ERROR);
    }

    public function testMapsACompleteResponse(): void
    {
        $http = new MockHttpClient([
            new MockResponse($this->payload(), ['http_code' => 200]),
        ]);

        $extractor = new BrandKitExtractor(
            $http,
            'test-token',
            new BrandKitMapper(),
            static fn (int $milliseconds) => null,
        );

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

        self::assertSame('Example', $kit->brandName);
        self::assertSame(['https://example.com/logo.svg'], $kit->logoUrls);
        self::assertSame(1, $http->getRequestsCount());
    }

    public function testRetriesAServiceFailure(): void
    {
        $http = new MockHttpClient([
            new MockResponse('', ['http_code' => 503]),
            new MockResponse($this->payload(), ['http_code' => 200]),
        ]);

        $extractor = new BrandKitExtractor(
            $http, 'test-token', new BrandKitMapper(),
            static fn (int $milliseconds) => null,
        );

        self::assertSame('Example', $extractor->extract('https://example.com')->brandName);
        self::assertSame(2, $http->getRequestsCount());
    }

    public function testRejectsAnIncompleteResponseWithoutRetrying(): void
    {
        $http = new MockHttpClient([
            new MockResponse('{"brand_name":"Incomplete"}', ['http_code' => 200]),
        ]);

        $extractor = new BrandKitExtractor(
            $http, 'test-token', new BrandKitMapper(),
            static fn (int $milliseconds) => null,
        );

        $this->expectException(BrandKitImportException::class);
        $extractor->extract('https://example.com');
    }
}

Pokrenite php bin/phpunit, a zatim testirajte naredbu s identifikatorom klijenta koji nije produkcijski.

Uvođenje, nadzor i česti neuspjesi

Omogućite da var/brand-kits bude zapisiv korisniku aplikacije i trajan kroz izdanja. U implementaciji s više čvorova premjestite snimke u zajedničku pohranu. Zagrijte konfiguraciju aplikacije nakon ubrizgavanja BRAND_KIT_TOKEN i ponovno pokrenite dugotrajne procese pri njegovoj rotaciji.

Nadzirite broj dovršenih i neuspjelih operacija prema kind, kao i trajanje zahtjeva na razini HTTP klijenta. Nikada ne zapisujte tokene, puna autorizacijska zaglavlja, tijela odgovora ni nizove upita URL-a. Naredba zapisuje samo identifikator klijenta i host web-stranice.

  • Neuspjeh autentifikacije: potvrdite aktivaciju plana i trenutačni servisni token. Ponovno generirani token poništava prethodni.
  • Neispravan zahtjev: provjerite je li URL javan, apsolutan i koristi li HTTP ili HTTPS.
  • Ograničenje broja zahtjeva: pustite da ograničeni ponovni pokušaji završe, zatim zakažite uvoz za kasnije ili provjerite kapacitet plana.
  • Neispravan odgovor: sačuvajte posljednju poznatu ispravnu snimku, upozorite na neuspjeh sheme i usporedite dokumentaciju usluge prije mijenjanja mappera.
  • Logotip nedostaje: prihvatite valjani brand kit i prikažite ponudu bez logotipa umjesto da ga nagađate.
  • Sustav datoteka samo za čitanje ili privremen: zamijenite repozitorij trajnom bazom podataka ili objektnom pohranom prije dodavanja replika.

Završni kontrolni popis za provjeru

  • Račun i Free, Plus ili Pro plan su aktivni.
  • Trenutačni token ograničen na uslugu dostavlja se putem BRAND_KIT_TOKEN.
  • Minimalni POST zahtjev uspijeva prema točnoj krajnjoj točki za izdvajanje.
  • Svih sedam obveznih odjeljaka brenda provjerava se prije trajne pohrane.
  • Neuspjesi autentifikacije i provjere nikada se ne ponavljaju naslijepo.
  • Ograničenja broja zahtjeva, transportne pogreške i neuspjesi poslužitelja koriste ograničene ponovne pokušaje.
  • Direktorij snimaka je zapisiv, trajan i isključen iz javnog pristupa webu.
  • CSS vrijednosti ponude nalaze se na popisu dopuštenih i provjerene su.
  • Udaljene resurse ne dohvaćaju prikazivači na poslužitelju bez SSRF zaštite.
  • Testovi prolaze bez stvarnih mrežnih zahtjeva.

Trajno poboljšanje nije samo to što se logotip pojavljuje automatski. Ono je u tome što brendiranje postaje kontroliran ulaz: pribavljen kroz jednu eksplicitnu API granicu, provjeren prije pohrane, vidljiv kada ne uspije i sigurno prilagođen prikazivaču ponuda. Time se ponavljajući dizajnerski posao pretvara u pouzdan dio aplikacije, umjesto u još jednu zbirku kopiranih datoteka i optimističnih pretpostavki.

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.