Symfony: Automatizirajte prijedloge s provjerenim resursima robne marke putem API-ja
Prijedlog može biti tehnički ispravan, a ipak djelovati nedovršeno kada nedostaju klijentov logotip, boje, tipografija i vizualni jezik. Ručno kopiranje tih resursa sporo je, nedosljedno i iznenađujuće lako pogriješiti. Bolji tijek rada jednom uvozi podatke o robnoj marki web-mjesta temeljene na dokazima, provjerava ih na granici aplikacije i svakom prijedlogu ili periodičnom izvješću daje istu kontroliranu snimku.
Ovaj vodič gradi taj tijek rada u Symfonyju i PHP-u 8.3. Konzolna naredba poziva API Brand Kit Extractor, mapira njegov odgovor u domenski objekt i pohranjuje atomsku JSON snimku. Generiranje prijedloga i izvješća zatim čita predmemoriranu snimku umjesto pozivanja vanjske usluge tijekom zahtjeva usmjerenog prema korisniku.
Dobijte pristup i kopirajte servisni token
Počnite stvaranjem računa na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako ga već imate.
- Otvorite stranicu usluge Brand Kit Extractor.
- Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte token ograničen na uslugu.
- Pohranite ga u konfiguraciju podržanu varijablama okruženja. Nikada ga nemojte predati u repozitorij.
Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Implementacija u nastavku koristi Bearer token jer vjerodajnicu zadržava izvan URL-ova i zapisnika pristupa.
Ponovno generiranje servisnog tokena opoziva prethodno aktivni token. Regeneriranje tretirajte kao rotaciju vjerodajnice: odmah ažurirajte svako implementirano okruženje, provjerite zahtjev novim tokenom i uklonite svaku zastarjelu verziju tajne.
Potvrdite točan API poziv
Operacija ekstrakcije jest POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Prihvaća JSON koji sadržava url. Testirajte vjerodajnicu prije pisanja integracijskog koda:
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"}'
Upotrijebite 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 uspješan HTTP status čini svaku vrijednost sigurnom ili prikladnom za objavu; granica Symfonyja provjerit će strukturu prije pohrane.
Postavite token u .env.local, koji Symfony projekti obično isključuju iz kontrole verzija:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
Arhitektura: uvezite jednom, prikazujte mnogo puta
Pozivanje ekstrakcije dok netko čeka pregled prijedloga povezalo bi dostupnost stranice s udaljenom latencijom, kvotama i prolaznim neuspjesima. Ovaj dizajn umjesto toga koristi namjernu naredbu za uvoz. Generator koristi posljednju valjanu lokalnu snimku, tako da prekid rada uzvodne usluge ne može pokvariti prijedlog već konfiguriranog klijenta.
BrandKitExtractorupravlja HTTP autentikacijom, vremenskim ograničenjima, ponovnim pokušajima i obradom statusa.BrandKitprovjerava i mapira udaljene podatke u domenu aplikacije.BrandKitStorezapisuje atomsku snimku indeksiranu lokalnim identifikatorom klijenta.ImportBrandKitCommandizvršava kontrolirane uvoze tijekom uvođenja ili osvježavanja.- Kod za prijedloge i izvješća čita samo provjerene snimke.
Ekstraktor pruža podatke temeljene na dokazima pronađene na javnoj stranici. „Potvrđeno” ovdje znači strukturno provjereno i sljedivo do zatraženog URL-a, a ne dokaz vlasništva nad žigom ili dopuštenja za korištenje svakog otkrivenog resursa. Zadržite ljudsko odobrenje u tijeku rada objavljivanja.
Stvorite Symfony projekt
Trebate PHP 8.3 ili noviji, Composer i odlazni HTTPS pristup iz radnog okruženja. Stvorite usmjerenu Symfony aplikaciju i instalirajte komponente prve strane:
composer create-project symfony/skeleton proposal-branding
cd proposal-branding
composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack
Stvorite src/Brand za integraciju i var/brand-kits za generirane snimke. Potonji mora biti zapisiv u produkciji i trajan kroz izdanja.
Povežite token u config/services.yaml. Symfonyjevo zadano otkrivanje usluga može automatski povezati preostale ovisnosti:
parameters:
brand_kit.storage_dir: '%kernel.project_dir%/var/brand-kits'
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Brand\BrandKitExtractor:
arguments:
$token: '%env(BRAND_KIT_TOKEN)%'
App\Brand\BrandKitStore:
arguments:
$directory: '%brand_kit.storage_dir%'
Mapirajte odgovor u strogi domenski objekt
Zadržite znanje o nazivima vanjskih polja u jednoj klasi. Polja nizova mogu sadržavati nizove znakova, objekte ili ugniježđene dokaze ovisno o otkrivenom web-mjestu, stoga granica zahtijeva JSON-kompatibilne nizove bez izmišljanja užeg, nedokumentiranog obrasca.
<?php
// src/Brand/BrandKit.php
namespace App\Brand;
final readonly class BrandKit 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 string $sourceUrl,
public string $importedAt,
) {}
public static function fromApi(array $data, string $sourceUrl): self
{
$brandName = $data['brand_name'] ?? null;
if (!is_string($brandName) || trim($brandName) === '') {
throw new \UnexpectedValueException('Missing or invalid brand_name.');
}
$arrays = [];
foreach ([
'logos',
'colors',
'fonts',
'imagery',
'social_profiles',
'css_variables',
] as $field) {
if (!array_key_exists($field, $data) || !is_array($data[$field])) {
throw new \UnexpectedValueException(
sprintf('Missing or invalid %s.', $field)
);
}
self::assertJsonValue($data[$field], $field);
$arrays[$field] = $data[$field];
}
return new self(
trim($brandName),
$arrays['logos'],
$arrays['colors'],
$arrays['fonts'],
$arrays['imagery'],
$arrays['social_profiles'],
$arrays['css_variables'],
$sourceUrl,
(new \DateTimeImmutable())->format(DATE_ATOM),
);
}
private static function assertJsonValue(mixed $value, string $path): void
{
if (is_array($value)) {
foreach ($value as $key => $child) {
self::assertJsonValue($child, $path.'.'.$key);
}
return;
}
if (!is_null($value) && !is_scalar($value)) {
throw new \UnexpectedValueException('Invalid value at '.$path);
}
}
public function jsonSerialize(): array
{
return get_object_vars($this);
}
}
Ako službena dokumentacija promijeni pravopis polja ili doda omotnicu odgovora, ažurirajte ovaj mapper i njegove testove umjesto širenja uvjetnog parsiranja kroz kontrolere i predloške.
Izgradite ograničen HTTP klijent svjestan ponovnih pokušaja
Klijent ponovno pokušava samo neuspjehe koji će vjerojatno biti privremeni: transportne pogreške, odgovore 429 i odgovore poslužitelja 5xx. Neuspjesi autentikacije i provjere zahtjeva vraćaju se odmah jer bi ponovno pokušavanje s istim unosom i vjerodajnicom trošilo kvotu i odgodilo dijagnostiku.
<?php
// src/Brand/BrandKitExtractor.php
namespace App\Brand;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class BrandKitExtractor
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit';
public function __construct(
private HttpClientInterface $http,
private LoggerInterface $logger,
private string $token,
) {}
public function extract(string $url): BrandKit
{
$this->assertPublicHttpUrl($url);
$lastError = null;
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('POST', self::ENDPOINT, [
'auth_bearer' => $this->token,
'json' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 20.0,
]);
$status = $response->getStatusCode();
if ($status >= 200 && $status < 300) {
return BrandKit::fromApi($response->toArray(false), $url);
}
if (in_array($status, [400, 401, 403, 422], true)) {
throw new BrandKitImportFailed(
'Remote request rejected.',
$status,
false
);
}
if ($status !== 429 && $status < 500) {
throw new BrandKitImportFailed(
'Unexpected remote response.',
$status,
false
);
}
$lastError = new BrandKitImportFailed(
$status === 429
? 'Rate limit or quota reached.'
: 'Remote service is temporarily unavailable.',
$status,
true
);
} catch (TransportExceptionInterface $error) {
$lastError = new BrandKitImportFailed(
'Transport failure while importing the brand kit.',
0,
true,
$error
);
}
$this->logger->warning('Brand-kit import attempt failed', [
'url' => $url,
'attempt' => $attempt,
'retryable' => $lastError->retryable,
'status' => $lastError->getCode(),
]);
if (!$lastError->retryable || $attempt === 3) {
throw $lastError;
}
usleep((250 * (2 ** ($attempt - 1)) + random_int(0, 100)) * 1000);
}
throw $lastError;
}
private function assertPublicHttpUrl(string $url): void
{
$parts = parse_url($url);
if (
!is_array($parts)
|| !in_array($parts['scheme'] ?? '', ['http', 'https'], true)
|| empty($parts['host'])
) {
throw new \InvalidArgumentException(
'A public HTTP or HTTPS URL is required.'
);
}
}
}
<?php
// src/Brand/BrandKitImportFailed.php
namespace App\Brand;
final class BrandKitImportFailed extends \RuntimeException
{
public function __construct(
string $message,
int $status,
public readonly bool $retryable,
?\Throwable $previous = null,
) {
parent::__construct($message, $status, $previous);
}
}
Zapisnici namjerno izostavljaju token i tijelo odgovora. Ako proizvoljni korisnici mogu slati URL-ove, dodajte aplikacijski popis dopuštenih domena ili provjeru vlasništva. Provjera sheme sprječava neispravan unos, ali ne utvrđuje da podnositelj zahtjeva kontrolira udaljenu domenu.
Pohranjujte samo potpune snimke
Prekinuti zapis ne smije zamijeniti ispravan komplet robne marke polovicom JSON dokumenta. Zapišite u privremenu datoteku u odredišnom direktoriju i atomarno je preimenujte:
<?php
// src/Brand/BrandKitStore.php
namespace App\Brand;
final class BrandKitStore
{
public function __construct(private string $directory) {}
public function save(string $client, BrandKit $kit): void
{
if (!preg_match('/\A[a-z0-9][a-z0-9-]{1,63}\z/', $client)) {
throw new \InvalidArgumentException('Invalid client identifier.');
}
if (!is_dir($this->directory)
&& !mkdir($this->directory, 0770, true)
&& !is_dir($this->directory)) {
throw new \RuntimeException('Cannot create brand-kit directory.');
}
$target = $this->directory.'/'.$client.'.json';
$temporary = tempnam($this->directory, 'brand-kit-');
if ($temporary === false) {
throw new \RuntimeException('Cannot create temporary snapshot.');
}
try {
$json = json_encode(
$kit,
JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
);
if (file_put_contents($temporary, $json, LOCK_EX) === false
|| !rename($temporary, $target)) {
throw new \RuntimeException('Cannot publish brand-kit snapshot.');
}
} finally {
if (is_file($temporary)) {
unlink($temporary);
}
}
}
public function get(string $client): BrandKit
{
$data = json_decode(
file_get_contents($this->directory.'/'.$client.'.json'),
true,
512,
JSON_THROW_ON_ERROR
);
return new BrandKit(
$data['brandName'],
$data['logos'],
$data['colors'],
$data['fonts'],
$data['imagery'],
$data['socialProfiles'],
$data['cssVariables'],
$data['sourceUrl'],
$data['importedAt'],
);
}
}
Izložite uvoz kao operativnu naredbu
<?php
// src/Command/ImportBrandKitCommand.php
namespace App\Command;
use App\Brand\BrandKitExtractor;
use App\Brand\BrandKitImportFailed;
use App\Brand\BrandKitStore;
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 BrandKitExtractor $extractor,
private BrandKitStore $store,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('client', InputArgument::REQUIRED)
->addArgument('url', InputArgument::REQUIRED);
}
protected function execute(
InputInterface $input,
OutputInterface $output
): int {
try {
$client = (string) $input->getArgument('client');
$kit = $this->extractor->extract(
(string) $input->getArgument('url')
);
$this->store->save($client, $kit);
$output->writeln('Imported brand kit for '.$kit->brandName);
return Command::SUCCESS;
} catch (BrandKitImportFailed | \InvalidArgumentException $error) {
$output->writeln('<error>'.$error->getMessage().'</error>');
return Command::FAILURE;
}
}
}
Uvezite klijenta s php bin/console app:brand-kit:import acme https://www.example.com. Generator prijedloga može ubrizgati BrandKitStore, pozvati get('acme') i proslijediti dobivene logotipe, boje, fontove, slike, društvene profile i CSS varijable svom sloju za iscrtavanje dokumenta.
Nemojte umetati udaljene CSS varijable ili URL-ove resursa izravno u HTML. Odaberite odobrene unose, ponovno provjerite URL-ove pri iscrtavanju, izbjegnite tekst i ograničite CSS vrijednosti na formate koje vaši predlošci podržavaju.
Testirajte vanjsku granicu bez mrežnog pristupa
MockHttpClient čini ponašanje uspjeha i neuspjeha determinističkim:
<?php
// tests/Brand/BrandKitExtractorTest.php
namespace App\Tests\Brand;
use App\Brand\BrandKitExtractor;
use App\Brand\BrandKitImportFailed;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class BrandKitExtractorTest extends TestCase
{
public function testMapsACompleteResponse(): void
{
$response = new MockResponse(json_encode([
'brand_name' => 'Example',
'logos' => [['url' => 'https://example.com/logo.svg']],
'colors' => ['#112233'],
'fonts' => ['Inter'],
'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--brand-primary' => '#112233'],
], JSON_THROW_ON_ERROR), ['http_code' => 200]);
$extractor = new BrandKitExtractor(
new MockHttpClient($response),
new NullLogger(),
'test-token'
);
$kit = $extractor->extract('https://example.com');
self::assertSame('Example', $kit->brandName);
self::assertSame(['#112233'], $kit->colors);
}
public function testDoesNotRetryAuthenticationFailure(): void
{
$calls = 0;
$client = new MockHttpClient(
function () use (&$calls): MockResponse {
$calls++;
return new MockResponse('', ['http_code' => 401]);
}
);
$extractor = new BrandKitExtractor(
$client,
new NullLogger(),
'invalid-token'
);
try {
$extractor->extract('https://example.com');
self::fail('Expected import failure.');
} catch (BrandKitImportFailed $error) {
self::assertFalse($error->retryable);
self::assertSame(401, $error->getCode());
self::assertSame(1, $calls);
}
}
}
Pokrenite php bin/phpunit. Dodajte testove mappera za nedostajuća polja, neispravne nizove i nevažeći JSON prije promjene prilagodnika odgovora.
Implementacija, nadzor i česti neuspjesi
Ubrizgajte BRAND_KIT_TOKEN putem upravitelja tajni svoje hosting platforme. Stvorite direktorij za snimke tijekom implementacije, dodijelite ga PHP korisniku i postavite ga na trajnu pohranu kada izdanja koriste jednokratne spremnike. Topla implementacija trebala bi sačuvati posljednju valjanu snimku.
Nadzirite trajanje uvoza, ishod, HTTP status, broj ponovnih pokušaja, identifikator klijenta i izvorni host. Upozorite na dugotrajne odgovore 401 ili 403 jer obično ukazuju na opozvan ili nepravilno implementiran token. Trajne odgovore 429 tretirajte kao signale kvote ili raspoređivanja, a ne kao dopuštenje za neograničene ponovne pokušaje.
- 401 ili 403: provjerite token ograničen na uslugu i je li ponovno generiran.
- 400 ili 422: pregledajte poslani javni URL; nemojte ponovno pokušavati s nepromijenjenim unosom.
- 429: zaustavite se nakon ograničenih pokušaja, zadržite prethodnu snimku i pokušajte ponovno kasnije.
- 5xx ili vremensko ograničenje transporta: dopustite kratki slijed odgode, zatim neuspješno završite bez zamjene pohranjenih podataka.
- Neuspjeh mapiranja: usporedite aktualni dokumentirani odgovor s centraliziranim mapperom i prvo ažurirajte testove.
- Pohrana bez prava pisanja: ispravite vlasništvo implementacije ili montirajte trajnu pohranu; nemojte se vraćati na nesiguran javni direktorij.
Završni kontrolni popis provjere
- Točna POST krajnja točka uspijeva s neprodukcijskim testnim URL-om.
- Token postoji samo u tajnoj konfiguraciji podržanoj varijablama okruženja.
- Svih sedam kategorija podataka robne marke provjereno je prije pohrane.
- Neuspjesi autentikacije i provjere nikada se naslijepo ne pokušavaju ponovno.
- Ograničenja brzine, neuspjesi poslužitelja i transportne pogreške imaju ograničene ponovne pokušaje.
- Zapisnici sadržavaju operativni kontekst, ali ne i vjerodajnicu ili neobrađeno tijelo odgovora.
- Zamjena snimke je atomska, a pohrana preživljava implementaciju.
- Automatizirani testovi prolaze bez mrežnog zahtjeva.
- Generirani prijedlog koristi odobrenu lokalnu snimku i izbjegava izlaz.
Važna produkcijska odluka nije samo kako pozvati API. Riječ je o tome gdje smjestiti povjerenje. Odvajanjem ekstrakcije, provjere, pohrane, odobravanja i iscrtavanja generator prijedloga dobiva dosljedno brendiranje bez ovisnosti svakog dokumenta o aktivnom vanjskom zahtjevu. Rezultat je tiša infrastruktura, sigurniji predlošci i prijedlozi koji djeluju namjerno pripremljeno, a ne sastavljeno u posljednji trenutak.