Vodiči

Symfony: Automate Brand Asset Import for Proposals with Brand Kit Extractor API

Symfony: Automatizirajte uvoz resursa robne marke za ponude pomoću API-ja Brand Kit Extractor

Generator ponuda postaje mnogo korisniji kada “usklađivanje s klijentovim brendom” prestane biti ručni kontrolni popis. Težak dio nije postavljanje logotipa iznad naslova. Radi se o pribavljanju ispravnih materijala, očuvanju njihova podrijetla, odbacivanju neispravnih podataka i osiguravanju da kvar uzvodne usluge ne može prekinuti izradu ponuda.

Ovaj vodič izrađuje tu granicu u Symfonyju i PHP-u 8.3. Konzolna naredba šalje URL javne web-stranice API-ju Brand Kit Extractor, preslikava odgovor u provjereni objekt domene i pohranjuje atomsku snimku za uobičajeni renderer ponuda i izvještaja. Putanja renderiranja nikada ne poziva API, pa prethodno uvezeni brendovi ostaju dostupni tijekom prekida rada ili iscrpljenja kvote.

Dobijte pristup i izradite servisni token

Počnite registracijom računa ili upotrijebite stranicu za prijavu ako ga već imate.

  1. Otvorite stranicu usluge Brand Kit Extractor.
  2. Odaberite dostupni Free, Plus ili Pro plan i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte ondje prikazani token ograničen na uslugu.
  5. Pohranite taj token u konfiguraciju projekta koja koristi varijable okruženja. Nikada ga nemojte predati u repozitorij.

Ponovno generiranje servisnog tokena opoziva prethodno aktivni token, stoga rotacija tokena mora ažurirati svako postavljeno okruženje koje ga koristi. Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer token jer je veća vjerojatnost da će se parametri upita pojaviti u zapisnicima pristupa i sustavima za nadzor.

Potvrdite krajnju točku prije pisanja aplikacijskog koda

Točan poziv je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Njegovo JSON tijelo sadrži url:

export BRAND_KIT_TOKEN='YOUR_SERVICE_TOKEN'

curl --fail-with-body \
  --connect-timeout 5 \
  --max-time 30 \
  --request POST \
  --header "Authorization: Bearer ${BRAND_KIT_TOKEN}" \
  --header 'Content-Type: application/json' \
  --data '{"url":"https://example.com"}' \
  'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'

unset BRAND_KIT_TOKEN

Tijekom integracije pregledajte aktivnu dokumentaciju i vraćeni sadržaj. Granica aplikacije u nastavku namjerno izbjegava pretpostavljanje nedokumentirane ovojnice odgovora. Pronalazi dokumentirana semantička polja nakon normalizacije naziva poput brand_name i brandName, a zatim zahtijeva jedno nedvosmisleno pojavljivanje svakoga od njih.

Postavite vjerodajnicu u .env.local, koji Symfony projekti obično isključuju iz kontrole verzija:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_ENDPOINT=https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit

Odaberite namjerno malu arhitekturu

Projekt zahtijeva PHP 8.3 ili noviji, Composer, Symfonyjeve komponente HttpClient i Console, Twig i podršku za PHPUnit. Izradite aplikaciju naredbom:

composer create-project symfony/skeleton brand-proposals
cd brand-proposals

composer require symfony/http-client symfony/console symfony/twig-bundle symfony/monolog-bundle
composer require --dev symfony/test-pack

Važne datoteke su:

  • src/Brand/BrandKit.php — preslikavanje i provjera domene.
  • src/Brand/BrandKitException.php — strukturirani neuspjesi.
  • src/Brand/BrandKitExtractor.php — HTTP granica.
  • src/Brand/BrandKitStore.php — atomske lokalne snimke.
  • src/Command/ImportBrandKitCommand.php — uvoz namijenjen operatoru.
  • src/Controller/ProposalController.php — renderiranje ponuda.
  • templates/proposal/show.html.twig — izlaz s brendom.

Ovaj dizajn održava uvoze sinkronima i eksplicitnima. Tipično izdvajanje brenda povremena je operacija postavljanja ili osvježavanja, a ne dio svakog zahtjeva stranice. Messenger bi dodao operativnu infrastrukturu bez poboljšanja ovog tijeka rada. Ako uvozi kasnije počnu pristizati putem obrasca namijenjenog klijentima, isti se ekstraktor može pozvati iz Messenger handlera.

Povežite vrijednosti okruženja putem ubrizgavanja ovisnosti u config/services.yaml:

parameters:
    app.brand_kit_storage_dir: '%kernel.project_dir%/var/brand-kits'

services:
    _defaults:
        autowire: true
        autoconfigure: true
        bind:
            $brandKitEndpoint: '%env(string:BRAND_KIT_ENDPOINT)%'
            $brandKitToken: '%env(string:BRAND_KIT_TOKEN)%'
            $brandKitStorageDir: '%app.brand_kit_storage_dir%'

    App\:
        resource: '../src/'

Izgradite obrambenu granicu domene

API pruža podatke o brendu utemeljene na dokazima, ali vanjski JSON i dalje je nepouzdan ulaz. Prije pohrane zahtijevajte naziv brenda te logotipe, boje, fontove, slike, profile na društvenim mrežama i CSS varijable. Odbacite duplicirana semantička polja, prevelike sadržaje, opasne CSS fragmente i neispravne URL-ove materijala.

Izradite src/Brand/BrandKit.php:

<?php

namespace App\Brand;

final readonly class BrandKit
{
    private function __construct(
        public string $name,
        public array $logos,
        public array $colors,
        public array $fonts,
        public array $imagery,
        public array $socialProfiles,
        public array $cssVariables,
    ) {}

    public static function fromApi(array $payload): self
    {
        $read = static fn (string $field): mixed => self::findOne($payload, $field);

        $name = $read('brandname');
        if (!is_string($name) || trim($name) === '' || mb_strlen($name) > 200) {
            throw new \UnexpectedValueException('Invalid brand name.');
        }

        $arrays = [];
        foreach (['logos', 'colors', 'fonts', 'imagery', 'socialprofiles'] as $field) {
            $value = $read($field);
            if (!is_array($value)) {
                throw new \UnexpectedValueException("Invalid {$field} collection.");
            }
            $arrays[$field] = $value;
        }

        $css = self::validateCssVariables($read('cssvariables'));

        $kit = new self(
            trim($name),
            $arrays['logos'],
            $arrays['colors'],
            $arrays['fonts'],
            $arrays['imagery'],
            $arrays['socialprofiles'],
            $css,
        );

        self::validateUrlFields($kit->toArray());

        $json = json_encode($kit->toArray(), JSON_THROW_ON_ERROR);
        if (strlen($json) > 1_000_000) {
            throw new \UnexpectedValueException('Brand kit exceeds the storage limit.');
        }

        return $kit;
    }

    public function toArray(): array
    {
        return [
            'brand_name' => $this->name,
            'logos' => $this->logos,
            'colors' => $this->colors,
            'fonts' => $this->fonts,
            'imagery' => $this->imagery,
            'social_profiles' => $this->socialProfiles,
            'css_variables' => $this->cssVariables,
        ];
    }

    public function primaryLogoUrl(): ?string
    {
        $scan = function (mixed $value) use (&$scan): ?string {
            if (is_string($value)
                && filter_var($value, FILTER_VALIDATE_URL)
                && parse_url($value, PHP_URL_SCHEME) === 'https') {
                return $value;
            }

            if (is_array($value)) {
                foreach ($value as $child) {
                    if (($found = $scan($child)) !== null) {
                        return $found;
                    }
                }
            }

            return null;
        };

        return $scan($this->logos);
    }

    private static function findOne(array $payload, string $wanted): mixed
    {
        $matches = [];

        $walk = function (array $node, int $depth) use (&$walk, &$matches, $wanted): void {
            if ($depth > 16) {
                throw new \UnexpectedValueException('Response nesting is too deep.');
            }

            foreach ($node as $key => $value) {
                $normalized = is_string($key)
                    ? preg_replace('/[^a-z0-9]/', '', strtolower($key))
                    : '';

                if ($normalized === $wanted) {
                    $matches[] = $value;
                }

                if (is_array($value)) {
                    $walk($value, $depth + 1);
                }
            }
        };

        $walk($payload, 0);

        if (count($matches) !== 1) {
            throw new \UnexpectedValueException("Expected exactly one {$wanted} field.");
        }

        return $matches[0];
    }

    private static function validateCssVariables(mixed $value): array
    {
        if (!is_array($value) || count($value) > 128) {
            throw new \UnexpectedValueException('Invalid CSS variables.');
        }

        foreach ($value as $name => $cssValue) {
            if (!is_string($name)
                || preg_match('/^--[a-zA-Z0-9-]+$/', $name) !== 1
                || !is_string($cssValue)
                || strlen($cssValue) > 200
                || preg_match('/[\x00-\x1F;{}<>]/', $cssValue)
                || stripos($cssValue, 'url(') !== false
                || stripos($cssValue, '@import') !== false) {
                throw new \UnexpectedValueException('Unsafe CSS variable.');
            }
        }

        return $value;
    }

    private static function validateUrlFields(array $node): void
    {
        foreach ($node as $key => $value) {
            $normalized = is_string($key)
                ? preg_replace('/[^a-z0-9]/', '', strtolower($key))
                : '';

            if (is_string($value) && str_ends_with($normalized, 'url')) {
                $scheme = parse_url($value, PHP_URL_SCHEME);
                if (!filter_var($value, FILTER_VALIDATE_URL)
                    || !in_array($scheme, ['http', 'https'], true)) {
                    throw new \UnexpectedValueException('Invalid asset URL.');
                }
            }

            if (is_array($value)) {
                self::validateUrlFields($value);
            }
        }
    }
}

Preslikavač ne tvrdi da je svaku boju, font ili sliku sigurno automatski renderirati. On uspostavlja ograničenu snimku sigurnu za JSON. Ponuda odabire HTTPS logotip i ograničenu mapu CSS varijabli; preostali dokazi ostaju dostupni za pregledane predloške i izvještaje.

Pozovite API s ograničenim ponašanjem pri neuspjehu

Izradite vrstu neuspjeha u src/Brand/BrandKitException.php:

<?php

namespace App\Brand;

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);
    }
}

Zatim implementirajte src/Brand/BrandKitExtractor.php. Koristi ukupno tri pokušaja, ograničeno eksponencijalno odgađanje, vremensko ograničenje povezivanja od pet sekundi i ukupno trajanje zahtjeva od trideset sekundi. Neuspjesi autentikacije i provjere zahtjeva nikada se ne ponavljaju.

<?php

namespace App\Brand;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class BrandKitExtractor
{
    public function __construct(
        private HttpClientInterface $http,
        private LoggerInterface $logger,
        private string $brandKitEndpoint,
        private string $brandKitToken,
    ) {}

    public function extract(string $website): BrandKit
    {
        $parts = parse_url($website);
        if (!is_array($parts)
            || !in_array($parts['scheme'] ?? null, ['http', 'https'], true)
            || empty($parts['host'])
            || isset($parts['user'])
            || isset($parts['pass'])) {
            throw new BrandKitException('invalid_request', 'Use a public HTTP or HTTPS URL.');
        }

        if ($this->brandKitToken === '') {
            throw new BrandKitException('configuration', 'The service token is not configured.');
        }

        $hostHash = hash('sha256', strtolower($parts['host']));
        $started = hrtime(true);

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->http->request('POST', $this->brandKitEndpoint, [
                    'auth_bearer' => $this->brandKitToken,
                    'json' => ['url' => $website],
                    'timeout' => 20.0,
                    'max_duration' => 30.0,
                ]);
                $status = $response->getStatusCode();
            } catch (TransportExceptionInterface $e) {
                if ($attempt < 3) {
                    $this->backoff($attempt, null);
                    continue;
                }

                throw new BrandKitException('network', 'The extraction service is unreachable.', null, $e);
            }

            $headers = $response->getHeaders(false);
            $retryable = in_array($status, [429, 500, 502, 503, 504], true);

            if ($retryable && $attempt < 3) {
                $this->logger->warning('Brand extraction will be retried.', [
                    'attempt' => $attempt,
                    'status' => $status,
                    'website_host_hash' => $hostHash,
                ]);
                $this->backoff($attempt, $headers['retry-after'][0] ?? null);
                continue;
            }

            if ($status < 200 || $status >= 300) {
                $kind = match ($status) {
                    401, 403 => 'authentication',
                    400, 422 => 'invalid_request',
                    429 => 'rate_limited',
                    default => 'upstream',
                };

                throw new BrandKitException(
                    $kind,
                    'Brand extraction failed without storing a snapshot.',
                    $status,
                );
            }

            try {
                $content = $response->getContent(false);
            } catch (TransportExceptionInterface $e) {
                if ($attempt < 3) {
                    $this->backoff($attempt, null);
                    continue;
                }

                throw new BrandKitException('network', 'The response body was interrupted.', null, $e);
            }

            if (strlen($content) > 1_000_000) {
                throw new BrandKitException('invalid_response', 'The response is too large.');
            }

            try {
                $payload = json_decode($content, true, 64, JSON_THROW_ON_ERROR);
                if (!is_array($payload)) {
                    throw new \UnexpectedValueException('Expected a JSON object or array.');
                }
                $kit = BrandKit::fromApi($payload);
            } catch (\JsonException|\UnexpectedValueException $e) {
                throw new BrandKitException(
                    'invalid_response',
                    'The response did not satisfy the brand-kit contract.',
                    $status,
                    $e,
                );
            }

            $this->logger->info('Brand extraction completed.', [
                'status' => $status,
                'attempt' => $attempt,
                'duration_ms' => (int) ((hrtime(true) - $started) / 1_000_000),
                'website_host_hash' => $hostHash,
            ]);

            return $kit;
        }

        throw new BrandKitException('upstream', 'Brand extraction failed.');
    }

    private function backoff(int $attempt, ?string $retryAfter): void
    {
        $milliseconds = ctype_digit((string) $retryAfter)
            ? min(5_000, (int) $retryAfter * 1_000)
            : min(2_000, 250 * (2 ** ($attempt - 1)) + random_int(0, 100));

        usleep($milliseconds * 1_000);
    }
}

Ne zapisuje se tijelo odgovora, token, puni URL web-stranice ni izdvojeni materijal. Stabilni hash naziva hosta dovoljan je za povezivanje ponovljenih pokušaja bez neformalnog otkrivanja domena klijenata.

Pohranite atomsku snimku i izložite naredbu za uvoz

Za slobodnog stručnjaka ili mali tim koji pokreće jednu instancu aplikacije, JSON snimke koje je moguće verzionirati lako je pregledati i sigurnosno kopirati. Izradite src/Brand/BrandKitStore.php:

<?php

namespace App\Brand;

final class BrandKitStore
{
    public function __construct(private string $brandKitStorageDir) {}

    public function save(string $client, BrandKit $kit): void
    {
        $path = $this->path($client);

        if (!is_dir($this->brandKitStorageDir)
            && !mkdir($this->brandKitStorageDir, 0770, true)
            && !is_dir($this->brandKitStorageDir)) {
            throw new \RuntimeException('Cannot create brand-kit storage.');
        }

        $temporary = tempnam($this->brandKitStorageDir, '.brand-kit-');
        if ($temporary === false) {
            throw new \RuntimeException('Cannot create a temporary snapshot.');
        }

        try {
            $json = json_encode(
                $kit->toArray(),
                JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES,
            );

            if (file_put_contents($temporary, $json, LOCK_EX) === false) {
                throw new \RuntimeException('Cannot write the brand-kit snapshot.');
            }

            chmod($temporary, 0640);

            if (!rename($temporary, $path)) {
                throw new \RuntimeException('Cannot publish the brand-kit snapshot.');
            }
        } finally {
            if (is_file($temporary)) {
                unlink($temporary);
            }
        }
    }

    public function load(string $client): BrandKit
    {
        $json = file_get_contents($this->path($client));
        if ($json === false) {
            throw new \RuntimeException('No imported brand kit exists.');
        }

        return BrandKit::fromApi(
            json_decode($json, true, 64, JSON_THROW_ON_ERROR),
        );
    }

    public function has(string $client): bool
    {
        return is_file($this->path($client));
    }

    private function path(string $client): string
    {
        if (preg_match('/^[a-z0-9][a-z0-9-]{1,63}$/', $client) !== 1) {
            throw new \InvalidArgumentException('Invalid client identifier.');
        }

        return $this->brandKitStorageDir.'/'.$client.'.json';
    }
}

Ponovna provjera snimki pri učitavanju štiti renderer od ručnih izmjena i oštećenih postavljanja. Privremena datoteka nalazi se u odredišnom direktoriju, čime završno preimenovanje ostaje atomsko u uobičajenom Linux postavljanju.

Izradite src/Command/ImportBrandKitCommand.php:

<?php

namespace App\Command;

use App\Brand\BrandKitException;
use App\Brand\BrandKitExtractor;
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;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'app:brand-kit:import', description: 'Import a verified brand snapshot.')]
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, 'Stable lowercase client identifier')
            ->addArgument('website', InputArgument::REQUIRED, 'Public brand website URL');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $client = (string) $input->getArgument('client');
        $website = (string) $input->getArgument('website');

        try {
            $kit = $this->extractor->extract($website);
            $this->store->save($client, $kit);
        } catch (BrandKitException|\InvalidArgumentException|\RuntimeException $e) {
            $kind = $e instanceof BrandKitException ? $e->kind : 'storage';
            $io->error("Import failed ({$kind}): {$e->getMessage()}");

            return Command::FAILURE;
        }

        $io->success("Imported the brand kit for {$kit->name}.");

        return Command::SUCCESS;
    }
}

Pokrenite uvoz naredbom:

php bin/console app:brand-kit:import acme https://www.example.com

Neuspješno osvježavanje ostavlja prethodnu snimku netaknutom. To je svojstvo važnije od pokušaja da renderiranje ponuda bude “uživo”.

Upotrijebite uvezene materijale u generatoru ponuda

Izradite laganu krajnju točku za renderiranje u src/Controller/ProposalController.php:

<?php

namespace App\Controller;

use App\Brand\BrandKitStore;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ProposalController extends AbstractController
{
    #[Route(
        '/proposals/{client}',
        name: 'proposal_show',
        requirements: ['client' => '[a-z0-9][a-z0-9-]{1,63}'],
        methods: ['GET'],
    )]
    public function show(string $client, Request $request, BrandKitStore $store): Response
    {
        if (!$store->has($client)) {
            throw $this->createNotFoundException('Import this client brand first.');
        }

        return $this->render('proposal/show.html.twig', [
            'brand' => $store->load($client),
            'subject' => $request->query->getString(
                'subject',
                'Services proposal',
            ),
        ]);
    }
}

Odgovarajući templates/proposal/show.html.twig prikazuje integracijsku točku koju koriste i HTML ponude i kasniji PDF proces:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>{{ subject }} — {{ brand.name }}</title>
    <style>
        :root {
        {% for name, value in brand.cssVariables %}
            {{ name }}: {{ value }};
        {% endfor %}
        }
        body { font-family: system-ui, sans-serif; margin: 3rem; }
        header { border-bottom: 3px solid var(--primary-color, #222); }
        img { display: block; max-height: 72px; max-width: 240px; }
    </style>
</head>
<body>
    <header>
        {% if brand.primaryLogoUrl %}
            <img src="{{ brand.primaryLogoUrl }}" alt="{{ brand.name }} logo">
        {% endif %}
        <h1>{{ subject }}</h1>
        <p>Prepared for {{ brand.name }}</p>
    </header>
    <main>
        <h2>Scope</h2>
        <p>Replace this section with proposal or report data from your domain.</p>
    </main>
</body>
</html>

Twig escapira naziv, predmet i URL logotipa. CSS vrijednosti dobivaju dodatnu provjeru domene jer samo HTML escapiranje ne čini proizvoljni tekst sigurnim u CSS kontekstu. Fontovi se zadržavaju u snimci, ali se ne preuzimaju automatski; fontovi smješteni na vanjskim poslužiteljima mogu uzrokovati probleme s licenciranjem, privatnošću, dostupnošću i performansama.

Testirajte granicu bez mrežnih poziva

Symfonyjev MockHttpClient pruža deterministički transport. Sljedeći testovi provjeravaju zahtjev, uspješno preslikavanje i ključno pravilo da se neuspjesi autentikacije ne ponavljaju. Dodajte tests/Brand/BrandKitExtractorTest.php:

<?php

namespace App\Tests\Brand;

use App\Brand\BrandKitException;
use App\Brand\BrandKitExtractor;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class BrandKitExtractorTest extends TestCase
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit';

    public function testItMapsAValidResponse(): void
    {
        $client = new MockHttpClient(function (
            string $method,
            string $url,
            array $options,
        ): MockResponse {
            self::assertSame('POST', $method);
            self::assertSame(self::ENDPOINT, $url);
            self::assertSame(
                ['url' => 'https://example.com'],
                json_decode($options['body'], true, flags: JSON_THROW_ON_ERROR),
            );

            return new MockResponse(json_encode([
                'brand_name' => 'Example Studio',
                'logos' => [['url' => 'https://example.com/logo.svg']],
                'colors' => ['#16324f'],
                'fonts' => ['Example Sans'],
                'imagery' => [],
                'social_profiles' => [],
                'css_variables' => ['--primary-color' => '#16324f'],
            ], JSON_THROW_ON_ERROR), ['http_code' => 200]);
        });

        $extractor = new BrandKitExtractor(
            $client,
            new NullLogger(),
            self::ENDPOINT,
            'test-token',
        );

        $kit = $extractor->extract('https://example.com');

        self::assertSame('Example Studio', $kit->name);
        self::assertSame('https://example.com/logo.svg', $kit->primaryLogoUrl());
    }

    public function testAuthenticationFailureIsNotRetried(): void
    {
        $client = new MockHttpClient([
            new MockResponse('{}', ['http_code' => 401]),
        ]);

        $extractor = new BrandKitExtractor(
            $client,
            new NullLogger(),
            self::ENDPOINT,
            'invalid-test-token',
        );

        try {
            $extractor->extract('https://example.com');
            self::fail('Expected an authentication failure.');
        } catch (BrandKitException $e) {
            self::assertSame('authentication', $e->kind);
            self::assertSame(401, $e->status);
        }

        self::assertSame(1, $client->getRequestsCount());
    }
}

Zadržite sintetičke fixtureove, a vjerodajnice neka budu očito lažne. Dodajte testove za neispravan JSON, duplicirana semantička polja, nesiguran CSS, prevelike odgovore, završni 429 i zamjenu pohrane prije nego što uvoznik smatrate dovršenom produkcijskom značajkom.

Sigurnost, nadzornik rada i postavljanje

Zadržite .env.local samo za lokalni razvoj. U produkciji ubrizgajte BRAND_KIT_TOKEN putem upravitelja tajnama platforme ili Symfonyjeva mehanizma za tajne. Ograničite pristup naredbi za uvoz na pouzdane operatore, promišljeno rotirajte token i osigurajte da zapisnici nikada ne sadrže autorizacijska zaglavlja ni tijela odgovora.

Provjera unosa odbacuje vjerodajnice ugrađene u URL-ove, ali produkcijska postavljanja trebala bi također odbaciti localhost, literale privatnih adresa i domene dostupne samo interno, u skladu s vlastitom mrežnom politikom. Udaljena usluga namijenjena je javnim web-stranicama; nemojte je upotrebljavati kao put do internih resursa.

Strukturirana polja zapisa čine mjerljivima trajanje, status, pokušaje i učestalost ponavljanja. Postavite upozorenja za trajne neuspjehe autentikacije, ograničavanje brzine, neispravne odgovore i neuobičajeno veliku latenciju. Jedan povremeni ponovljeni pokušaj operativni je detalj; ponovljeni završni neuspjesi upućuju na istekao token, ograničenje plana, promijenjeni ugovor odgovora, problem s mrežom ili nedostupnu uzvodnu uslugu.

Dajte korisniku tijekom izvođenja pristup za pisanje u var/brand-kits, pokrenite testove, zagrijte produkcijsku predmemoriju i izvedite jedan uvoz:

APP_ENV=test php bin/phpunit

APP_ENV=prod APP_DEBUG=0 php bin/console cache:warmup
APP_ENV=prod APP_DEBUG=0 php bin/console app:brand-kit:import acme https://www.example.com

Lokalne datoteke dobro funkcioniraju na jednom poslužitelju. Više replika zahtijeva zajedničku trajnu pohranu ili repozitorij temeljen na bazi podataka; u suprotnom jedna replika može uvesti snimku koju druga ne može vidjeti. Pri promjeni pohrane zadržite isto sučelje BrandKitStore kako bi ekstraktor i renderer ostali netaknuti.

Uobičajeni neuspjesi i njihovo značenje

  • 401 ili 403: token nedostaje, neispravan je, opozvan je ili nije dostupan tijekom izvođenja. Ispravite konfiguraciju; nemojte ponavljati pokušaj.
  • 400 ili 422: poslani URL nije prihvatljiv. Ispravite URL umjesto da ga ponavljate.
  • 429: aktivni plan ili usluga ograničava zahtjeve. Poštujte ograničeno odgađanje i zakažite osvježavanje za kasnije.
  • 500, 502, 503 ili 504: privremeni problem uzvodne usluge može opravdati već implementirane ograničene ponovljene pokušaje.
  • Neispravan odgovor: zadržite posljednju dobru snimku, usporedite trenutačnu službenu dokumentaciju s preslikavačem i ažurirajte testove prije prihvaćanja promijenjenog oblika.
  • Ponuda nema logotip: odgovor možda ne sadrži upotrebljiv HTTPS logotip. Prikažite tekstualnu zamjenu umjesto da oslabljujete provjeru URL-a.
  • Snimka se ne može zapisati: provjerite vlasništvo nad direktorijem, dozvole, konfiguraciju trajnog volumena i slobodan prostor na disku.

Završni kontrolni popis za provjeru

  • Račun i Free, Plus ili Pro plan su aktivni.
  • Token ograničen na uslugu dolazi iz ploče Service token na stranici dokumentacije.
  • BRAND_KIT_TOKEN ubrizgava se putem konfiguracije koja koristi varijable okruženja te nije prisutan u Gitu ni zapisnicima.
  • Aplikacija šalje POST na točnu krajnju točku za izdvajanje s JSON poljem url.
  • Naziv brenda, logotipi, boje, fontovi, slike, profili na društvenim mrežama i CSS varijable provjeravaju se prije pohrane.
  • Neuspjesi autentikacije i provjere ne ponavljaju se; prolazni neuspjesi koriste ograničeno odgađanje.
  • Neuspješno osvježavanje čuva posljednju valjanu snimku.
  • Putanja ponude sigurno se renderira kada nedostaje logotip.
  • Testovi koriste MockHttpClient i nikada ne kontaktiraju stvarnu uslugu.
  • Produkcijska pohrana omogućuje pisanje, trajna je, ima sigurnosnu kopiju i dijeli se kada aplikacija ima više replika.

Trajna arhitektonska prednost nije automatsko postavljanje logotipa. To je razdvajanje pribavljanja i objavljivanja: nepouzdana vanjska operacija proizvodi malu, provjerenu snimku, dok svakodnevni generator ponuda koristi samo lokalne, provjerene podatke. Ta granica čini brendiranje praktičnim bez dopuštanja da vjerodajnice, neispravni materijali, kvote ili prekid rada uzvodne usluge određuju može li se ponuda isporučiti.

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.