Vodiči

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

Symfony Onboarding: Automatska izrada nacrta tema odredišne stranice pomoću API-ja Brand Kit Extractor

Prazno početno platno za onboarding stvara nezgodan izbor: tražiti od korisnika da ručno konfigurira boje, fontove i slike ili nagađati kako bi njihova odredišna stranica trebala izgledati. Bolja početna točka je javna web-stranica koju već održavaju.

U ovom vodiču izgradit ćemo Symfony endpoint za onboarding koji šalje korisnikovu web-stranicu API-ju Brand Kit Extractor, validira dobivene dokaze o brendu, izvodi namjerno konzervativnu temu i pohranjuje je kao skicu. Ništa se ne objavljuje automatski. Korisnik i dalje pregledava i odobrava rezultat.

Ta je razlika važna. Izvučeni podaci o brendu koristan su ulaz, ali i dalje su nepouzdani vanjski podaci. URL-ovi mogu biti nesigurni, CSS vrijednosti mogu postati vektori za ubacivanje zlonamjernog sadržaja, uzvodni odgovori mogu se promijeniti, a vizualno ispravna boja i dalje može ne ispunjavati zahtjeve pristupačnosti. Naša će integracija sačuvati dokaze, a rendereru odredišne stranice izložiti samo usku, validiranu temu.

Pristupite usluzi i kopirajte servisni token

Prije pisanja integracijskog koda izradite račun ili mu pristupite:

  1. Registrirajte se na https://ai.mihajlo.mk/register ili se prijavite na https://ai.mihajlo.mk/login.
  2. Otvorite stranicu usluge Brand Kit Extractor.
  3. Odaberite dostupni Free, Plus ili Pro plan i dovršite aktivaciju.
  4. Otvorite službenu dokumentaciju usluge.
  5. Pronađite ploču Service token i kopirajte token ograničen na uslugu.

Ponovno generiranje ovog tokena opoziva prethodni aktivni token. Rotaciju tretirajte kao promjenu pri implementaciji: ažurirajte tajnu aplikacije, implementirajte ili ponovno pokrenite pogođene procese, provjerite jedan zahtjev i tek tada smatrajte rotaciju dovršenom.

API prihvaća Bearer token, zaglavlje X-API-Token ili parametar tokena u upitu. Upotrijebit ćemo Bearer token jer ostaje izvan URL-ova i uobičajenih dnevnika pristupa. Ova usluga nije bez tokena: svaki zahtjev treba jedan od podržanih oblika autentikacije.

Potvrdite točan API ugovor

Integracija šalje ovaj zahtjev:

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

JSON tijelo sadržava jedno polje, url. Testirajte vjerodajnicu prije uključivanja Symfonyja:

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

Ne lijepite stvarni token u povijest ljuske na zajedničkom računalu. Za lokalni razvoj stavite ga u Symfonyjevu ignoriranu datoteku .env.local. U produkciji ubrizgajte istu varijablu okruženja putem hosting platforme ili upravitelja tajni.

# .env.local
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

Odaberite malu arhitekturu usmjerenu na pregled

Značajka ima četiri granice: autentificirani kontroler za onboarding, HTTP klijent, mapiranje domene i tablicu baze podataka. Mapper prihvaća vraćeni naziv brenda, logotipe, boje, fontove, slike, društvene profile i CSS varijable tek nakon validacije njihovih tipova i vrijednosti.

Ovaj primjer izvlačenje izvršava sinkrono. To skroman tijek onboardinga čini jednostavnim za upravljanje i omogućuje korisniku da odmah primi skicu. Ako izmjerena vremena odgovora premašuju proračun vašeg web zahtjeva, premjestite isti poziv klijenta iza Symfony Messengera i neka kontroler vrati identifikator prihvaćenog posla. Nemojte dodavati red čekanja samo da prikrijete nedostajuća vremenska ograničenja.

Relevantne datoteke su:

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

Krenite od Symfony aplikacije koja koristi PHP 8.3 ili noviji s konfiguriranim PostgreSQL-om, zatim instalirajte službeni HTTP klijent, CSRF zaštitu, Doctrine integraciju, migracije i alate za testiranje:

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

Putem injekcije ovisnosti povežite token iz varijable okruženja i fiksni endpoint:

# 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%'

Mapirajte odgovor u sigurnu skicu domene

API pruža podatke o brendu utemeljene na dokazima, ali renderer nikada ne bi smio spajati vraćeni CSS u tablicu stilova. Sljedeći mapper zahtijeva svih sedam kategorija, ograničava veličine kolekcija, validira URL-ove i boje te pohranjuje CSS varijable samo kao pregledane dokaze. Stvarna se tema ponovno izgrađuje iz sigurnih primitiva.

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

Ovaj namjerno strogi mapper odražava prihvaćenu granicu aplikacije. Ako službena dokumentacija definira ugniježđene objekte umjesto kolekcija nizova, eksplicitno mapirajte te dokumentirane objekte; nemojte ublažavati validaciju kako biste prihvatili proizvoljna stabla odgovora.

Izgradite ograničen HTTP klijent svjestan statusa

Klijent koristi vremensko ograničenje neaktivnosti, ukupno ograničenje trajanja i najviše tri pokušaja. Neuspjesi autentikacije i validacije nikada se ne ponavljaju. Greške transporta, ograničenja stope i greške poslužitelja dobivaju ograničeno odgađanje.

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

Logger ne bilježi token, tijelo odgovora ni puni URL korisnika. Strukturirane vrijednosti kind razlikuju radnje operatera: rotirajte vjerodajnice za neuspjehe autentikacije, pregledajte kompatibilnost sadržaja za nevažeće odgovore i provjerite kvotu ili kapacitet za trajna ograničenja stope.

Validirajte unos za onboarding i pohranjujte samo skice

Izradite PostgreSQL migraciju koja sadržava ovu tablicu, zatim tijekom implementacije pokrenite 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);

Kontroler zahtijeva autentificiranog korisnika i valjani CSRF token. Prihvaća samo HTTPS javne nazive hostova. To smanjuje slučajnu zloupotrebu i zloupotrebu kvote, iako udaljena usluga za izvlačenje i dalje mora provoditi vlastite DNS i zaštite izlazne mreže.

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

Generirajte vrijednost zaglavlja zahtjeva preglednika pomoću Twigova csrf_token('onboarding_theme'). Ako je identifikator korisnika adresa e-pošte, prije pokretanja ga zamijenite nepromjenjivim korisničkim ID-om i stranim ključem.

Testirajte uspješne putanje i putanje neuspjeha koje se ne ponavljaju

MockHttpClient održava testove determinističkima i dokazuje da aplikacija tijekom testnog paketa nikada ne treba aktivnu uslugu.

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

Pokrenite php bin/phpunit. Dodajte testove mappera za nesigurnu shemu logotipa, neispravnu boju, preveliku kolekciju, nevažeći naziv CSS varijable i kategoriju odgovora koja nedostaje. Dodajte funkcionalni test kontrolera kako biste provjerili autentikaciju, provođenje CSRF-a, vlasništvo i da se nijedan red baze podataka ne pojavljuje nakon uzvodnog neuspjeha.

Produkcijska pitanja koja zaslužuju eksplicitne odluke

  • Renderiranje: dodjeljujte validirane vrijednosti putem kontroliranih svojstava predloška ili CSS Object Modela. Nikada ne renderirajte vraćeni tekst CSS varijable kao sirovu tablicu stilova.
  • Pristupačnost: izvučene boje vizualni su dokaz, a ne dokaz čitljivog kontrasta. Izvršite vlastite provjere kontrasta i osigurajte neutralnu zamjensku vrijednost.
  • Privatnost resursa: izbjegavajte automatsko prosljeđivanje ili preuzimanje logotipa i slika. Prikažite kandidate tijekom pregleda i definirajte politiku zadržavanja za odbačene skice.
  • Kvote: ograničite učestalost radnje onboardinga, spriječite paralelne prijave po korisniku i, kada je prikladno, predmemorirajte ili ponovno upotrijebite nedavnu skicu za isti normalizirani host.
  • Praćenje: upozoravajte na kontinuirane neuspjehe autentikacije, ograničenja stope, transportne greške i nevažeće odgovore. Pratite latenciju i broj pokušaja bez bilježenja vjerodajnica ili nepotrebnih podataka korisnika.
  • Implementacija: ubrizgajte BRAND_KIT_TOKEN u web i radna okruženja ako se Messenger naknadno uvede. Zagrijte Symfony predmemoriju i pokrenite migracije prije usmjeravanja prometa na novu rutu.

Uobičajeni neuspjesi i njihovo značenje

401 ili 403 obično upućuje na nedostajući, opozvani ili pogrešno kopirani servisni token. Nemojte ga ponavljati. 400 ili 422 znači da zahtjev treba ispraviti, a ne ponoviti. 429 zahtijeva ograničeno čekanje i pregled plana ili kvote. Ponavljani 5xx ili transportni neuspjesi trebaju ostaviti onboarding oporavljivim: zadržite URL koji je korisnik unio u pregledniku i ponudite kasniji ponovni pokušaj.

Neuspjeh invalid_response posebno je vrijedan. Sprječava da promjena uzvodnog oblika tiho uđe u pohranu ili dospije do tablice stilova. Usporedite odgovor sa službenom dokumentacijom, namjerno ažurirajte mapper i dodajte fixture koji pokriva revidirani ugovor.

Kontrolni popis za konačnu provjeru

  • Testni zahtjev doseže točan POST endpoint s JSON poljem url.
  • Stvarni token postoji samo u konfiguraciji tajne podržanoj varijablama okruženja.
  • Provjere autentikacije, CSRF-a, URL-a, odgovora i vlasništva aktivne su.
  • Naziv brenda, logotipi, boje, fontovi, slike, društveni profili i CSS varijable validiraju se prije pohrane.
  • Neuspjesi autentikacije i validacije zahtjeva ne ponavljaju se.
  • Vremenska ograničenja, broj ponovnih pokušaja, odgađanje i čekanja zbog ograničenja stope ograničeni su.
  • Dnevnici isključuju tokene, tijela odgovora i pune URL-ove korisnika.
  • Dobiveni zapis ima status review_required i ne može se sam objaviti.
  • Čovjek može odobriti, urediti ili odbiti predloženu temu.

Najbolja automatizacija onboardinga ne pretvara se da je izvlačenje prosudba. Ona postojeću web-stranicu pretvara u korisnu prvu skicu, okružuje nesigurne dokaze strogim granicama i konačnu odluku ostavlja korisniku. Ta kombinacija čini iskustvo brzim, a sustav ne čini nepromišljenim.

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.