Туториали

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

Symfony: Вметнете проверени бренд-материјали во понуди со Brand Kit API

Генераторот на понуди станува изненадувачки кревок кога брендирањето пристигнува како „сината боја од веб-страницата“ и лого копирано од потпис во е-пошта. Ресурсите се разидуваат, боите се нагаѓаат, а секој нов клиент создава уште една рачна задача за доработка.

Ова упатство го заменува тој работен тек со Symfony интеграција насочена кон продукциска употреба. Конзолна команда ја испраќа јавната веб-страница на клиентот до API-то Brand Kit Extractor, го валидира одговорот на границата на апликацијата и зачувува нормализирана снимка за секојдневен HTML генератор на понуди или извештаи. Дизајнот фаворизира експлицитни состојби на неуспех, ограничени мрежни повици, детерминистички тестови и слој за складирање што може да се развива без да го поврзе остатокот од апликацијата со надворешниот одговор.

Добијте пристап и создајте сервисен токен

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

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

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

Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Третирајте ја ротацијата како операција на распоредување: ажурирајте го складиштето за тајни, распоредете ги или рестартирајте ги потрошувачите, потврдете го новиот токен и дури потоа сметајте дека промената е завршена.

Потврдете го пристапот со точната крајна точка

API повикот е POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Неговото JSON тело содржи url. Тестирајте го пред да додадете код од рамката:

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

Не внесувајте вистински токен во контрола на изворниот код или во примери од историјата на школката. Во Symfony проектот, ставете го во .env.local за локален развој:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

Продукцијата треба да ја вметне истата променлива преку управувачот со тајни на хостинг-платформата. Во .env внесете само не-таен заменик, како BRAND_KIT_TOKEN=.

Архитектура за практичен генератор на понуди

Оваа имплементација претпоставува PHP 8.3 или понов и постоечка Symfony апликација со достапни Console, HttpClient и Filesystem. Инсталирајте ги компонентите што недостасуваат со:

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

Увозот останува синхрон бидејќи е конзолна задача активирана од оператор, а не дел од интерактивно барање. Така се избегнува воведување на Messenger само за преместување на кратка команда на друго место. Ако увозите подоцна потекнуваат од HTTP барања насочени кон клиентите, Messenger станува оправдан: ставете ја URL-адресата во ред, вратете веднаш и извршете ја истата апликациска услуга во worker.

Проектот има четири граници:

  • BrandKitMapper отфрла неправилни или нецелосни надворешни податоци.
  • BrandKitExtractor управува со автентикацијата, временските ограничувања, повторните обиди и класификацијата на HTTP неуспесите.
  • BrandKitStore ја зачувува стабилната претстава на апликацијата.
  • ImportBrandKitCommand го поврзува увозникот со работниот тек за понуди.

Складиште засновано на датотеки е доволно за една инстанца на апликацијата или споделен траен волумен. Повеќе реплики треба да го заменат само ова складиште со постојаност во база на податоци или object storage; ниту API клиентот ниту рендерерот на понуди не треба да се менуваат.

Пресликајте го надворешниот одговор во доменски објект

Договорот обезбедува име на бренд, логоа, бои, фонтови, слики, социјални профили и CSS променливи. Надворешниот JSON никогаш не треба директно да тече во Twig или во постојаното складирање. Маперот подолу ги канонизира само доставените имиња на полиња од највисоко ниво, го бара секое поле и наметнува конзервативни типови.

Поставете ги овие класи во src/BrandKit/BrandKit.php и 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));
    }
}

Собирачот на URL-адреси не претпоставува недокументирана вгнездена структура на лого. Тој извлекува HTTP URL-адреси погодни за прикажување од каков било структуриран доказ за лого што го содржи одговорот. Празните колекции остануваат валидни затоа што некои јавни веб-страници можеби не ја изложуваат секоја категорија на ресурси.

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

Клиентот повторува само при ограничување на стапката и привремени серверски неуспеси. Грешките при автентикација и неважечките барања се детерминистички сè додека не се променат конфигурацијата или влезот, па нивното повторување само троши квота и ја одложува дијагнозата.

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

Временското ограничување од пет секунди ги ограничува поврзувањето и мрежната неактивност, додека max_duration го ограничува целото барање. Доцнењата при повторните обиди исто така се ограничени. Телата на одговорите намерно се изоставени од исклучоците бидејќи upstream тело може да содржи чувствителна дијагностика или прекумерни податоци.

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

Конфигурирајте dependency injection без да ја предадете ингеренцијата во комит:

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

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

Складиштето користи Filesystem::dumpFile() за читателите да не видат делумно запишан JSON документ.

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

Извршете php bin/console app:brand-kit:import acme https://www.example.com. Добиениот var/brand-kits/acme.json станува стабилен влез за генераторот на понуди.

Вметнете безбедни вредности во понудите

Рендерерот не треба да претвора произволен извлечен CSS во извршлив текст за стилови. Избирајте ги вредностите намерно, валидирајте ги и задржете неутрална резервна вредност. Истиот принцип важи и за URL-адресите на логоата: Twig escaping ја штити HTML синтаксата, но PDF engine на серверската страна не смее да презема оддалечени ресурси без посебни SSRF контроли и одобрен преземач.

<?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>

„Потврдено“ овде значи дека апликацијата прифатила структурно валидно извлекување засновано на докази, поврзано со побараната јавна веб-страница. Тоа не утврдува права за трговска марка, дозвола за лиценцирање или дека оддалечените датотеки се безбедни за преземање.

Тестирајте успех, повторни обиди и неправилни податоци

MockHttpClient ги одржува тестовите детерминистички и спречува случаен мрежен пристап. Вметнувањето sleeper без дејство ги прави тестовите за повторни обиди брзи.

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

Извршете php bin/phpunit, а потоа тестирајте ја командата со идентификатор на клиент што не е за продукција.

Распоредување, следење и вообичаени неуспеси

Направете var/brand-kits да може да се запишува од корисникот на апликацијата и да биде траен низ изданијата. Во распоредување со повеќе јазли, преместете ги снимките во споделено складирање. Загрејте ја конфигурацијата на апликацијата по вметнувањето на BRAND_KIT_TOKEN и рестартирајте ги долготрајните процеси при негова ротација.

Следете ги бројот на завршувања и неуспеси според kind, како и траењето на барањата на слојот на HTTP клиентот. Никогаш не евидентирајте токени, целосни заглавија за авторизација, тела на одговори или низи за пребарување во URL-адресите. Командата ги евидентира само идентификаторот на клиентот и хостот на веб-страницата.

  • Неуспех при автентикација: потврдете ја активацијата на планот и тековниот сервисен токен. Повторно генериран токен го поништува претходниот.
  • Неважечко барање: проверете дали URL-адресата е јавна, апсолутна и користи HTTP или HTTPS.
  • Ограничување на стапката: дозволете ограничените повторни обиди да завршат, потоа закажете го увозот подоцна или проверете го капацитетот на планот.
  • Неважечки одговор: зачувајте ја последната позната добра снимка, алармирајте при неуспех на шемата и споредете ја документацијата за услугата пред да го промените маперот.
  • Недостасува лого: прифатете го валидниот brand kit и прикажете ја понудата без лого, наместо да нагаѓате.
  • Датотечен систем само за читање или еферемен: заменете го складиштето со трајна база на податоци или object storage пред да додадете реплики.

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

  • Сметката и Free, Plus или Pro планот се активни.
  • Тековниот токен ограничен на услугата се доставува преку BRAND_KIT_TOKEN.
  • Минималното POST барање успева кон точната крајна точка за извлекување.
  • Сите седум задолжителни делови на брендот се валидираат пред зачувување.
  • Неуспесите при автентикација и валидација никогаш не се повторуваат слепо.
  • Ограничувањата на стапката, транспортните грешки и серверските неуспеси користат ограничени повторни обиди.
  • Директориумот за снимки е запишлив, траен и исклучен од јавен веб-пристап.
  • CSS вредностите на понудата се на allow-list и валидирани.
  • Оддалечените ресурси не се преземаат од рендерери на серверската страна без SSRF заштита.
  • Тестовите поминуваат без вистински мрежни барања.

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

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

Mihajlo

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