Vodiči

Symfony Brand Kit Automation: Pre-Populate Client Workspaces

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

  1. Stvorite radni prostor s autoriziranim javnim HTTP ili HTTPS web-mjestom.
  2. Potvrdite da se web-zahtjev vraća bez čekanja ekstrakcije.
  3. Pokrenite Messenger radnika i provjerite da je poruka obrađena.
  4. Potvrdite da radni prostor doseže ready.
  5. Provjerite jesu li vraćeni naziv robne marke, logotipi, boje, fontovi, slike, društveni profili i CSS varijable pohranjeni.
  6. Potvrdite da sučelje za uvođenje unaprijed popunjava svoje kontrole logotipa, boje i fonta iz tih pohranjenih vrijednosti.
  7. Testirajte nevaljani token i potvrdite da nema slijepe petlje ponovnih pokušaja ni curenja vjerodajnica.
  8. Testirajte nepravilno oblikovan odgovor s pomoću MockHttpClient i 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.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.