Автоматизација на Symfony Brand Kit: однапред пополнете ги работните простори на клиентите
Празен работен простор за клиент создава незгоден прв впечаток. Клиентот веќе внел адреса на веб-страница, а екранот за поставување сè уште бара од него да прикачи лого, да пронајде вредности за бои и да идентификува фонтови што можеби не ги знае по име.
Подобар процес на воведување ја користи веб-страницата како почетна точка. Во ова упатство, Symfony апликација испраќа јавна URL-адреса до API-то Brand Kit Extractor, ги валидира вратените име на брендот, логоа, бои, фонтови, слики, профили на социјални мрежи и CSS-променливи, а потоа го складира резултатот во работниот простор. Извлекувањето се извршува преку Messenger, па воведувањето останува брзо и кога оддалечената веб-страница е бавна.
Дизајнот е намерно скромен: еден контролер, една порака во редица, строга API-граница и JSON-колони за податоци засновани на докази што може да се развиваат независно од вашата апликација.
Добијте пристап пред да напишете интеграциски код
Најпрво, регистрирајте сметка или најавете се. Отворете ја страницата на услугата Brand Kit Extractor, изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
Потоа, отворете ја официјалната документација за услугата. Пронајдете го панелот Service token и копирајте го токенот со опсег на услугата што е прикажан таму. Оваа услуга бара автентикација; не е API без токен.
Повторното генерирање на токенот го поништува претходно активниот токен. Третирајте ја ротацијата како промена при распоредување: ажурирајте ја секоја активна околина пред да се потпрете на новата акредитива и никогаш не ставајте ниту еден од токените во контрола на изворен код, фикстури, логови или слики од екранот.
Точната операција што ја користи овој проект е:
POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit
API-то прифаќа Bearer автентикација, заглавие X-API-Token или параметар за токен во барањето. Оваа имплементација користи Bearer токен бидејќи заглавијата поретко од низите за барање се појавуваат во прокси и пристапни логови.
Пред да ја изградите функционалноста, проверете ги сметката и токенот со минимално барање:
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"}'
Користете јавна веб-страница што имате право да ја обработувате. Успешниот одговор треба да содржи име на брендот, логоа, бои, фонтови, слики, профили на социјални мрежи и CSS-променливи. Не претпоставувајте дека само успехот ја прави секоја вредност соодветна за складирање; границата на апликацијата ќе ја потврди формата на одговорот.
Ставете ја вистинската акредитива во .env.local за локален развој, а не во верзионираната датотека .env:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=brand_kit
Во продукција, внесете ги истите променливи преку управувачот со тајни на платформата за распоредување.
Создадете Symfony проект
Апликацијата бара PHP 8.3 или понов, Composer, Symfony апликација и база на податоци поддржана од Doctrine. Почнувајќи од празен директориум, инсталирајте ги само компонентите што ѝ служат на функционалноста:
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
Добиената функционалност има четири слоја:
- Контролерот создава работен простор и испраќа задача за извлекување.
- Messenger го преместува оддалечениот I/O надвор од патеката на барањето.
- Наменски клиент управува со автентикацијата, временските ограничувања, повторните обиди и HTTP-неуспесите.
- Маперот ги отфрла нецелосните или погрешно форматираните одговори пред да ги види Doctrine.
JSON-колоните се корисен компромис тука. Логоата, боите и фонтовите може да содржат побогати докази од една URL-адреса или хексадецимална вредност, а нивното предвремено поедноставување би отфрлило информации. Ако на апликацијата подоцна ѝ требаат прашања како „најди го секој работен простор што го користи овој фонт“, претворете го тој конкретен концепт во нормализирани табели.
Дефинирајте строга доменска граница
Создадете src/Brand/BrandKit.php. Маперот намерно го валидира само документираниот договор на највисоко ниво. Ги зачувува вгнездените вредности наместо да измислува недокументирана шема за лого, боја или фонт.
<?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'],
);
}
}
Ова е намерна граница што се затвора при неуспех. Празна низа е валидна бидејќи јавна веб-страница може да не изложува веродостојни докази за фонт или профил на социјална мрежа. Недостасувачко поле или погрешен тип не е валиден, бидејќи складирањето делумен одговор би направило транспортен проблем да изгледа како легитимен податок за брендот.
Изградете отпорен HTTP-клиент
Создадете src/Brand/BrandKitClient.php. Тој испраќа точно еден JSON-параметар, url, и го ограничува и неактивното време на мрежата и вкупното времетраење на барањето. Повторува обиди при транспортни неуспеси, HTTP 429 и серверски грешки, но никогаш не повторува при неуспеси на автентикација, авторизација или друга клиентска валидација.
<?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.');
}
}
Клиентот не ги запишува во лог токенот, телото на одговорот или целосната URL-адреса. URL-адресите може да содржат идентификатори на клиенти или низи за барање, додека податоците од одговорот може да откријат деловни сметки и дизајнерски средства. Логовите треба да бележат оперативни факти, а не да дуплираат податоци.
Поврзете ја скаларната акредитива преку config/services.yaml:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Brand\BrandKitClient:
arguments:
$token: '%env(string:BRAND_KIT_TOKEN)%'
Зачувајте го целосниот резултат
Ентитетот на работниот простор треба да ја задржи својата изворна URL-адреса на веб-страницата и експлицитна состојба на извлекување. Додајте Doctrine JSON-својства за секое валидирано поле од одговорот, не само за трите што моментално ги прикажува воведувањето:
<?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);
}
}
Генерирајте и прегледајте ја миграцијата пред да ја примените:
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
Ставете го извлекувањето во редица по создавањето на работниот простор
Создадете мала порака и обработувач во src/Message/ExtractWorkspaceBrand.php и 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();
}
}
Конфигурирајте асинхрона испорака во config/packages/messenger.yaml:
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
App\Message\ExtractWorkspaceBrand: async
Вашиот контролер за создавање работен простор со автентикација треба да потврди дека url е HTTP или HTTPS URL-адреса со име на домаќин, да го зачува работниот простор, да изврши flush за да го добие неговиот ID и дури потоа да испрати:
$workspace = new Workspace($name, $url);
$entityManager->persist($workspace);
$entityManager->flush();
$messageBus->dispatch(
new ExtractWorkspaceBrand($workspace->getId())
);
Барајте авторизација на оваа рута, отфрлете директни приватни или loopback адреси и прифаќајте само веб-страници што клиентот е овластен да ги обработува. Оддалечената услуга презема јавна веб-страница, но вашата апликација сè уште контролира кој може да стави задачи во редица и колку работа може да создаде една сметка.
Тестирајте ја границата без мрежни повици
MockHttpClient го прави тестот детерминистички и го проверува излезниот договор:
<?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);
}
}
Додајте тестови за маперот за недостасувачко поле, невалиден brand_name и колекции што не се низи. Додајте тестови за обработувачот што покриваат недостасувачки работен простор, успешно зачувување и структурирана состојба failed кога клиентот фрла исклучок. Извршете ги со:
php bin/phpunit
Продукциска сигурност и вообичаени неуспеси
Извршувајте го worker-от под systemd, Supervisor, контејнерски оркестратор или worker-функцијата на вашата хостинг платформа:
php bin/console messenger:consume async \
--time-limit=3600 \
--memory-limit=256M \
--no-interaction
Распоредете ја миграцијата на базата на податоци пред да стартувате worker-и што го содржат новиот обработувач. Рестартирајте ги worker-ите по секое издание за да вчитаат тековен код и ротирани тајни.
Следете ги бројките и латентноста за работните простори ready и failed, категориите HTTP-статуси, обидите за повторување и староста на редицата. Алармирање за растечка редица открива запрен worker; алармирање за неуспеси на автентикацијата открива истечен или повторно генериран токен. Ограничете го контекстот на логовите на ID-ја на работни простори и метаподатоци за статус.
Најчестите начини на неуспех имаат различни решенија:
- HTTP 401 или 403: проверете ја активацијата на планот и токенот за услугата. Не повторувајте автоматски.
- HTTP 429: почитувајте го ограниченото доцнење
Retry-After, а потоа оставете го работниот простор во состојба на неуспех ако ограничувањето продолжи. Понудете експлицитна акција за повторен обид подоцна. - Грешки на валидација од HTTP 400-опсегот: повторно проверете ги јавната URL-адреса и телото на барањето; повторените идентични повици нема да ги поправат.
- Истекувања на време или одговори од HTTP 500-опсегот: дозволете го ограничениот backoff на клиентот, притоа одржувајќи го веб-барањето независно од резултатот.
- Погрешно форматирани успешни одговори: отфрлете ги пред складирањето и зачувајте ги постојните податоци на работниот простор.
Контролна листа за конечна проверка
- Создадете работен простор со овластена јавна HTTP или HTTPS веб-страница.
- Потврдете дека веб-барањето се враќа без да чека извлекување.
- Стартувајте го Messenger worker-от и потврдете дека пораката е обработена.
- Потврдете дека работниот простор достигнува
ready. - Потврдете дека се зачувани вратените име на брендот, логоа, бои, фонтови, слики, профили на социјални мрежи и CSS-променливи.
- Потврдете дека интерфејсот за воведување однапред ги пополнува своите контроли за лого, боја и фонт од тие зачувани вредности.
- Тестирајте невалиден токен и потврдете дека нема слепа јамка на повторни обиди или протекување на акредитиви.
- Тестирајте погрешно форматиран одговор со
MockHttpClientи потврдете дека не се прифаќа делумен комплет.
Исполиран дел од оваа функционалност не е HTTP-повикот. Тоа е границата околу него: асинхроно извршување, умерени повторни обиди, целосна валидација, складирање што ги зачувува доказите и состојба на неуспех од која клиентот може да се опорави. Со овие делови на место, адресата на веб-страница престанува да биде уште едно поле во формулар и станува семе за работен простор што веќе изгледа како да му припаѓа на клиентот.