Туториали

Symfony: Automate Proposals with Verified Brand Assets via API

Symfony: Автоматизирајте понуди со потврдени бренд-материјали преку API

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

Ова упатство го гради тој работен тек во Symfony и PHP 8.3. Конзолна команда го повикува API-то Brand Kit Extractor, го мапира неговиот одговор во доменски објект и зачувува атомска JSON снимка. Генерирањето предлози и извештаи потоа ја чита кешираната снимка наместо да повикува надворешна услуга за време на барање насочено кон клиент.

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

Започнете со создавање сметка на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате.

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

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

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

Потврдете го точниот API повик

Операцијата за извлекување е POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Прифаќа JSON што содржи url. Тестирајте ги ингеренциите пред да напишете интеграциски код:

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 "Content-Type: application/json" \
  --data '{"url":"https://example.com"}'

Користете јавна веб-страница што имате право да ја обработувате. Успешен одговор треба да ги содржи името на брендот, логоата, боите, фонтoвите, сликите, профилите на социјалните мрежи и CSS променливите. Не претпоставувајте дека успешен HTTP статус ја прави секоја вредност безбедна или погодна за објавување; границата на Symfony ќе ја валидира структурата пред зачувување.

Ставете го токенот во .env.local, кој Symfony проектите вообичаено го исклучуваат од контрола на верзии:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

Архитектура: увезете еднаш, рендерирајте многупати

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

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

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

Создадете Symfony проект

Потребни ви се PHP 8.3 или понов, Composer и појдовен HTTPS пристап од извршната околина. Создадете фокусирана Symfony апликација и инсталирајте компоненти од прва страна:

composer create-project symfony/skeleton proposal-branding
cd proposal-branding
composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack

Создадете src/Brand за интеграцијата и var/brand-kits за генерираните снимки. Второто мора да биде запишливо во продукција и трајно низ изданијата.

Поврзете го токенот во config/services.yaml. Стандардното откривање услуги на Symfony може автоматски да ги поврзе преостанатите зависности:

parameters:
    brand_kit.storage_dir: '%kernel.project_dir%/var/brand-kits'

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    App\Brand\BrandKitExtractor:
        arguments:
            $token: '%env(BRAND_KIT_TOKEN)%'

    App\Brand\BrandKitStore:
        arguments:
            $directory: '%brand_kit.storage_dir%'

Мапирајте го одговорот во строг доменски објект

Чувајте го знаењето за надворешните имиња на полиња во една класа. Полињата со низи може да содржат низи, објекти или вгнездени докази во зависност од откриената страница, па затоа границата бара JSON-компатибилни низи без измислување потесна, недокументирана шема.

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

final readonly class BrandKit implements \JsonSerializable
{
    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 string $sourceUrl,
        public string $importedAt,
    ) {}

    public static function fromApi(array $data, string $sourceUrl): self
    {
        $brandName = $data['brand_name'] ?? null;

        if (!is_string($brandName) || trim($brandName) === '') {
            throw new \UnexpectedValueException('Missing or invalid brand_name.');
        }

        $arrays = [];
        foreach ([
            'logos',
            'colors',
            'fonts',
            'imagery',
            'social_profiles',
            'css_variables',
        ] as $field) {
            if (!array_key_exists($field, $data) || !is_array($data[$field])) {
                throw new \UnexpectedValueException(
                    sprintf('Missing or invalid %s.', $field)
                );
            }

            self::assertJsonValue($data[$field], $field);
            $arrays[$field] = $data[$field];
        }

        return new self(
            trim($brandName),
            $arrays['logos'],
            $arrays['colors'],
            $arrays['fonts'],
            $arrays['imagery'],
            $arrays['social_profiles'],
            $arrays['css_variables'],
            $sourceUrl,
            (new \DateTimeImmutable())->format(DATE_ATOM),
        );
    }

    private static function assertJsonValue(mixed $value, string $path): void
    {
        if (is_array($value)) {
            foreach ($value as $key => $child) {
                self::assertJsonValue($child, $path.'.'.$key);
            }
            return;
        }

        if (!is_null($value) && !is_scalar($value)) {
            throw new \UnexpectedValueException('Invalid value at '.$path);
        }
    }

    public function jsonSerialize(): array
    {
        return get_object_vars($this);
    }
}

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

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

Клиентот повторува само неуспеси што веројатно се привремени: транспортни грешки, 429 и одговори 5xx од страната на серверот. Неуспесите на автентикацијата и валидацијата на барањето се враќаат веднаш, бидејќи повторувањето на истиот влез и ингеренции би трошело квота и би ја одложило дијагностиката.

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

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

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

    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $token,
    ) {}

    public function extract(string $url): BrandKit
    {
        $this->assertPublicHttpUrl($url);
        $lastError = null;

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', self::ENDPOINT, [
                    'auth_bearer' => $this->token,
                    'json' => ['url' => $url],
                    'timeout' => 10.0,
                    'max_duration' => 20.0,
                ]);

                $status = $response->getStatusCode();

                if ($status >= 200 && $status < 300) {
                    return BrandKit::fromApi($response->toArray(false), $url);
                }

                if (in_array($status, [400, 401, 403, 422], true)) {
                    throw new BrandKitImportFailed(
                        'Remote request rejected.',
                        $status,
                        false
                    );
                }

                if ($status !== 429 && $status < 500) {
                    throw new BrandKitImportFailed(
                        'Unexpected remote response.',
                        $status,
                        false
                    );
                }

                $lastError = new BrandKitImportFailed(
                    $status === 429
                        ? 'Rate limit or quota reached.'
                        : 'Remote service is temporarily unavailable.',
                    $status,
                    true
                );
            } catch (TransportExceptionInterface $error) {
                $lastError = new BrandKitImportFailed(
                    'Transport failure while importing the brand kit.',
                    0,
                    true,
                    $error
                );
            }

            $this->logger->warning('Brand-kit import attempt failed', [
                'url' => $url,
                'attempt' => $attempt,
                'retryable' => $lastError->retryable,
                'status' => $lastError->getCode(),
            ]);

            if (!$lastError->retryable || $attempt === 3) {
                throw $lastError;
            }

            usleep((250 * (2 ** ($attempt - 1)) + random_int(0, 100)) * 1000);
        }

        throw $lastError;
    }

    private function assertPublicHttpUrl(string $url): void
    {
        $parts = parse_url($url);

        if (
            !is_array($parts)
            || !in_array($parts['scheme'] ?? '', ['http', 'https'], true)
            || empty($parts['host'])
        ) {
            throw new \InvalidArgumentException(
                'A public HTTP or HTTPS URL is required.'
            );
        }
    }
}
<?php
// src/Brand/BrandKitImportFailed.php
namespace App\Brand;

final class BrandKitImportFailed extends \RuntimeException
{
    public function __construct(
        string $message,
        int $status,
        public readonly bool $retryable,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, $status, $previous);
    }
}

Дневниците намерно ги изоставуваат токенот и телото на одговорот. Ако произволни корисници можат да поднесуваат URL-адреси, додајте листа на дозволени домени во апликацијата или потврда на сопственоста. Валидацијата на шемата спречува неправилен влез, но не утврдува дека барателот го контролира оддалечениот домен.

Чувајте само целосни снимки

Прекинато запишување не смее да замени исправен пакет за бренд со половина JSON документ. Запишете во привремена датотека во одредишниот директориум и преименувајте ја атомски:

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

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

    public function save(string $client, BrandKit $kit): void
    {
        if (!preg_match('/\A[a-z0-9][a-z0-9-]{1,63}\z/', $client)) {
            throw new \InvalidArgumentException('Invalid client identifier.');
        }

        if (!is_dir($this->directory)
            && !mkdir($this->directory, 0770, true)
            && !is_dir($this->directory)) {
            throw new \RuntimeException('Cannot create brand-kit directory.');
        }

        $target = $this->directory.'/'.$client.'.json';
        $temporary = tempnam($this->directory, 'brand-kit-');

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

        try {
            $json = json_encode(
                $kit,
                JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
            );

            if (file_put_contents($temporary, $json, LOCK_EX) === false
                || !rename($temporary, $target)) {
                throw new \RuntimeException('Cannot publish brand-kit snapshot.');
            }
        } finally {
            if (is_file($temporary)) {
                unlink($temporary);
            }
        }
    }

    public function get(string $client): BrandKit
    {
        $data = json_decode(
            file_get_contents($this->directory.'/'.$client.'.json'),
            true,
            512,
            JSON_THROW_ON_ERROR
        );

        return new BrandKit(
            $data['brandName'],
            $data['logos'],
            $data['colors'],
            $data['fonts'],
            $data['imagery'],
            $data['socialProfiles'],
            $data['cssVariables'],
            $data['sourceUrl'],
            $data['importedAt'],
        );
    }
}

Изложете го увозот како оперативна команда

<?php
// src/Command/ImportBrandKitCommand.php
namespace App\Command;

use App\Brand\BrandKitExtractor;
use App\Brand\BrandKitImportFailed;
use App\Brand\BrandKitStore;
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 BrandKitExtractor $extractor,
        private BrandKitStore $store,
    ) {
        parent::__construct();
    }

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

    protected function execute(
        InputInterface $input,
        OutputInterface $output
    ): int {
        try {
            $client = (string) $input->getArgument('client');
            $kit = $this->extractor->extract(
                (string) $input->getArgument('url')
            );
            $this->store->save($client, $kit);
            $output->writeln('Imported brand kit for '.$kit->brandName);

            return Command::SUCCESS;
        } catch (BrandKitImportFailed | \InvalidArgumentException $error) {
            $output->writeln('<error>'.$error->getMessage().'</error>');
            return Command::FAILURE;
        }
    }
}

Увезете клиент со php bin/console app:brand-kit:import acme https://www.example.com. Генераторот на предлози може да вбризга BrandKitStore, да повика get('acme') и да ги предаде добиените логоа, бои, фонтови, слики, профили на социјалните мрежи и CSS променливи во својот слој за рендерирање документи.

Не вметнувајте оддалечени CSS променливи или URL-адреси на средства директно во HTML. Изберете одобрени записи, повторно валидирајте ги URL-адресите при рендерирање, екранувајте текст и ограничете ги CSS вредностите на формати што ги поддржуваат вашите шаблони.

Тестирајте ја надворешната граница без мрежен пристап

MockHttpClient го прави однесувањето при успех и неуспех детерминистичко:

<?php
// tests/Brand/BrandKitExtractorTest.php
namespace App\Tests\Brand;

use App\Brand\BrandKitExtractor;
use App\Brand\BrandKitImportFailed;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class BrandKitExtractorTest extends TestCase
{
    public function testMapsACompleteResponse(): void
    {
        $response = new MockResponse(json_encode([
            'brand_name' => 'Example',
            'logos' => [['url' => 'https://example.com/logo.svg']],
            'colors' => ['#112233'],
            'fonts' => ['Inter'],
            'imagery' => [],
            'social_profiles' => [],
            'css_variables' => ['--brand-primary' => '#112233'],
        ], JSON_THROW_ON_ERROR), ['http_code' => 200]);

        $extractor = new BrandKitExtractor(
            new MockHttpClient($response),
            new NullLogger(),
            'test-token'
        );

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

        self::assertSame('Example', $kit->brandName);
        self::assertSame(['#112233'], $kit->colors);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $calls = 0;
        $client = new MockHttpClient(
            function () use (&$calls): MockResponse {
                $calls++;
                return new MockResponse('', ['http_code' => 401]);
            }
        );

        $extractor = new BrandKitExtractor(
            $client,
            new NullLogger(),
            'invalid-token'
        );

        try {
            $extractor->extract('https://example.com');
            self::fail('Expected import failure.');
        } catch (BrandKitImportFailed $error) {
            self::assertFalse($error->retryable);
            self::assertSame(401, $error->getCode());
            self::assertSame(1, $calls);
        }
    }
}

Извршете php bin/phpunit. Додајте тестови за маперот за полиња што недостигаат, неправилни низи и невалиден JSON пред да го промените адаптерот за одговор.

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

Вбризгајте BRAND_KIT_TOKEN преку менаџерот за тајни на вашата хостинг-платформа. Создадете го директориумот за снимки при распоредувањето, доделете го на PHP корисникот и поставете го на трајно складиште кога изданијата користат привремени контејнери. Топлите распоредувања треба да ја зачуваат последната валидна снимка.

Следете го времетраењето на увозот, исходот, HTTP статусот, бројот на повторни обиди, идентификаторот на клиентот и изворниот хост. Поставете предупредување за трајни одговори 401 или 403 бидејќи тие обично укажуваат на поништен или неправилно распореден токен. Третирајте ги трајните одговори 429 како сигнали за квота или распоредување, а не како дозвола за неограничени повторни обиди.

  • 401 или 403: потврдете го токенот со опсег на услугата и дали е повторно генериран.
  • 400 или 422: проверете ја поднесената јавна URL-адреса; не повторувајте непроменет влез.
  • 429: запрете по ограничените обиди, задржете ја претходната снимка и обидете се повторно подоцна.
  • 5xx или транспортски истек: дозволете ја кратката низа на повлекување, потоа неуспешно завршете без да ги замените зачуваните податоци.
  • Неуспех при мапирање: споредете го тековниот документиран одговор со централизираниот мапер и прво ажурирајте ги тестовите.
  • Складиште без дозвола за запишување: поправете ја сопственоста при распоредувањето или монтирајте трајно складиште; не користете небезбеден јавен директориум како резервно решение.

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

  • Точната POST крајна точка успева со тест URL што не е за продукција.
  • Токенот постои само во конфигурација на тајни поддржана од променливи на околината.
  • Сите седум категории податоци за бренд се валидираат пред зачувување.
  • Неуспесите на автентикацијата и валидацијата никогаш не се повторуваат слепо.
  • Ограничувањата на стапката, неуспесите на серверот и транспортните грешки имаат ограничени повторни обиди.
  • Дневниците содржат оперативен контекст, но немаат ингеренции или необработено тело на одговор.
  • Замената на снимката е атомска и складиштето го преживува распоредувањето.
  • Автоматизираните тестови поминуваат без мрежно барање.
  • Генерираниот предлог користи одобрена локална снимка и го екранува излезот.

Важната продукциска одлука не е само како да се повика API. Таа е каде да се постави довербата. Со одвојување на извлекувањето, валидацијата, складирањето, одобрувањето и рендерирањето, генераторот на предлози добива доследно брендирање без секој документ да зависи од активно надворешно барање. Резултатот е потивка инфраструктура, побезбедни шаблони и предлози што изгледаат намерно подготвени, наместо составени во последен момент.

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

Mihajlo

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