Symfony: Umetnite provjerene resurse robne marke u prijedloge pomoću API-ja Brand Kit
Generator ponuda postaje iznenađujuće krhak kada brendiranje stigne kao „plava boja s web-stranice” i logotip kopiran iz potpisa e-pošte. Resursi se mijenjaju, boje se nagađaju, a svaki novi klijent stvara još jedan ručni zadatak čišćenja.
Ovaj vodič zamjenjuje takav tijek rada Symfony integracijom spremnom za produkciju. Konzolna naredba šalje javnu web-stranicu klijenta API-ju Brand Kit Extractor, provjerava odgovor na granici aplikacije i sprema normaliziranu snimku za svakodnevni generator HTML ponuda ili izvještaja. Dizajn daje prednost eksplicitnim stanjima neuspjeha, ograničenim mrežnim pozivima, determinističkim testovima i sloju za pohranu koji se može razvijati bez vezivanja ostatka aplikacije uz vanjski odgovor.
Dobijte pristup i izradite servisni token
Registrirajte se putem stranice za registraciju ili upotrijebite stranicu za prijavu ako već imate račun.
- 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.
Usluga zahtijeva autentifikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Ova implementacija koristi oblik Bearer jer zadržava vjerodajnice izvan URL-ova, zapisnika pristupa, povijesti preglednika i proxy analitike.
Ponovno generiranje servisnog tokena opoziva prethodno aktivni token. Rotaciju tretirajte kao operaciju uvođenja: ažurirajte spremište tajni, uvedite ili ponovno pokrenite potrošače, provjerite novi token i tek tada smatrajte promjenu dovršenom.
Potvrdite pristup s točnom krajnjom točkom
API poziv je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Njegovo JSON tijelo sadrži url. Testirajte ga prije dodavanja koda okvira:
curl --request POST \
'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://example.com"}'
Ne lijepite stvarni token u primjere za kontrolu izvornog koda ili povijest ljuske. U Symfony projektu stavite ga u .env.local za lokalni razvoj:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
Produkcija bi trebala unijeti istu varijablu putem upravitelja tajni platforme za hosting. U .env predajte samo zamjensku vrijednost bez tajne, kao što je BRAND_KIT_TOKEN=.
Arhitektura za praktični generator ponuda
Ova implementacija pretpostavlja PHP 8.3 ili noviji i postojeću Symfony aplikaciju s dostupnim komponentama Console, HttpClient i Filesystem. Komponente koje nedostaju instalirajte pomoću:
composer require symfony/http-client symfony/console symfony/filesystem
composer require --dev symfony/test-pack
Uvoz ostaje sinkron jer je to konzolni zadatak koji pokreće operater, a ne dio interaktivnog zahtjeva. Time se izbjegava uvođenje Messengera samo radi premještanja kratke naredbe negdje drugdje. Ako uvozi kasnije potječu iz HTTP zahtjeva usmjerenih korisnicima, Messenger postaje koristan: stavite URL u red čekanja, odmah vratite odgovor i pokrenite istu aplikacijsku uslugu u radniku.
Projekt ima četiri granice:
BrandKitMapperodbacuje neispravne ili nepotpune vanjske podatke.BrandKitExtractorupravlja autentifikacijom, vremenskim ograničenjima, ponovnim pokušajima i klasifikacijom HTTP neuspjeha.BrandKitStoretrajno sprema stabilan prikaz aplikacije.ImportBrandKitCommandpovezuje uvoznik s tijekom rada ponude.
Spremište temeljeno na datotekama dovoljno je za jednu instancu aplikacije ili zajednički trajni volumen. Više replika treba zamijeniti samo ovaj repozitorij postojanošću baze podataka ili objektne pohrane; ni API klijent ni prikazivač ponuda ne trebaju se mijenjati.
Preslikajte vanjski odgovor u domenski objekt
Ugovor pruža naziv brenda, logotipe, boje, fontove, slike, društvene profile i CSS varijable. Vanjski JSON nikada ne bi trebao teći izravno u Twig ili trajnu pohranu. Mapper u nastavku kanonizira samo ponuđene nazive polja najviše razine, zahtijeva svako polje i provodi konzervativne tipove.
Smjestite ove klase u src/BrandKit/BrandKit.php i src/BrandKit/BrandKitMapper.php:
<?php
// src/BrandKit/BrandKit.php
namespace App\BrandKit;
final readonly class BrandKit implements \JsonSerializable
{
public function __construct(
public string $sourceUrl,
public string $brandName,
public array $logos,
public array $colors,
public array $fonts,
public array $imagery,
public array $socialProfiles,
public array $cssVariables,
public array $logoUrls,
) {}
public function jsonSerialize(): array
{
return [
'source_url' => $this->sourceUrl,
'brand_name' => $this->brandName,
'logos' => $this->logos,
'colors' => $this->colors,
'fonts' => $this->fonts,
'imagery' => $this->imagery,
'social_profiles' => $this->socialProfiles,
'css_variables' => $this->cssVariables,
'logo_urls' => $this->logoUrls,
];
}
}
<?php
// src/BrandKit/BrandKitMapper.php
namespace App\BrandKit;
final class BrandKitMapper
{
public function map(string $sourceUrl, array $payload): BrandKit
{
$fields = [];
foreach ($payload as $name => $value) {
if (is_string($name)) {
$canonical = strtolower(
preg_replace('/[^a-z0-9]+/i', '', $name) ?? ''
);
$fields[$canonical] = $value;
}
}
$brandName = $this->required($fields, 'brandname');
if (!is_string($brandName) || trim($brandName) === '') {
throw new \UnexpectedValueException('Invalid brand name');
}
$arrays = [];
foreach ([
'logos', 'colors', 'fonts', 'imagery',
'socialprofiles', 'cssvariables',
] as $field) {
$value = $this->required($fields, $field);
if (!is_array($value)) {
throw new \UnexpectedValueException(
sprintf('Invalid brand-kit field: %s', $field)
);
}
$arrays[$field] = $value;
}
return new BrandKit(
$sourceUrl,
trim($brandName),
$arrays['logos'],
$arrays['colors'],
$arrays['fonts'],
$arrays['imagery'],
$arrays['socialprofiles'],
$arrays['cssvariables'],
$this->collectHttpUrls($arrays['logos']),
);
}
private function required(array $fields, string $name): mixed
{
if (!array_key_exists($name, $fields)) {
throw new \UnexpectedValueException(
sprintf('Missing brand-kit field: %s', $name)
);
}
return $fields[$name];
}
private function collectHttpUrls(array $values): array
{
$urls = [];
array_walk_recursive($values, static function (mixed $value) use (&$urls): void {
if (!is_string($value) || filter_var($value, FILTER_VALIDATE_URL) === false) {
return;
}
$scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
if ($scheme === 'http' || $scheme === 'https') {
$urls[] = $value;
}
});
return array_values(array_unique($urls));
}
}
Sakupljač URL-ova ne pretpostavlja nedokumentirani ugniježđeni oblik logotipa. Izvodi prikazive HTTP URL-ove iz bilo kojih strukturiranih dokaza o logotipu koje odgovor sadrži. Prazne kolekcije ostaju valjane jer neke javne web-stranice možda ne izlažu svaku kategoriju resursa.
Izgradite ograničen API klijent svjestan ponovnih pokušaja
Klijent ponavlja pokušaj samo kod ograničenja broja zahtjeva i privremenih pogrešaka poslužitelja. Pogreške autentifikacije i neispravni zahtjevi deterministički su dok se konfiguracija ili ulaz ne promijene, pa njihovo ponavljanje samo troši kvotu i odgađa dijagnostiku.
<?php
// src/BrandKit/BrandKitExtractor.php
namespace App\BrandKit;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class BrandKitImportException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly bool $retryable,
string $message,
) {
parent::__construct($message);
}
}
final class BrandKitExtractor
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit';
private \Closure $sleep;
public function __construct(
private readonly HttpClientInterface $http,
private readonly string $token,
private readonly BrandKitMapper $mapper,
?\Closure $sleep = null,
) {
if (trim($token) === '') {
throw new \InvalidArgumentException('BRAND_KIT_TOKEN is empty');
}
$this->sleep = $sleep
?? static fn (int $milliseconds) => usleep($milliseconds * 1000);
}
public function extract(string $url): BrandKit
{
if (filter_var($url, FILTER_VALIDATE_URL) === false
|| !in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)
) {
throw new \InvalidArgumentException('A public HTTP or HTTPS URL is required');
}
for ($attempt = 0; $attempt < 3; $attempt++) {
try {
$response = $this->http->request('POST', self::ENDPOINT, [
'headers' => [
'Authorization' => 'Bearer '.$this->token,
'Accept' => 'application/json',
],
'json' => ['url' => $url],
'timeout' => 5.0,
'max_duration' => 20.0,
]);
$status = $response->getStatusCode();
} catch (TransportExceptionInterface $exception) {
if ($attempt < 2) {
($this->sleep)(250 * (2 ** $attempt));
continue;
}
throw new BrandKitImportException(
'transport', true, 'Brand-kit service was unreachable'
);
}
if ($status >= 200 && $status < 300) {
try {
$payload = json_decode(
$response->getContent(false),
true,
512,
JSON_THROW_ON_ERROR
);
if (!is_array($payload)) {
throw new \JsonException('Expected a JSON object');
}
return $this->mapper->map($url, $payload);
} catch (\JsonException|\UnexpectedValueException $exception) {
throw new BrandKitImportException(
'invalid_response', false, $exception->getMessage()
);
}
}
if ($status === 401 || $status === 403) {
throw new BrandKitImportException(
'authentication', false, 'Service token was rejected'
);
}
if ($status === 400 || $status === 422) {
throw new BrandKitImportException(
'invalid_request', false, 'The website URL was rejected'
);
}
if ($status === 429 || $status >= 500) {
if ($attempt < 2) {
$headers = $response->getHeaders(false);
$retryAfter = $headers['retry-after'][0] ?? null;
$delay = ctype_digit((string) $retryAfter)
? min(2000, (int) $retryAfter * 1000)
: 250 * (2 ** $attempt);
($this->sleep)($delay);
continue;
}
throw new BrandKitImportException(
$status === 429 ? 'rate_limit' : 'upstream',
true,
'Temporary brand-kit service failure'
);
}
throw new BrandKitImportException(
'http', false, sprintf('Unexpected HTTP status %d', $status)
);
}
throw new \LogicException('Retry loop ended unexpectedly');
}
}
Vremensko ograničenje od pet sekundi ograničava vezu i mrežnu neaktivnost, dok max_duration ograničava cijeli zahtjev. Kašnjenja ponovnih pokušaja također su ograničena. Tijela odgovora namjerno nisu uključena u iznimke jer nadređeno tijelo može sadržavati osjetljivu dijagnostiku ili prekomjerne podatke.
Trajno spremite snimke i izložite naredbu za uvoz
Konfigurirajte ubrizgavanje ovisnosti bez predavanja vjerodajnice:
# config/services.yaml
services:
App\BrandKit\BrandKitExtractor:
arguments:
$token: '%env(BRAND_KIT_TOKEN)%'
App\BrandKit\BrandKitStore:
arguments:
$directory: '%kernel.project_dir%/var/brand-kits'
Repozitorij koristi Filesystem::dumpFile() kako čitatelji ne bi vidjeli djelomično zapisan JSON dokument.
<?php
// src/BrandKit/BrandKitStore.php
namespace App\BrandKit;
use Symfony\Component\Filesystem\Filesystem;
final class BrandKitStore
{
public function __construct(
private readonly string $directory,
private readonly Filesystem $filesystem,
) {}
public function save(string $customer, BrandKit $kit): void
{
$this->assertCustomer($customer);
$this->filesystem->mkdir($this->directory, 0750);
$this->filesystem->dumpFile(
$this->directory.'/'.$customer.'.json',
json_encode(
$kit,
JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
)
);
}
public function find(string $customer): ?array
{
$this->assertCustomer($customer);
$path = $this->directory.'/'.$customer.'.json';
if (!is_file($path)) {
return null;
}
$value = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
return is_array($value) ? $value : null;
}
private function assertCustomer(string $customer): void
{
if (!preg_match('/^[a-z0-9][a-z0-9-]{0,63}$/', $customer)) {
throw new \InvalidArgumentException('Invalid customer identifier');
}
}
}
<?php
// src/Command/ImportBrandKitCommand.php
namespace App\Command;
use App\BrandKit\BrandKitExtractor;
use App\BrandKit\BrandKitImportException;
use App\BrandKit\BrandKitStore;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:brand-kit:import')]
final class ImportBrandKitCommand extends Command
{
public function __construct(
private readonly BrandKitExtractor $extractor,
private readonly BrandKitStore $store,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('customer', InputArgument::REQUIRED)
->addArgument('url', InputArgument::REQUIRED);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$customer = (string) $input->getArgument('customer');
$url = (string) $input->getArgument('url');
try {
$kit = $this->extractor->extract($url);
$this->store->save($customer, $kit);
} catch (BrandKitImportException $exception) {
$this->logger->error('Brand-kit import failed', [
'customer' => $customer,
'host' => parse_url($url, PHP_URL_HOST),
'kind' => $exception->kind,
'retryable' => $exception->retryable,
]);
$output->writeln('<error>Import failed: '.$exception->kind.'</error>');
return Command::FAILURE;
}
$this->logger->info('Brand-kit import completed', [
'customer' => $customer,
'brand_name' => $kit->brandName,
]);
$output->writeln('Imported brand kit for '.$kit->brandName);
return Command::SUCCESS;
}
}
Pokrenite php bin/console app:brand-kit:import acme https://www.example.com. Dobiveni var/brand-kits/acme.json postaje stabilan ulaz generatora ponuda.
Umetnite sigurne vrijednosti u ponude
Prikazivač ne bi trebao pretvoriti proizvoljni izdvojeni CSS u izvršivi tekst stilskog lista. Namjerno odaberite vrijednosti, provjerite ih i zadržite neutralnu zamjensku vrijednost. Isto načelo vrijedi za URL-ove logotipa: Twig escaping štiti HTML sintaksu, ali PDF pokretač na poslužitelju ne smije dohvaćati udaljene resurse bez zasebnih SSRF kontrola i odobrenog programa za preuzimanje.
<?php
$brand = $brandKitStore->find($customer)
?? throw $this->createNotFoundException('Brand kit not imported');
$accent = $brand['css_variables']['--primary-color'] ?? '#1f2937';
if (!is_string($accent) || !preg_match('/^#[0-9a-f]{6}$/i', $accent)) {
$accent = '#1f2937';
}
return $this->render('proposal/show.html.twig', [
'proposal' => $proposal,
'brandName' => $brand['brand_name'],
'logoUrl' => $brand['logo_urls'][0] ?? null,
'accent' => $accent,
]);
<article style="--proposal-accent: {{ accent }}">
{% if logoUrl %}
<img src="{{ logoUrl }}" alt="{{ brandName }} logo">
{% endif %}
<h1>Proposal for {{ brandName }}</h1>
{# Render the existing proposal or report content here. #}
</article>
„Provjereno” ovdje znači da je aplikacija prihvatila strukturno valjano izdvajanje utemeljeno na dokazima, povezano sa zatraženom javnom web-stranicom. To ne utvrđuje prava na zaštitni znak, dopuštenje za licenciranje ni sigurnost udaljenih datoteka za preuzimanje.
Testirajte uspjeh, ponovne pokušaje i neispravne podatke
MockHttpClient održava testove determinističkima i sprječava slučajni pristup mreži. Ubrizgavanje uspavljivača bez operacije ubrzava testove ponovnih pokušaja.
<?php
namespace App\Tests\BrandKit;
use App\BrandKit\BrandKitExtractor;
use App\BrandKit\BrandKitImportException;
use App\BrandKit\BrandKitMapper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class BrandKitExtractorTest extends TestCase
{
private function payload(): string
{
return json_encode([
'brand_name' => 'Example',
'logos' => ['https://example.com/logo.svg'],
'colors' => ['#123456'],
'fonts' => [],
'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--primary-color' => '#123456'],
], JSON_THROW_ON_ERROR);
}
public function testMapsACompleteResponse(): void
{
$http = new MockHttpClient([
new MockResponse($this->payload(), ['http_code' => 200]),
]);
$extractor = new BrandKitExtractor(
$http,
'test-token',
new BrandKitMapper(),
static fn (int $milliseconds) => null,
);
$kit = $extractor->extract('https://example.com');
self::assertSame('Example', $kit->brandName);
self::assertSame(['https://example.com/logo.svg'], $kit->logoUrls);
self::assertSame(1, $http->getRequestsCount());
}
public function testRetriesAServiceFailure(): void
{
$http = new MockHttpClient([
new MockResponse('', ['http_code' => 503]),
new MockResponse($this->payload(), ['http_code' => 200]),
]);
$extractor = new BrandKitExtractor(
$http, 'test-token', new BrandKitMapper(),
static fn (int $milliseconds) => null,
);
self::assertSame('Example', $extractor->extract('https://example.com')->brandName);
self::assertSame(2, $http->getRequestsCount());
}
public function testRejectsAnIncompleteResponseWithoutRetrying(): void
{
$http = new MockHttpClient([
new MockResponse('{"brand_name":"Incomplete"}', ['http_code' => 200]),
]);
$extractor = new BrandKitExtractor(
$http, 'test-token', new BrandKitMapper(),
static fn (int $milliseconds) => null,
);
$this->expectException(BrandKitImportException::class);
$extractor->extract('https://example.com');
}
}
Pokrenite php bin/phpunit, a zatim testirajte naredbu s identifikatorom klijenta koji nije produkcijski.
Uvođenje, nadzor i česti neuspjesi
Omogućite da var/brand-kits bude zapisiv korisniku aplikacije i trajan kroz izdanja. U implementaciji s više čvorova premjestite snimke u zajedničku pohranu. Zagrijte konfiguraciju aplikacije nakon ubrizgavanja BRAND_KIT_TOKEN i ponovno pokrenite dugotrajne procese pri njegovoj rotaciji.
Nadzirite broj dovršenih i neuspjelih operacija prema kind, kao i trajanje zahtjeva na razini HTTP klijenta. Nikada ne zapisujte tokene, puna autorizacijska zaglavlja, tijela odgovora ni nizove upita URL-a. Naredba zapisuje samo identifikator klijenta i host web-stranice.
- Neuspjeh autentifikacije: potvrdite aktivaciju plana i trenutačni servisni token. Ponovno generirani token poništava prethodni.
- Neispravan zahtjev: provjerite je li URL javan, apsolutan i koristi li HTTP ili HTTPS.
- Ograničenje broja zahtjeva: pustite da ograničeni ponovni pokušaji završe, zatim zakažite uvoz za kasnije ili provjerite kapacitet plana.
- Neispravan odgovor: sačuvajte posljednju poznatu ispravnu snimku, upozorite na neuspjeh sheme i usporedite dokumentaciju usluge prije mijenjanja mappera.
- Logotip nedostaje: prihvatite valjani brand kit i prikažite ponudu bez logotipa umjesto da ga nagađate.
- Sustav datoteka samo za čitanje ili privremen: zamijenite repozitorij trajnom bazom podataka ili objektnom pohranom prije dodavanja replika.
Završni kontrolni popis za provjeru
- Račun i Free, Plus ili Pro plan su aktivni.
- Trenutačni token ograničen na uslugu dostavlja se putem
BRAND_KIT_TOKEN. - Minimalni POST zahtjev uspijeva prema točnoj krajnjoj točki za izdvajanje.
- Svih sedam obveznih odjeljaka brenda provjerava se prije trajne pohrane.
- Neuspjesi autentifikacije i provjere nikada se ne ponavljaju naslijepo.
- Ograničenja broja zahtjeva, transportne pogreške i neuspjesi poslužitelja koriste ograničene ponovne pokušaje.
- Direktorij snimaka je zapisiv, trajan i isključen iz javnog pristupa webu.
- CSS vrijednosti ponude nalaze se na popisu dopuštenih i provjerene su.
- Udaljene resurse ne dohvaćaju prikazivači na poslužitelju bez SSRF zaštite.
- Testovi prolaze bez stvarnih mrežnih zahtjeva.
Trajno poboljšanje nije samo to što se logotip pojavljuje automatski. Ono je u tome što brendiranje postaje kontroliran ulaz: pribavljen kroz jednu eksplicitnu API granicu, provjeren prije pohrane, vidljiv kada ne uspije i sigurno prilagođen prikazivaču ponuda. Time se ponavljajući dizajnerski posao pretvara u pouzdan dio aplikacije, umjesto u još jednu zbirku kopiranih datoteka i optimističnih pretpostavki.