Symfony: API za izdvajanje kompleta robne marke pokreće novu automatizaciju radnog prostora klijenta
Prazan radni prostor klijenta stvara trenutačne prepreke. Netko mora pronaći ispravan logotip, kopirati boje iz stilske datoteke, utvrditi fontove i raspršene tragove pretvoriti u upotrebljive postavke. Taj je posao dovoljno malen da se opetovano podcjenjuje, a dovoljno čest da zaslužuje automatizaciju.
Ovaj vodič izrađuje produkcijski usmjerenu Symfony značajku koja prihvaća URL javne web-stranice klijenta, poziva API Brand Kit Extractor, validira odgovor na granici aplikacije i pohranjuje dobiveni logotip, boje, fontove, slike, društvene profile i CSS varijable. Radni prostor tada se može otvoriti s vjerodostojnim vizualnim temeljem umjesto s praznim zaslonom za konfiguraciju.
Pribavite pristup prije pisanja integracijskog koda
API Brand Kit Extractor zahtijeva vjerodajnicu ograničenu na uslugu. Nije usluga bez tokena. Dovršite postupak uključivanja ovim redoslijedom:
- Izradite račun 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 plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte token ograničen na uslugu.
Ponovno generiranje tog tokena opoziva prethodno aktivni token. Ponovno generiranje tretirajte kao rotaciju vjerodajnica: odmah ažurirajte svako implementirano okruženje, provjerite novi token i uklonite zastarjelu vrijednost s platforme za tajne.
API prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Ova implementacija koristi oblik Bearer jer je vjerojatnije da će se parametri upita pojaviti u zapisima proxy poslužitelja, povijesti preglednika i URL-ovima za nadzor.
Potvrdite točan zahtjev
Operacija je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Njezino JSON tijelo sadrži url. Prije izrade Symfony značajke pošaljite jedan minimalan zahtjev iz pouzdanog terminala:
curl --request POST \
'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"}'
Dobiveni token ili odgovor nemojte lijepiti u tikete, fixtureove, snimke zaslona ni izvornu kontrolu. Uspješan zahtjev dokazuje da aktivacija računa i autentikacija rade neovisno o Symfonyju.
Pohranite vjerodajnicu u .env.local za lokalni razvoj. Symfony ovu datoteku uobičajeno isključuje iz kontrole verzija:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_ENDPOINT=https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit
U produkciji iste varijable postavite putem konfiguracije tajni i okruženja platforme za hosting. Nikada nemojte ugraditi token u sliku ili datoteku .env predanu u repozitorij.
Odaberite namjerno malu arhitekturu
Ovaj tijek rada ima četiri odgovornosti: kontroler validira lokalni zahtjev i autorizaciju, HTTP klijent upravlja vanjskim protokolom, mapator domene validira udaljene podatke, a entitet radnog prostora trajno pohranjuje prihvaćeni rezultat. Odvajanje tih granica omogućuje testiranje neispravnih odgovora uzvodne usluge bez uključivanja Doctrinea ili stvarne mreže.
Ekstrakcija se ovdje izvršava sinkrono jer je riječ o jednoj radnji postavljanja koju pokreće korisnik, a kontroler ima ograničeno ukupno trajanje zahtjeva. Ako okolni proizvod već obrađuje uključivanje asinkrono, ista se usluga može pozvati iz Symfony Messengera. Dodavanje reda čekanja samo za jedan ograničeni zahtjev stvorilo bi više stanja implementacije i neuspjeha nego što ovaj uobičajeni tijek rada radnog prostora treba.
Započnite s PHP-om 8.3 ili novijim, Composerom, Symfony aplikacijom i konfiguriranom Doctrine bazom podataka:
composer require symfony/http-client symfony/orm-pack symfony/validator
composer require --dev symfony/test-pack symfony/maker-bundle
php bin/console about
php bin/console doctrine:schema:validate
Relevantna struktura projekta namjerno je sažeta:
src/
BrandKit/BrandKit.php
BrandKit/BrandKitClient.php
BrandKit/BrandKitException.php
Controller/WorkspaceBrandKitController.php
Entity/Workspace.php
tests/
BrandKit/BrandKitClientTest.php
config/
services.yaml
Mapirajte nepouzdani JSON u objekt domene
HTTP odgovor 200 nije dopuštenje za pohranu proizvoljnog JSON-a. Granica mora provjeriti dokumentirani naziv robne marke, logotipe, boje, fontove, slike, društvene profile i CSS varijable. Prazna polja ostaju valjana jer javna web-stranica možda ne izlaže svaku kategoriju.
Sljedeći mapator zahtijeva te članove, provjerava njihove općenite JSON tipove, ograničava ukupnu veličinu korisnog tereta i odabire upotrebljiv HTTP ili HTTPS logotip bez pretpostavke da svaka stavka logotipa ima isti prikaz:
<?php
// src/BrandKit/BrandKit.php
namespace App\BrandKit;
final readonly class BrandKit
{
private const MAX_JSON_BYTES = 250_000;
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 fromApiPayload(array $payload): self
{
$brandName = $payload['brand_name'] ?? null;
if ($brandName !== null && (!is_string($brandName) || trim($brandName) === '')) {
throw new \UnexpectedValueException('brand_name must be a non-empty string or null.');
}
$kit = new self(
$brandName === null ? null : trim($brandName),
self::arrayMember($payload, 'logos'),
self::arrayMember($payload, 'colors'),
self::arrayMember($payload, 'fonts'),
self::arrayMember($payload, 'imagery'),
self::arrayMember($payload, 'social_profiles'),
self::arrayMember($payload, 'css_variables'),
);
$encoded = json_encode($payload, JSON_THROW_ON_ERROR);
if (strlen($encoded) > self::MAX_JSON_BYTES) {
throw new \UnexpectedValueException('Brand kit payload is too large.');
}
return $kit;
}
public function primaryLogo(): ?string
{
foreach ($this->logos as $logo) {
$candidate = is_string($logo)
? $logo
: (is_array($logo) && is_string($logo['url'] ?? null)
? $logo['url']
: null);
if ($candidate !== null
&& filter_var($candidate, FILTER_VALIDATE_URL)
&& in_array(
strtolower((string) parse_url($candidate, PHP_URL_SCHEME)),
['http', 'https'],
true
)
) {
return $candidate;
}
}
return null;
}
private static function arrayMember(array $payload, string $key): array
{
if (!array_key_exists($key, $payload) || !is_array($payload[$key])) {
throw new \UnexpectedValueException(sprintf('%s must be an array.', $key));
}
return $payload[$key];
}
}
To je također jedino mjesto koje treba prilagoditi ako službena dokumentacija kasnije uvede verziju ili omota odgovor. Kontroleri i entiteti nikada ne bi trebali postati povezani sa sirovim transportnim JSON-om.
Izradite ograničen API klijent svjestan ponovnih pokušaja
Klijent šalje samo dokumentirani član url. Ponovno pokušava nakon transportnih neuspjeha, HTTP 429 odgovora i neuspjeha poslužitelja uz kratko ograničeno odgađanje. Ne pokušava ponovno nakon pogrešaka autentikacije ili neuspjeha validacije: oni zahtijevaju promjenu konfiguracije ili unosa, a ne drugi istovjetni zahtjev.
<?php
// src/BrandKit/BrandKitException.php
namespace App\BrandKit;
final class BrandKitException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $status = null,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
<?php
// src/BrandKit/BrandKitClient.php
namespace App\BrandKit;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class BrandKitClient
{
public function __construct(
private HttpClientInterface $http,
private LoggerInterface $logger,
private string $brandKitEndpoint,
private string $brandKitToken,
) {
}
public function extract(string $url): BrandKit
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('POST', $this->brandKitEndpoint, [
'headers' => [
'Authorization' => 'Bearer '.$this->brandKitToken,
'Accept' => 'application/json',
],
'json' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 25.0,
]);
$status = $response->getStatusCode();
if (($status === 429 || $status >= 500) && $attempt < 3) {
$this->logger->warning('Brand kit request will be retried.', [
'status' => $status,
'attempt' => $attempt,
]);
usleep($this->backoffMicros($response->getHeaders(false), $attempt));
continue;
}
if ($status === 401 || $status === 403) {
throw new BrandKitException(
'authentication',
'The brand kit service rejected its credential.',
$status
);
}
if ($status === 429) {
throw new BrandKitException(
'quota',
'The brand kit service is rate limited.',
$status
);
}
if ($status < 200 || $status >= 300) {
throw new BrandKitException(
$status >= 500 ? 'upstream_unavailable' : 'request_rejected',
'The brand kit service rejected the request.',
$status
);
}
try {
$payload = $response->toArray(false);
return BrandKit::fromApiPayload($payload);
} catch (DecodingExceptionInterface|\UnexpectedValueException $exception) {
throw new BrandKitException(
'invalid_response',
'The brand kit service returned an invalid response.',
$status,
$exception
);
}
} catch (TransportExceptionInterface $exception) {
if ($attempt === 3) {
throw new BrandKitException(
'transport',
'The brand kit service could not be reached.',
null,
$exception
);
}
$this->logger->warning('Brand kit transport failure; retrying.', [
'attempt' => $attempt,
'exception_class' => $exception::class,
]);
usleep(250_000 * $attempt);
}
}
throw new \LogicException('Unreachable retry state.');
}
private function backoffMicros(array $headers, int $attempt): int
{
$retryAfter = $headers['retry-after'][0] ?? null;
if (is_string($retryAfter) && ctype_digit($retryAfter)) {
return min((int) $retryAfter, 2) * 1_000_000;
}
return 250_000 * $attempt;
}
}
Izričito povežite skalarne argumente. Symfony umeće HTTP klijent i zapisivač prema tipu:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\BrandKit\BrandKitClient:
arguments:
$brandKitEndpoint: '%env(BRAND_KIT_ENDPOINT)%'
$brandKitToken: '%env(BRAND_KIT_TOKEN)%'
Primijenite komplet na autorizirani radni prostor
Dodajte nullable polja robne marke postojećem entitetu Workspace. JSON stupci čuvaju nizove bogate dokazima, dok primaryLogoUrl sučelju pruža praktičnu, validiranu zadanu vrijednost.
<?php
// Relevant additions to src/Entity/Workspace.php
#[ORM\Column(length: 255, nullable: true)]
private ?string $brandName = null;
#[ORM\Column(length: 2048, nullable: true)]
private ?string $primaryLogoUrl = null;
#[ORM\Column(type: 'json')]
private array $brandColors = [];
#[ORM\Column(type: 'json')]
private array $brandFonts = [];
#[ORM\Column(type: 'json')]
private array $brandImagery = [];
#[ORM\Column(type: 'json')]
private array $brandSocialProfiles = [];
#[ORM\Column(type: 'json')]
private array $brandCssVariables = [];
public function applyBrandKit(\App\BrandKit\BrandKit $kit): void
{
$this->brandName = $kit->brandName;
$this->primaryLogoUrl = $kit->primaryLogo();
$this->brandColors = $kit->colors;
$this->brandFonts = $kit->fonts;
$this->brandImagery = $kit->imagery;
$this->brandSocialProfiles = $kit->socialProfiles;
$this->brandCssVariables = $kit->cssVariables;
}
Kontroler provjerava vlasništvo nad radnim prostorom putem glasnika, validira da je predana vrijednost HTTP URL javnog stila, poziva graničnu uslugu i ispire samo potpuno mapirane podatke:
<?php
// src/Controller/WorkspaceBrandKitController.php
namespace App\Controller;
use App\BrandKit\BrandKitClient;
use App\BrandKit\BrandKitException;
use App\Repository\WorkspaceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class WorkspaceBrandKitController extends AbstractController
{
#[Route('/workspaces/{id}/brand-kit', methods: ['POST'])]
public function __invoke(
int $id,
Request $request,
WorkspaceRepository $workspaces,
BrandKitClient $client,
EntityManagerInterface $entityManager,
): JsonResponse {
$workspace = $workspaces->find($id);
if ($workspace === null) {
throw $this->createNotFoundException();
}
$this->denyAccessUnlessGranted('EDIT', $workspace);
try {
$input = $request->toArray();
} catch (\Throwable) {
return $this->json(['error' => 'invalid_json'], 400);
}
$url = $input['url'] ?? null;
$scheme = is_string($url) ? strtolower((string) parse_url($url, PHP_URL_SCHEME)) : '';
if (!is_string($url)
|| filter_var($url, FILTER_VALIDATE_URL) === false
|| !in_array($scheme, ['http', 'https'], true)
) {
return $this->json(['error' => 'invalid_public_url'], 422);
}
try {
$kit = $client->extract($url);
$workspace->applyBrandKit($kit);
$entityManager->flush();
} catch (BrandKitException $exception) {
$status = $exception->kind === 'quota' ? 429 : 502;
return $this->json([
'error' => 'brand_kit_unavailable',
'reason' => $exception->kind,
'retryable' => in_array(
$exception->kind,
['quota', 'transport', 'upstream_unavailable'],
true
),
], $status);
}
return $this->json([
'workspace_id' => $id,
'brand_name' => $kit->brandName,
'primary_logo_url' => $kit->primaryLogo(),
'colors' => $kit->colors,
'fonts' => $kit->fonts,
]);
}
}
Za strože upravljanje unosom odbijte loopback i privatne mrežne adrese ili dopustite samo domene koje je potvrdio vlasnik radnog prostora. Iako vaš poslužitelj šalje URL API-ju umjesto da ga izravno dohvaća, prihvaćanje proizvoljnih odredišta koja izgledaju interno i dalje je nepotrebno te može vanjskoj usluzi otkriti osjetljiva imena.
Testirajte protokol bez pozivanja produkcije
MockHttpClient čini integraciju determinističkom. Test provjerava metodu, krajnju točku, zaglavlje autentikacije, JSON zahtjev i mapiranje domene:
<?php
// tests/BrandKit/BrandKitClientTest.php
namespace App\Tests\BrandKit;
use App\BrandKit\BrandKitClient;
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 testItMapsAValidatedBrandKit(): void
{
$http = new MockHttpClient(
function (string $method, string $url, array $options): MockResponse {
self::assertSame('POST', $method);
self::assertSame('https://service.test/extract', $url);
self::assertStringContainsString(
'Authorization: Bearer test-token',
implode("\n", $options['headers'])
);
self::assertSame(
['url' => 'https://example.com'],
json_decode($options['body'], true, 512, JSON_THROW_ON_ERROR)
);
return 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,
'response_headers' => ['content-type: application/json'],
]);
}
);
$client = new BrandKitClient(
$http,
new NullLogger(),
'https://service.test/extract',
'test-token'
);
$kit = $client->extract('https://example.com');
self::assertSame('Example', $kit->brandName);
self::assertSame('#112233', $kit->colors[0]);
self::assertSame('https://example.com/logo.svg', $kit->primaryLogo());
}
public function testItRejectsAnIncompleteResponse(): void
{
$http = new MockHttpClient(new MockResponse(
'{"brand_name":"Incomplete"}',
['http_code' => 200]
));
$client = new BrandKitClient(
$http,
new NullLogger(),
'https://service.test/extract',
'test-token'
);
$this->expectException(\App\BrandKit\BrandKitException::class);
$client->extract('https://example.com');
}
}
Dodajte testove kontrolera za neautoriziranog korisnika, neispravan JSON, nevaljan URL, iscrpljenu uzvodnu kvotu i uspješnu trajnu pohranu. Test usluge nikada ne smije ovisiti o aktivnom računu ni trošiti kvotu plana.
Implementirajte uz operativne zaštitne mjere
Lokalno generirajte i pregledajte Doctrine migraciju, pokrenite skup testova i primijenite predanu migraciju tijekom implementacije:
php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction
php bin/phpunit
php bin/console cache:clear --env=prod
Bilježite identifikator radnog prostora, uzvodni status, broj pokušaja, kategoriju neuspjeha i trajanje kada je dostupno. Nemojte bilježiti zaglavlje Authorization, potpuni odgovor ni predani niz upita. Upozoravajte na trajne neuspjehe autentikacije, ponovljene odgovore 429, transportne neuspjehe i nevaljane oblike odgovora; svaki upućuje na drugog vlasnika i drugačiji način otklanjanja.
Uobičajeni neuspjesi su predvidljivi:
- 401 ili 403: token nedostaje, opozvan je, netočno kopiran ili pripada pogrešnoj usluzi. Nemojte naslijepo ponavljati pokušaj.
- 429: aktivni plan ili stopa zahtjeva je iscrpljena. Poštujte ograničeno vrijeme ponovnog pokušaja i dopustite korisniku da pokuša kasnije.
- 400 ili 422: predani URL nije prihvatljiv. Ispravite unos umjesto da ga ponavljate.
- 5xx ili transportni neuspjeh: kratko pokušajte ponovno, zatim sačuvajte postojeće vrijednosti radnog prostora i vratite neuspjeh koji se može ponovno pokušati.
- Nevaljan JSON ili nedostajući članovi: tretirajte ovo kao neuspjeh ugovora. Ne pohranjujte ništa i istražite prije slabljenja validacije.
- Nema upotrebljivog logotipa: zadržite validirane boje i fontove, ostavite primarni logotip nullable i ponudite ručni odabir ili prijenos.
Kontrolni popis za završnu provjeru
- Plan usluge je aktivan, a token ograničen na uslugu isporučuje se putem konfiguracije podržane varijablama okruženja.
- Aplikacija poziva točnu POST krajnju točku s JSON članom
url. - Vrijeme povezivanja i ukupno vrijeme odgovora ograničeni su.
- Ponavljaju se samo prolazni neuspjesi, uz kratko ograničeno odgađanje.
- Svih sedam kategorija odgovora validirano je prije nego što ih Doctrine primi.
- Autorizacija radnog prostora izvršava se prije svakog vanjskog zahtjeva ili mutacije.
- Testovi koriste
MockHttpClienti ne sadrže stvarnu vjerodajnicu. - Zapisnici izlažu kategorije neuspjeha bez izlaganja tokena ili sirovih podataka robne marke.
- Stvarni radni prostor za staging otvara se s unaprijed popunjenim javnim logotipom, bojama i fontovima.
Najbolja automatizacija uključivanja ne pokušava donositi nepovratne dizajnerske odluke. Ona uklanja praznu stranicu. Tretiranjem izdvojenih podataka robne marke kao validiranih dokaza, očuvanjem granica neuspjeha i održavanjem mogućnosti uređivanja svakog polja, ova Symfony integracija novom radnom prostoru daje korisnu početnu točku bez pretvaranja da automatizacija zamjenjuje prosudbu.