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:
- Registrirajte se na https://ai.mihajlo.mk/register ili se prijavite na https://ai.mihajlo.mk/login.
- Otvorite stranicu usluge Brand Kit Extractor.
- Odaberite dostupni Free, Plus ili Pro plan i dovršite aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- 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_TOKENu 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_requiredi 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.