Tutorials

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

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

A proposal generator becomes much more useful when “make it match the client’s brand” stops being a manual checklist. The difficult part is not placing a logo above a heading. It is acquiring the right assets, preserving their provenance, rejecting malformed data, and ensuring an upstream outage cannot break proposal generation.

This tutorial builds that boundary in Symfony and PHP 8.3. A console command sends a public website URL to the Brand Kit Extractor API, maps the response into a validated domain object, and stores an atomic snapshot for an ordinary proposal and report renderer. The rendering path never calls the API, so previously imported brands remain available during outages or quota exhaustion.

Get access and create a service token

Start by registering an account, or use the sign-in page if you already have one.

  1. Open the Brand Kit Extractor service page.
  2. Choose an available Free, Plus, or Pro plan and complete its activation.
  3. Open the official service documentation.
  4. Find the Service token panel and copy the service-scoped token shown there.
  5. Store that token in environment-backed project configuration. Never commit it to the repository.

Regenerating the service token revokes the previously active token, so token rotation must update every deployed environment that uses it. This service does require authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. We will use a Bearer token because query parameters are more likely to appear in access logs and monitoring systems.

Confirm the endpoint before writing application code

The exact call is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON body contains 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

Review the live documentation and the returned payload during integration. The application boundary below deliberately avoids assuming an undocumented response envelope. It locates the documented semantic fields after normalizing names such as brand_name and brandName, then requires one unambiguous occurrence of each.

Place the credential in .env.local, which Symfony projects normally exclude from version control:

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

Choose a deliberately small architecture

The project needs PHP 8.3 or newer, Composer, Symfony’s HttpClient and Console components, Twig, and PHPUnit support. Create the application with:

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

The important files are:

  • src/Brand/BrandKit.php — domain mapping and validation.
  • src/Brand/BrandKitException.php — structured failures.
  • src/Brand/BrandKitExtractor.php — the HTTP boundary.
  • src/Brand/BrandKitStore.php — atomic local snapshots.
  • src/Command/ImportBrandKitCommand.php — an operator-facing import.
  • src/Controller/ProposalController.php — proposal rendering.
  • templates/proposal/show.html.twig — the branded output.

This design keeps imports synchronous and explicit. A typical brand extraction is an occasional setup or refresh operation, not part of every page request. Messenger would add operational machinery without improving this workflow. If imports later arrive through a customer-facing form, the same extractor can be called from a Messenger handler.

Bind environment values through dependency injection in 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/'

Build a defensive domain boundary

The API provides evidence-based brand data, but external JSON is still untrusted input. Before storage, require a brand name plus logos, colors, fonts, imagery, social profiles, and CSS variables. Reject duplicate semantic fields, excessive payloads, dangerous CSS fragments, and malformed asset URLs.

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

The mapper does not claim that every color, font, or image is safe to render automatically. It establishes a bounded, JSON-safe snapshot. The proposal chooses an HTTPS logo and a restricted CSS-variable map; the remaining evidence stays available for reviewed templates and reports.

Call the API with bounded failure behavior

Create a failure type in 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);
    }
}

Then implement src/Brand/BrandKitExtractor.php. It uses three total attempts, bounded exponential backoff, a five-second connection timeout, and a thirty-second overall request duration. Authentication and request-validation failures are never retried.

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

No response body, token, full website URL, or extracted asset is logged. A stable hash of the hostname is enough to correlate retries without exposing customer domains casually.

Store an atomic snapshot and expose an import command

For a freelancer or small team running one application instance, versionable JSON snapshots are easy to inspect and back up. Create 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';
    }
}

Revalidating snapshots on load protects the renderer from manual edits and corrupted deployments. The temporary file lives in the destination directory, allowing the final rename to remain atomic on a conventional Linux deployment.

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

Run an import with:

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

An unsuccessful refresh leaves the previous snapshot untouched. That property matters more than trying to make proposal rendering “live.”

Use the imported assets in the proposal generator

Create a lightweight rendering endpoint in 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',
            ),
        ]);
    }
}

The corresponding templates/proposal/show.html.twig demonstrates the integration point used by both HTML proposals and a later PDF pipeline:

<!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 escapes the name, subject, and logo URL. CSS values receive additional domain validation because HTML escaping alone does not make arbitrary text safe in a CSS context. Fonts are retained in the snapshot but are not downloaded automatically; externally hosted fonts can introduce licensing, privacy, availability, and performance concerns.

Test the boundary without making network calls

Symfony’s MockHttpClient provides a deterministic transport. The following tests verify the request, successful mapping, and the critical rule that authentication failures are not retried. Add 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());
    }
}

Keep fixtures synthetic and credentials obviously fake. Add tests for malformed JSON, duplicated semantic fields, unsafe CSS, oversized responses, a terminal 429, and storage replacement before treating the importer as a finished production feature.

Security, observability, and deployment

Keep .env.local for local development only. In production, inject BRAND_KIT_TOKEN through the platform’s secret manager or Symfony’s secrets facility. Restrict import-command access to trusted operators, rotate the token deliberately, and ensure logs never contain authorization headers or response bodies.

The input validator rejects credentials embedded in URLs, but production deployments should also reject localhost, private-address literals, and internal-only domains according to their own network policy. The remote service is intended for public websites; do not use it as a route to internal resources.

The structured log fields make duration, status, attempts, and retry frequency measurable. Alert on sustained authentication failures, rate limiting, invalid responses, and unusually high latency. A single intermittent retry is operational detail; repeated final failures indicate an expired token, plan limitation, changed response contract, networking problem, or unavailable upstream.

Give the runtime user write access to var/brand-kits, run the tests, warm the production cache, and perform one import:

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

Local files work well on one server. Multiple replicas need shared persistent storage or a database-backed repository; otherwise one replica may import a snapshot that another cannot see. Preserve the same BrandKitStore interface when changing storage so the extractor and renderer remain untouched.

Common failures and their meaning

  • 401 or 403: the token is missing, invalid, revoked, or unavailable to the runtime. Correct configuration; do not retry.
  • 400 or 422: the submitted URL is unacceptable. Correct the URL rather than repeating it.
  • 429: the active plan or service is limiting requests. Honor bounded backoff and schedule the refresh later.
  • 500, 502, 503, or 504: a temporary upstream problem may justify the limited retries already implemented.
  • Invalid response: keep the last good snapshot, compare the current official documentation with the mapper, and update tests before accepting a changed shape.
  • Proposal has no logo: the response may not contain a usable HTTPS logo. Render the text fallback instead of weakening URL validation.
  • Snapshot cannot be written: verify directory ownership, permissions, persistent volume configuration, and free disk space.

Final verification checklist

  • The account and Free, Plus, or Pro plan are active.
  • The service-scoped token comes from the documentation page’s Service token panel.
  • BRAND_KIT_TOKEN is injected through environment-backed configuration and absent from Git and logs.
  • The application sends POST to the exact extraction endpoint with a JSON url.
  • Brand name, logos, colors, fonts, imagery, social profiles, and CSS variables are validated before storage.
  • Authentication and validation failures are not retried; transient failures use bounded backoff.
  • A failed refresh preserves the last valid snapshot.
  • The proposal route renders safely when a logo is missing.
  • Tests use MockHttpClient and never contact the real service.
  • Production storage is writable, persistent, backed up, and shared when the application has multiple replicas.

The lasting architectural win is not automatic logo placement. It is the separation between acquisition and publication: an unreliable external operation produces a small, verified snapshot, while the everyday proposal generator consumes only local, validated data. That boundary keeps branding convenient without allowing credentials, malformed assets, quotas, or upstream downtime to dictate whether a proposal can be delivered.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.