Automatizacija Symfony Brand Kita: unaprijed popunite radne prostore klijenata
Prazan radni prostor klijenta stvara neugodan prvi dojam. Kupac je već unio adresu web-mjesta, no zaslon za postavljanje i dalje od njega traži da prenese logotip, pronađe vrijednosti boja i odredi fontove čije nazive možda ne zna.
Bolji tijek uvođenja koristi web-mjesto kao polazište. U ovom vodiču aplikacija Symfony šalje javni URL API-ju Brand Kit Extractor, provjerava vraćeni naziv robne marke, logotipe, boje, fontove, slike, društvene profile i CSS varijable, a zatim rezultat pohranjuje u radni prostor. Ekstrakcija se izvršava putem Messengera, pa uvođenje ostaje responzivno čak i kada je udaljeno web-mjesto sporo.
Dizajn je namjerno skroman: jedan kontroler, jedna poruka u redu čekanja, stroga API granica i JSON stupci za podatke temeljene na dokazima koji se mogu razvijati neovisno o vašoj aplikaciji.
Dobijte pristup prije pisanja integracijskog koda
Najprije registrirajte račun ili se prijavite. Otvorite stranicu usluge Brand Kit Extractor, odaberite dostupni paket Free, Plus ili Pro i dovršite njegovu aktivaciju.
Zatim otvorite službenu dokumentaciju usluge. Pronađite ploču Service token i kopirajte tamo prikazani token ograničen na uslugu. Ova usluga zahtijeva autentifikaciju; nije API bez tokena.
Ponovno generiranje tokena opoziva prethodno aktivni token. Rotaciju tretirajte kao promjenu pri implementaciji: ažurirajte svako pokrenuto okruženje prije oslanjanja na novu vjerodajnicu i nikada nemojte nijedan token stavljati u kontrolu izvornog koda, fixturee, zapisnike ili snimke zaslona.
Točna operacija koju koristi ovaj projekt jest:
POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit
API prihvaća Bearer autentifikaciju, zaglavlje X-API-Token ili parametar tokena u upitu. Ova implementacija koristi Bearer token jer je manja vjerojatnost da će se zaglavlja, za razliku od nizova upita, pojaviti u zapisnicima proxyja i pristupa.
Prije izrade značajke provjerite račun i token minimalnim zahtjevom:
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"}'
Koristite javno web-mjesto koje imate pravo obraditi. Uspješan odgovor trebao bi sadržavati naziv robne marke, logotipe, boje, fontove, slike, društvene profile i CSS varijable. Nemojte pretpostaviti da sam uspjeh čini svaku vrijednost prikladnom za pohranu; granica aplikacije provjerit će oblik odgovora.
Stvarnu vjerodajnicu za lokalni razvoj stavite u .env.local, a ne u verzioniranu datoteku .env:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=brand_kit
U produkciji iste varijable umetnite putem upravitelja tajnama platforme za implementaciju.
Izradite Symfony projekt
Aplikacija zahtijeva PHP 8.3 ili noviji, Composer, Symfony aplikaciju i bazu podataka koju podržava Doctrine. Počevši od praznog direktorija, instalirajte samo komponente koje služe toj značajci:
composer create-project symfony/skeleton brand-workspaces
cd brand-workspaces
composer require symfony/framework-bundle symfony/http-client symfony/messenger \
symfony/doctrine-messenger doctrine/doctrine-bundle doctrine/orm \
doctrine/doctrine-migrations-bundle symfony/validator
composer require --dev symfony/test-pack
Rezultirajuća značajka ima četiri sloja:
- Kontroler stvara radni prostor i šalje posao ekstrakcije.
- Messenger premješta udaljeni I/O iz putanje zahtjeva.
- Namjenski klijent upravlja autentifikacijom, vremenskim ograničenjima, ponovnim pokušajima i HTTP neuspjesima.
- Mapper odbacuje nepotpune ili nepravilno oblikovane odgovore prije nego što ih Doctrine primi.
JSON stupci ovdje su koristan kompromis. Logotipi, boje i fontovi mogu sadržavati bogatije dokaze od jednog URL-a ili hex vrijednosti, a njihovo prerano izravnavanje odbacilo bi informacije. Ako aplikacija kasnije bude trebala upite poput „pronađi svaki radni prostor koji koristi ovaj font”, taj konkretni koncept izdvojite u normalizirane tablice.
Definirajte strogu domensku granicu
Izradite src/Brand/BrandKit.php. Mapper namjerno provjerava samo dokumentirani ugovor na najvišoj razini. Zadržava ugniježđene vrijednosti umjesto da izmišlja nedokumentiranu shemu logotipa, boje ili fonta.
<?php
namespace App\Brand;
final readonly class BrandKit
{
public function __construct(
public string $brandName,
public array $logos,
public array $colors,
public array $fonts,
public array $imagery,
public array $socialProfiles,
public array $cssVariables,
) {}
}
final class InvalidBrandKit extends \RuntimeException {}
final class BrandKitMapper
{
public function map(array $data): BrandKit
{
if (!isset($data['brand_name'])
|| !is_string($data['brand_name'])
|| trim($data['brand_name']) === '') {
throw new InvalidBrandKit('Response has no valid brand_name.');
}
$arrays = [
'logos',
'colors',
'fonts',
'imagery',
'social_profiles',
'css_variables',
];
foreach ($arrays as $field) {
if (!array_key_exists($field, $data) || !is_array($data[$field])) {
throw new InvalidBrandKit(
sprintf('Response field "%s" must be an array.', $field)
);
}
}
return new BrandKit(
trim($data['brand_name']),
$data['logos'],
$data['colors'],
$data['fonts'],
$data['imagery'],
$data['social_profiles'],
$data['css_variables'],
);
}
}
Ovo je namjerna granica koja je zatvorena pri neuspjehu. Prazno polje valjano je jer javno web-mjesto možda ne izlaže pouzdane dokaze o fontu ili društvenom profilu. Polje koje nedostaje ili je pogrešne vrste nije valjano jer bi pohranjivanje djelomičnog odgovora učinilo da problem prijenosa izgleda kao legitimni podaci o robnoj marki.
Izgradite otporni HTTP klijent
Izradite src/Brand/BrandKitClient.php. Šalje točno jedan JSON parametar, url, i ograničava i vrijeme mirovanja mreže i ukupno trajanje zahtjeva. Ponovno pokušava kod neuspjeha prijenosa, HTTP 429 i pogrešaka poslužitelja, ali nikada ne ponavlja autentifikaciju, autorizaciju ili druge neuspjehe provjere valjanosti na strani klijenta.
<?php
namespace App\Brand;
use JsonException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class BrandKitRequestFailed extends \RuntimeException {}
final class BrandKitClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit';
public function __construct(
private HttpClientInterface $http,
private string $token,
private BrandKitMapper $mapper,
private LoggerInterface $logger,
) {}
public function extract(string $url): BrandKit
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('POST', self::ENDPOINT, [
'headers' => [
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
],
'json' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 20.0,
]);
$status = $response->getStatusCode();
if ($status === 429 || $status >= 500) {
if ($attempt === 3) {
throw new BrandKitRequestFailed(
sprintf('Extractor remained unavailable (HTTP %d).', $status)
);
}
$headers = $response->getHeaders(false);
$retryAfter = (int) ($headers['retry-after'][0] ?? 0);
$delayMs = $retryAfter > 0
? min($retryAfter * 1000, 2000)
: 250 * (2 ** ($attempt - 1));
$this->logger->warning('Brand extraction will be retried.', [
'attempt' => $attempt,
'status' => $status,
'delay_ms' => $delayMs,
]);
usleep($delayMs * 1000);
continue;
}
if ($status < 200 || $status >= 300) {
throw new BrandKitRequestFailed(
sprintf('Extractor rejected the request (HTTP %d).', $status)
);
}
try {
$data = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR
);
} catch (JsonException $e) {
throw new BrandKitRequestFailed(
'Extractor returned invalid JSON.',
previous: $e
);
}
if (!is_array($data)) {
throw new BrandKitRequestFailed(
'Extractor returned a non-object JSON value.'
);
}
return $this->mapper->map($data);
} catch (TransportExceptionInterface $e) {
if ($attempt === 3) {
throw new BrandKitRequestFailed(
'Extractor could not be reached.',
previous: $e
);
}
$delayMs = 250 * (2 ** ($attempt - 1));
$this->logger->warning('Brand extraction transport failure.', [
'attempt' => $attempt,
'delay_ms' => $delayMs,
]);
usleep($delayMs * 1000);
}
}
throw new BrandKitRequestFailed('Brand extraction failed.');
}
}
Klijent ne zapisuje token, tijelo odgovora ni potpuni URL. URL-ovi mogu sadržavati identifikatore kupaca ili nizove upita, dok podaci odgovora mogu otkriti poslovne račune i dizajnerske resurse. Zapisnici bi trebali bilježiti operativne činjenice, a ne duplicirati sadržaj.
Proslijedite skalarnu vjerodajnicu putem config/services.yaml:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Brand\BrandKitClient:
arguments:
$token: '%env(string:BRAND_KIT_TOKEN)%'
Spremite potpuni rezultat
Entitet radnog prostora trebao bi zadržati svoj izvorni URL web-mjesta i eksplicitno stanje ekstrakcije. Dodajte Doctrine JSON svojstva za svako provjereno polje odgovora, a ne samo za tri koja se trenutačno prikazuju pri uvođenju:
<?php
namespace App\Entity;
use App\Brand\BrandKit;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Workspace
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 160)]
private string $name;
#[ORM\Column(length: 2048)]
private string $websiteUrl;
#[ORM\Column(length: 20)]
private string $brandStatus = 'pending';
#[ORM\Column(length: 255, nullable: true)]
private ?string $brandName = null;
#[ORM\Column(type: 'json')] private array $logos = [];
#[ORM\Column(type: 'json')] private array $colors = [];
#[ORM\Column(type: 'json')] private array $fonts = [];
#[ORM\Column(type: 'json')] private array $imagery = [];
#[ORM\Column(type: 'json')] private array $socialProfiles = [];
#[ORM\Column(type: 'json')] private array $cssVariables = [];
#[ORM\Column(length: 500, nullable: true)]
private ?string $brandFailure = null;
public function __construct(string $name, string $websiteUrl)
{
$this->name = $name;
$this->websiteUrl = $websiteUrl;
}
public function getId(): ?int { return $this->id; }
public function getWebsiteUrl(): string { return $this->websiteUrl; }
public function applyBrandKit(BrandKit $kit): void
{
$this->brandName = $kit->brandName;
$this->logos = $kit->logos;
$this->colors = $kit->colors;
$this->fonts = $kit->fonts;
$this->imagery = $kit->imagery;
$this->socialProfiles = $kit->socialProfiles;
$this->cssVariables = $kit->cssVariables;
$this->brandStatus = 'ready';
$this->brandFailure = null;
}
public function failBrandExtraction(string $reason): void
{
$this->brandStatus = 'failed';
$this->brandFailure = mb_substr($reason, 0, 500);
}
}
Generirajte i pregledajte migraciju prije njezine primjene:
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
Stavite ekstrakciju u red nakon stvaranja radnog prostora
Izradite malu poruku i obrađivač u src/Message/ExtractWorkspaceBrand.php i src/MessageHandler/ExtractWorkspaceBrandHandler.php:
<?php
// src/Message/ExtractWorkspaceBrand.php
namespace App\Message;
final readonly class ExtractWorkspaceBrand
{
public function __construct(public int $workspaceId) {}
}
// src/MessageHandler/ExtractWorkspaceBrandHandler.php
namespace App\MessageHandler;
use App\Brand\BrandKitClient;
use App\Entity\Workspace;
use App\Message\ExtractWorkspaceBrand;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class ExtractWorkspaceBrandHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
private BrandKitClient $client,
private LoggerInterface $logger,
) {}
public function __invoke(ExtractWorkspaceBrand $message): void
{
$workspace = $this->entityManager->find(
Workspace::class,
$message->workspaceId
);
if (!$workspace) {
$this->logger->notice('Brand extraction skipped; workspace is absent.', [
'workspace_id' => $message->workspaceId,
]);
return;
}
try {
$workspace->applyBrandKit(
$this->client->extract($workspace->getWebsiteUrl())
);
$this->logger->info('Workspace brand kit populated.', [
'workspace_id' => $message->workspaceId,
]);
} catch (\Throwable $e) {
$workspace->failBrandExtraction($e->getMessage());
$this->logger->error('Workspace brand extraction failed.', [
'workspace_id' => $message->workspaceId,
'exception_class' => $e::class,
]);
}
$this->entityManager->flush();
}
}
Konfigurirajte asinkronu isporuku u config/packages/messenger.yaml:
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
App\Message\ExtractWorkspaceBrand: async
Vaš kontroler za stvaranje radnog prostora s autentifikacijom trebao bi provjeriti da je url HTTP ili HTTPS URL s nazivom hosta, spremiti radni prostor, izvršiti flush kako bi dobio njegov ID i tek onda poslati poruku:
$workspace = new Workspace($name, $url);
$entityManager->persist($workspace);
$entityManager->flush();
$messageBus->dispatch(
new ExtractWorkspaceBrand($workspace->getId())
);
Zahtijevajte autorizaciju na ovoj ruti, odbijajte izravne privatne ili loopback adrese i prihvaćajte samo web-mjesta koja kupac smije obrađivati. Udaljena usluga dohvaća javno web-mjesto, ali vaša aplikacija i dalje kontrolira tko može staviti posao u red i koliko posla jedan račun može stvoriti.
Testirajte granicu bez mrežnih poziva
MockHttpClient čini test determinističkim i provjerava izlazni ugovor:
<?php
namespace App\Tests\Brand;
use App\Brand\BrandKitClient;
use App\Brand\BrandKitMapper;
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 testItMapsACompleteBrandKit(): void
{
$response = new MockResponse(json_encode([
'brand_name' => 'Example',
'logos' => [['url' => 'https://example.com/logo.svg']],
'colors' => [['value' => '#112233']],
'fonts' => [['family' => 'Example Sans']],
'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--brand-color' => '#112233'],
], JSON_THROW_ON_ERROR), ['http_code' => 200]);
$http = new MockHttpClient(
function (string $method, string $url, array $options) use ($response) {
self::assertSame('POST', $method);
self::assertSame(
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
$url
);
self::assertStringContainsString(
'Authorization: Bearer test-token',
implode("\n", $options['headers'])
);
self::assertSame(
['url' => 'https://example.com'],
$options['json']
);
return $response;
}
);
$client = new BrandKitClient(
$http,
'test-token',
new BrandKitMapper(),
new NullLogger()
);
$kit = $client->extract('https://example.com');
self::assertSame('Example', $kit->brandName);
self::assertCount(1, $kit->logos);
self::assertCount(1, $kit->colors);
self::assertCount(1, $kit->fonts);
}
}
Dodajte testove mappera za polje koje nedostaje, nevaljan brand_name i kolekcije koje nisu polja. Dodajte testove obrađivača koji pokrivaju nedostajući radni prostor, uspješno spremanje i strukturirano stanje failed kada klijent izbaci iznimku. Pokrenite ih naredbom:
php bin/phpunit
Pouzdanost u produkciji i česti neuspjesi
Pokrenite radnika pod systemd-om, Supervisorom, orkestratorom spremnika ili mogućnošću radnika svoje hosting platforme:
php bin/console messenger:consume async \
--time-limit=3600 \
--memory-limit=256M \
--no-interaction
Implementirajte migraciju baze podataka prije pokretanja radnika koji sadrže novi obrađivač. Ponovno pokrenite radnike nakon svakog izdanja kako bi učitali trenutačni kod i rotirane tajne.
Pratite broj i latenciju za radne prostore ready i failed, kategorije HTTP statusa, pokušaje ponavljanja i starost reda. Upozorenje za rastući red otkriva zaustavljenog radnika; upozorenje za neuspjehe autentifikacije otkriva istekao ili ponovno generiran token. Kontekst zapisnika ograničite na ID-jeve radnih prostora i metapodatke statusa.
Najčešći načini neuspjeha imaju različita rješenja:
- HTTP 401 ili 403: provjerite aktivaciju paketa i token usluge. Nemojte automatski ponovno pokušavati.
- HTTP 429: poštujte ograničeno kašnjenje
Retry-After, a zatim ostavite radni prostor neuspjelim ako ograničenje potraje. Kasnije ponudite izričitu radnju ponovnog pokušaja. - Greške provjere valjanosti HTTP 400 raspona: ponovno provjerite javni URL i tijelo zahtjeva; ponovljeni identični pozivi neće ih ispraviti.
- Vremenska ograničenja ili odgovori HTTP 500 raspona: dopustite ograničeni eksponencijalni odmak klijenta, uz zadržavanje web-zahtjeva neovisnim o rezultatu.
- Nepravilno oblikovani uspješni odgovori: odbijte ih prije pohrane i sačuvajte postojeće podatke radnog prostora.
Završni kontrolni popis za provjeru
- Stvorite radni prostor s autoriziranim javnim HTTP ili HTTPS web-mjestom.
- Potvrdite da se web-zahtjev vraća bez čekanja ekstrakcije.
- Pokrenite Messenger radnika i provjerite da je poruka obrađena.
- Potvrdite da radni prostor doseže
ready. - Provjerite jesu li vraćeni naziv robne marke, logotipi, boje, fontovi, slike, društveni profili i CSS varijable pohranjeni.
- Potvrdite da sučelje za uvođenje unaprijed popunjava svoje kontrole logotipa, boje i fonta iz tih pohranjenih vrijednosti.
- Testirajte nevaljani token i potvrdite da nema slijepe petlje ponovnih pokušaja ni curenja vjerodajnica.
- Testirajte nepravilno oblikovan odgovor s pomoću
MockHttpClienti potvrdite da se ne prihvaća djelomični komplet.
Uglancani dio ove značajke nije HTTP poziv. To je granica oko njega: asinkrono izvršavanje, suzdržani ponovni pokušaji, potpuna provjera, pohrana koja čuva dokaze i stanje neuspjeha iz kojeg se kupac može oporaviti. Kada su ti dijelovi na mjestu, adresa web-mjesta prestaje biti još jedno polje obrasca i postaje sjeme za radni prostor koji već djeluje kao da pripada klijentu.