Туториали

Symfony Onboarding: Auto-Draft Landing Page Themes with Brand Kit Extractor API

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

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

Во ова упатство ќе изградиме Symfony endpoint за воведување што ја испраќа веб-страницата на клиентот до API-то Brand Kit Extractor, ги валидира добиените докази за брендот, изведува намерно конзервативна тема и ја зачувува како нацрт. Ништо не се објавува автоматски. Клиентот и понатаму го прегледува и одобрува резултатот.

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

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

Пред да напишете код за интеграција, креирајте или пристапете до вашата сметка:

  1. Регистрирајте се на https://ai.mihajlo.mk/register или најавете се на https://ai.mihajlo.mk/login.
  2. Отворете ја страницата на услугата Brand Kit Extractor.
  3. Изберете достапен Free, Plus или Pro план и завршете ја активацијата.
  4. Отворете ја официјалната документација за услугата.
  5. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.

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

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

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

Интеграцијата го прави следново барање:

POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit

JSON-телото содржи едно поле, url. Тестирајте ги ингеренциите пред да го вклучите Symfony:

curl --fail-with-body \
  --request POST \
  --url 'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://www.example.com"}'

Не внесувајте вистински токен во историјата на школката на споделена машина. За локален развој, ставете го во игнорираната датотека .env.local на Symfony. Во продукција, внесете ја истата променлива на околината преку хостинг-платформата или менаџерот за тајни.

# .env.local
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

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

Функционалноста има четири граници: автентициран контролер за воведување, HTTP-клиент, доменски мапер и табела во база на податоци. Маперот ги прифаќа вратените име на бренд, логоа, бои, фонтови, слики, социјални профили и CSS-променливи само откако ќе ги валидира нивните типови и вредности.

Овој пример го извршува извлекувањето синхроно. Тоа овозможува скромниот тек на воведување лесно да се управува и клиентот веднаш да добие нацрт. Ако измерените времиња на одговор го надминат буџетот за веб-барања, преместете го истиот повик на клиентот зад Symfony Messenger и нека контролерот враќа идентификатор на прифатена задача. Не додавајте редица само за да ги прикриете недостасувачките истекувања на време.

Релевантните датотеки се:

src/
  Brand/BrandKitClient.php
  Brand/BrandKitDraft.php
  Brand/BrandKitException.php
  Controller/OnboardingThemeController.php
tests/
  Brand/BrandKitClientTest.php
migrations/
  VersionCreateOnboardingThemeDraft.php
config/
  services.yaml

Почнете од Symfony апликација што работи со PHP 8.3 или понова верзија и има конфигуриран PostgreSQL, а потоа инсталирајте ги HTTP-клиентот од прва страна, CSRF-заштитата, Doctrine-интеграцијата, миграциите и алатките за тестирање:

composer require symfony/http-client symfony/security-csrf \
  doctrine/doctrine-bundle doctrine/doctrine-migrations-bundle
composer require --dev symfony/test-pack

Поврзете го токенот поддржан од околината и фиксниот endpoint преку dependency injection:

# config/services.yaml
parameters:
  brand_kit.endpoint: 'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'

services:
  App\Brand\BrandKitClient:
    arguments:
      $token: '%env(string:BRAND_KIT_TOKEN)%'
      $endpoint: '%brand_kit.endpoint%'

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

API-то обезбедува податоци за брендот засновани на докази, но рендерерот никогаш не треба да спојува вратен CSS во stylesheet. Следниот мапер ги бара сите седум категории, ги ограничува големините на колекциите, ги валидира URL-адресите и боите и ги складира CSS-променливите само како прегледани докази. Вистинската тема се гради повторно од безбедни примитиви.

<?php
// src/Brand/BrandKitDraft.php

namespace App\Brand;

final readonly class BrandKitDraft 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 static function fromApi(array $data): self
    {
        $brandName = $data['brand_name'] ?? null;

        if (!is_string($brandName) || trim($brandName) === '' || strlen($brandName) > 200) {
            throw new \DomainException('Invalid brand name.');
        }

        return new self(
            trim($brandName),
            self::stringList($data, 'logos', self::validUrl(...)),
            self::stringList($data, 'colors', self::validColor(...)),
            self::stringList($data, 'fonts', self::validFont(...)),
            self::stringList($data, 'imagery', self::validUrl(...)),
            self::stringList($data, 'social_profiles', self::validUrl(...)),
            self::cssMap($data),
        );
    }

    public function theme(): array
    {
        return [
            'primary_color' => $this->colors[0] ?? '#1f2937',
            'secondary_color' => $this->colors[1] ?? '#f3f4f6',
            'font_family' => $this->fonts[0] ?? 'system-ui',
            'logo_candidate' => $this->logos[0] ?? null,
            'review_required' => true,
        ];
    }

    public function jsonSerialize(): 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,
        ];
    }

    private static function stringList(
        array $data,
        string $key,
        callable $validator,
    ): array {
        $values = $data[$key] ?? null;

        if (!is_array($values) || !array_is_list($values) || count($values) > 50) {
            throw new \DomainException(sprintf('Invalid %s collection.', $key));
        }

        foreach ($values as $value) {
            if (!is_string($value) || strlen($value) > 2048 || !$validator($value)) {
                throw new \DomainException(sprintf('Invalid value in %s.', $key));
            }
        }

        return array_values(array_unique($values));
    }

    private static function cssMap(array $data): array
    {
        $variables = $data['css_variables'] ?? null;

        if (!is_array($variables) || count($variables) > 100) {
            throw new \DomainException('Invalid CSS variables.');
        }

        foreach ($variables as $name => $value) {
            if (
                !is_string($name)
                || preg_match('/^--[a-z0-9-]{1,80}$/', $name) !== 1
                || !is_string($value)
                || strlen($value) > 200
            ) {
                throw new \DomainException('Invalid CSS variable.');
            }
        }

        return $variables;
    }

    private static function validUrl(string $value): bool
    {
        if (filter_var($value, FILTER_VALIDATE_URL) === false) {
            return false;
        }

        return in_array(strtolower((string) parse_url($value, PHP_URL_SCHEME)), ['http', 'https'], true);
    }

    private static function validColor(string $value): bool
    {
        return preg_match('/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3}|[0-9a-fA-F]{5})?$/', $value) === 1;
    }

    private static function validFont(string $value): bool
    {
        return preg_match("/^[\p{L}\p{N} .,'-]{1,80}$/u", $value) === 1;
    }
}

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

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

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

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

final class BrandKitException extends \RuntimeException
{
    public function __construct(public readonly string $kind)
    {
        parent::__construct($kind);
    }
}
<?php
// src/Brand/BrandKitClient.php

namespace App\Brand;

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

final readonly class BrandKitClient
{
    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $token,
        private string $endpoint,
    ) {}

    public function extract(string $url): BrandKitDraft
    {
        if (trim($this->token) === '') {
            throw new BrandKitException('configuration');
        }

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

                $status = $response->getStatusCode();

                $this->logger->info('brand_kit.response', [
                    'attempt' => $attempt,
                    'status' => $status,
                    'host_hash' => hash('sha256', (string) parse_url($url, PHP_URL_HOST)),
                ]);

                if ($status >= 200 && $status < 300) {
                    try {
                        $data = json_decode(
                            $response->getContent(false),
                            true,
                            512,
                            JSON_THROW_ON_ERROR,
                        );
                    } catch (\JsonException) {
                        throw new BrandKitException('invalid_response');
                    }

                    if (!is_array($data)) {
                        throw new BrandKitException('invalid_response');
                    }

                    return BrandKitDraft::fromApi($data);
                }

                if ($status === 401 || $status === 403) {
                    throw new BrandKitException('authentication');
                }

                if ($status === 400 || $status === 422) {
                    throw new BrandKitException('request_rejected');
                }

                if ($status === 429) {
                    if ($attempt === 3) {
                        throw new BrandKitException('rate_limited');
                    }

                    $retryAfter = $response->getHeaders(false)['retry-after'][0] ?? null;
                    $seconds = ctype_digit((string) $retryAfter)
                        ? min(5, (int) $retryAfter)
                        : $attempt;

                    usleep($seconds * 1_000_000);
                    continue;
                }

                if ($status >= 500 && $attempt < 3) {
                    usleep($attempt * 300_000);
                    continue;
                }

                throw new BrandKitException('upstream_failure');
            } catch (TransportExceptionInterface) {
                if ($attempt === 3) {
                    throw new BrandKitException('transport');
                }

                usleep($attempt * 300_000);
            }
        }

        throw new BrandKitException('upstream_failure');
    }
}

Логерот не бележи токен, тело на одговор или целосна URL-адреса на клиентот. Структурираните вредности kind го разликуваат дејството на операторот: ротирајте ги ингеренциите при неуспеси на автентикацијата, проверете ја компатибилноста на payload-от при неважечки одговори и испитајте ги квотата или капацитетот при трајни ограничувања на стапката.

Валидирајте го влезот за воведување и чувајте само нацрти

Креирајте PostgreSQL миграција што ја содржи оваа табела, па за време на распоредувањето извршете php bin/console doctrine:migrations:migrate --no-interaction:

CREATE TABLE onboarding_theme_draft (
    id CHAR(32) PRIMARY KEY,
    owner_identifier VARCHAR(180) NOT NULL,
    source_host VARCHAR(253) NOT NULL,
    brand_name VARCHAR(200) NOT NULL,
    brand_kit JSONB NOT NULL,
    theme JSONB NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_theme_draft_owner
    ON onboarding_theme_draft (owner_identifier, created_at);

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

<?php
// src/Controller/OnboardingThemeController.php

namespace App\Controller;

use App\Brand\BrandKitClient;
use App\Brand\BrandKitException;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;

final class OnboardingThemeController extends AbstractController
{
    #[Route('/onboarding/theme-draft', name: 'onboarding_theme_draft', methods: ['POST'])]
    #[IsGranted('ROLE_USER')]
    public function __invoke(
        Request $request,
        CsrfTokenManagerInterface $csrf,
        BrandKitClient $client,
        Connection $database,
    ): JsonResponse {
        $token = new CsrfToken(
            'onboarding_theme',
            (string) $request->headers->get('X-CSRF-Token'),
        );

        if (!$csrf->isTokenValid($token)) {
            return $this->json(['error' => 'invalid_csrf_token'], 403);
        }

        try {
            $input = $request->toArray();
        } catch (\JsonException) {
            return $this->json(['error' => 'invalid_json'], 400);
        }

        $url = $input['url'] ?? null;
        $host = is_string($url) ? parse_url($url, PHP_URL_HOST) : null;

        if (
            !is_string($url)
            || filter_var($url, FILTER_VALIDATE_URL) === false
            || strtolower((string) parse_url($url, PHP_URL_SCHEME)) !== 'https'
            || !is_string($host)
            || !str_contains($host, '.')
            || filter_var($host, FILTER_VALIDATE_IP) !== false
            || strtolower($host) === 'localhost'
        ) {
            return $this->json(['error' => 'invalid_public_url'], 422);
        }

        try {
            $draft = $client->extract($url);
        } catch (BrandKitException|\DomainException $exception) {
            $kind = $exception instanceof BrandKitException
                ? $exception->kind
                : 'invalid_response';

            return $this->json(
                ['error' => 'theme_draft_unavailable', 'reason' => $kind],
                503,
                $kind === 'rate_limited' ? ['Retry-After' => '10'] : [],
            );
        }

        $id = bin2hex(random_bytes(16));
        $user = $this->getUser();

        $database->insert('onboarding_theme_draft', [
            'id' => $id,
            'owner_identifier' => $user->getUserIdentifier(),
            'source_host' => strtolower($host),
            'brand_name' => $draft->brandName,
            'brand_kit' => json_encode($draft, JSON_THROW_ON_ERROR),
            'theme' => json_encode($draft->theme(), JSON_THROW_ON_ERROR),
            'status' => 'review_required',
            'created_at' => (new \DateTimeImmutable())->format(DATE_ATOM),
        ]);

        return $this->json([
            'id' => $id,
            'status' => 'review_required',
            'theme' => $draft->theme(),
        ], 201);
    }
}

Генерирајте ја вредноста на заглавието на барањето од прелистувачот со Twig-овото csrf_token('onboarding_theme'). Ако вашиот кориснички идентификатор е адреса на е-пошта, заменете го со непроменлив кориснички ID и странски клуч пред пуштањето во употреба.

Тестирајте ги патеките за успех и неуспех што не се повторува

MockHttpClient ги одржува тестовите детерминистички и докажува дека на апликацијата никогаш не ѝ е потребна живата услуга за време на тест-пакетот.

<?php
// tests/Brand/BrandKitClientTest.php

namespace App\Tests\Brand;

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

final class BrandKitClientTest extends TestCase
{
    public function testMapsAValidResponseIntoAReviewDraft(): void
    {
        $http = new MockHttpClient(function (string $method, string $url): MockResponse {
            self::assertSame('POST', $method);
            self::assertSame(
                'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
                $url,
            );

            return new MockResponse(json_encode([
                'brand_name' => 'Example Studio',
                'logos' => ['https://www.example.com/logo.svg'],
                'colors' => ['#123456', '#f4f4f4'],
                'fonts' => ['Inter'],
                'imagery' => ['https://www.example.com/hero.jpg'],
                'social_profiles' => ['https://www.example.com/social'],
                'css_variables' => ['--brand-primary' => '#123456'],
            ], JSON_THROW_ON_ERROR));
        });

        $client = new BrandKitClient(
            $http,
            new NullLogger(),
            'test-token',
            'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
        );

        $draft = $client->extract('https://www.example.com');

        self::assertSame('Example Studio', $draft->brandName);
        self::assertTrue($draft->theme()['review_required']);
    }

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

        $client = new BrandKitClient(
            $http,
            new NullLogger(),
            'expired-token',
            'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
        );

        try {
            $client->extract('https://www.example.com');
            self::fail('Expected authentication failure.');
        } catch (BrandKitException $exception) {
            self::assertSame('authentication', $exception->kind);
        }

        self::assertSame(1, $calls);
    }
}

Извршете php bin/phpunit. Додадете тестови за маперот за небезбедна шема на лого, неправилно форматирана боја, преголема колекција, неважечко име на CSS-променлива и недостасувачка категорија на одговор. Додадете функционален тест на контролерот за да ги проверите автентикацијата, спроведувањето на CSRF, сопственоста и дека не се појавува ред во базата на податоци по неуспех од надворешната услуга.

Продукциски прашања што заслужуваат експлицитни одлуки

  • Рендерирање: доделувајте валидирани вредности преку контролирани својства на шаблонот или CSS Object Model. Никогаш не рендерирајте текст од вратени CSS-променливи како суров stylesheet.
  • Пристапност: извлечените бои се визуелни докази, а не доказ за читлив контраст. Извршете сопствени проверки на контрастот и обезбедете неутрална резервна опција.
  • Приватност на средствата: избегнувајте автоматско проксирање или преземање логоа и слики. Прикажете кандидати за време на прегледот и дефинирајте политика за задржување на отфрлените нацрти.
  • Квоти: ограничете го дејството за воведување, спречете паралелни поднесувања по корисник и кеширајте или повторно искористете неодамнешен нацрт за истиот нормализиран хост кога е соодветно.
  • Набљудливост: поставете предупредувања за трајни неуспеси на автентикацијата, ограничувањето на стапката, транспортот и неважечките одговори. Следете ги латентноста и бројот на обиди без да бележите ингеренции или непотребни податоци за клиентот.
  • Распоредување: внесете BRAND_KIT_TOKEN и во веб и во worker околините ако Messenger подоцна се воведе. Загрејте го Symfony кешот и извршете ги миграциите пред да испратите сообраќај до новата рута.

Вообичаени неуспеси и што значат

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

Неуспехот invalid_response е особено вреден. Тој спречува промена на обликот од надворешната услуга тивко да влезе во складиштето или да стигне до stylesheet. Споредете го одговорот со официјалната документација, намерно ажурирајте го маперот и додадете fixture што го покрива ревидираниот договор.

Конечна контролна листа за верификација

  • Тест-барањето стигнува до точниот POST endpoint со JSON url.
  • Вистинскиот токен постои само во тајна конфигурација поддржана од околината.
  • Проверките за автентикација, CSRF, URL, одговор и сопственост се активни.
  • Името на брендот, логоата, боите, фонтовите, сликите, социјалните профили и CSS-променливите се валидираат пред складирањето.
  • Неуспесите на автентикацијата и валидацијата на барањето не се повторуваат.
  • Истекувањата на време, бројот на повторни обиди, повлекувањето и чекањата поради ограничување на стапката се ограничени.
  • Дневниците ги исклучуваат токените, телата на одговорите и целосните URL-адреси на клиентите.
  • Добиениот запис има статус review_required и не може сам да се објави.
  • Човек може да ја одобри, уреди или отфрли предложената тема.

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

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

Mihajlo

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