Vodiči

PHP 8.3: Integrate Brand Assets into Proposals with the Brand Kit Extractor API

PHP 8.3: Integrirajte resurse robne marke u prijedloge pomoću API-ja Brand Kit Extractor

Generator ponuda postaje doista koristan kada brendiranje prestane tretirati kao vježbu kopiranja i lijepljenja u posljednji trenutak. Na temelju web-mjesta korisnika ovaj projekt jednom uvozi njegov vizualni identitet, provjerava ga na granici aplikacije, pohranjuje nepromjenjivu snimku i koristi siguran podskup pri prikazivanju ponuda i izvještaja.

Implementacija koristi PHP 8.3, izvorni cURL, vjerodajnice iz okruženja, ograničeni broj ponovnih pokušaja, atomsku pohranu i PHPUnit s determinističkim lažnim transportom. Brand Kit Extractor ostaje iza namjenske API granice, tako da promjene prezentacije ili postojanosti ne prodiru u mrežni kod.

Pribavite pristup prije pisanja integracijskog koda

  1. Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
  2. Otvorite stranicu usluge Brand Kit Extractor.
  3. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  4. Otvorite službenu dokumentaciju usluge.
  5. Pronađite ploču Service token i kopirajte token ograničen na uslugu.

Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo oblik Bearer jer je vjerojatnije da će se vjerodajnice u nizovima upita pojaviti u zapisima proxyja i pristupa.

Ponovno generiranje tokena usluge opoziva prethodno aktivni token. Rotaciju tretirajte kao operaciju implementacije: ažurirajte tajnu, implementirajte ili ponovno učitajte svaki proces koji je koristi, provjerite ekstrakciju i tek tada rotaciju smatrajte dovršenom.

Potvrdite krajnju točku minimalnim zahtjevom

Točna je operacija POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Njezin JSON zahtjev sadrži url.

export BRAND_KIT_TOKEN='YOUR_SERVICE_TOKEN'

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

Prije izrade značajke stvorite nepotvrđenu datoteku .env.local:

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

Dodajte .env.local, generirane snimke brenda i izlaz ponuda u .gitignore. Izvorni PHP ne čita automatski dotenv datoteke, stoga ih lokalne naredbe u nastavku izvoze u procesno okruženje. Produkcija bi trebala umetnuti istu varijablu putem svojeg upravitelja tajnama, izvršnog okruženja spremnika ili upravitelja usluga.

Arhitektura: uvezite jednom, prikazujte ponavljano

Pozivanje vanjskog ekstraktora dok netko čeka PDF ili pretpregled ponude stvara nepotrebnu povezanost. Umjesto toga, eksplicitna naredba za uvoz poziva API i sprema verzioniranu JSON snimku. Generator izvještaja čita tu lokalnu snimku bez pristupa mreži.

Rezultirajuća je granica mala:

  • CurlTransport upravlja HTTP mehanikom i vremenskim ograničenjima.
  • BrandKitClient upravlja autentikacijom, ponovnim pokušajima, dekodiranjem odgovora i klasifikacijom neuspjeha.
  • BrandKit mapira i provjerava odgovor domene prije pohrane.
  • BrandKitRepository atomski zapisuje snimke.
  • ProposalRenderer koristi samo vrijednosti sigurne za prezentaciju.

„Provjereno” ovdje znači da je snimka došla iz determinističke usluge ekstrakcije utemeljene na dokazima i prošla naše strukturne i sigurnosne provjere. To ne utvrđuje vlasništvo nad žigom niti daje dopuštenje za upotrebu imovine druge organizacije.

Struktura projekta i ovisnosti

brand-proposals/
├── bin/
│   ├── import-brand-kit
│   └── generate-proposal
├── src/
│   ├── Http.php
│   ├── BrandKit.php
│   └── Proposal.php
├── tests/
│   └── BrandKitClientTest.php
├── var/
│   ├── brand-kits/
│   └── proposals/
├── composer.json
├── .env.local
└── .gitignore

Upotrijebite PHP 8.3 s proširenjima cURL i JSON. PHPUnit 11.5 jedini je paket treće strane i koristi se samo za razvoj.

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.5"
  },
  "autoload": {
    "psr-4": {
      "ProposalApp\\": "src/"
    }
  },
  "scripts": {
    "test": "phpunit tests"
  }
}
composer install
set -a
. ./.env.local
set +a

Izgradite ograničeni izvorni cURL transport

Transport izvodi jedan pokušaj. Pravilo ponovnih pokušaja pripada klijentu usluge, gdje su dostupni HTTP status i značenje aplikacije.

<?php
// src/Http.php
declare(strict_types=1);

namespace ProposalApp;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

interface Transport
{
    public function postJson(
        string $url,
        array $headers,
        array $payload,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse;
}

final class TransportFailure extends \RuntimeException {}

final class CurlTransport implements Transport
{
    public function postJson(
        string $url,
        array $headers,
        array $payload,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        $handle = curl_init($url);
        if ($handle === false) {
            throw new TransportFailure('Unable to initialize cURL');
        }

        $headerLines = [];
        foreach ($headers as $name => $value) {
            $headerLines[] = $name . ': ' . $value;
        }

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
            CURLOPT_HTTPHEADER => $headerLines,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs,
            CURLOPT_TIMEOUT_MS => $timeoutMs,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
        ]);

        $raw = curl_exec($handle);
        if ($raw === false) {
            $message = curl_error($handle);
            curl_close($handle);
            throw new TransportFailure($message);
        }

        $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        $headerSize = (int) curl_getinfo($handle, CURLINFO_HEADER_SIZE);
        curl_close($handle);

        $parsedHeaders = [];
        foreach (preg_split('/\r\n|\r|\n/', substr($raw, 0, $headerSize)) as $line) {
            if (str_contains($line, ':')) {
                [$name, $value] = explode(':', $line, 2);
                $parsedHeaders[strtolower(trim($name))] = trim($value);
            }
        }

        return new HttpResponse(
            $status,
            $parsedHeaders,
            substr($raw, $headerSize),
        );
    }
}

Vremensko ograničenje povezivanja od pet sekundi i ukupno vremensko ograničenje od 30 sekundi, upotrijebljeni u nastavku, namjerno su konačni. Prilagodite ih na temelju promatrane produkcijske latencije, ali nikada nemojte dopustiti radnicima za ponude da čekaju neograničeno.

Mapirajte odgovor u strogi objekt domene

Aplikacija mora prije pohrane provjeriti vraćeni naziv brenda, logotipe, boje, fontove, slike, društvene profile i CSS varijable. Mapper odbacuje nedostajuća polja, neispravno oblikovane kolekcije, prekomjerno ugnježđivanje, prevelike nizove i URI sheme koje nikada ne bi smjele doći do renderer-a.

<?php
// src/BrandKit.php
declare(strict_types=1);

namespace ProposalApp;

final readonly class BrandKit implements \JsonSerializable
{
    public function __construct(
        public string $brandName,
        public array $logos,
        public array $colors,
        public array $fonts,
        public array $imagery,
        public array $socialProfiles,
        public array $cssVariables,
    ) {}

    public static function fromApi(array $data): self
    {
        $keys = [
            'brand_name', 'logos', 'colors', 'fonts', 'imagery',
            'social_profiles', 'css_variables',
        ];

        foreach ($keys as $key) {
            if (!array_key_exists($key, $data)) {
                throw new \UnexpectedValueException("Missing response field: {$key}");
            }
        }

        $name = trim(is_string($data['brand_name']) ? $data['brand_name'] : '');
        if ($name === '' || strlen($name) > 200) {
            throw new \UnexpectedValueException('Invalid brand_name');
        }

        foreach (array_slice($keys, 1) as $key) {
            if (!is_array($data[$key])) {
                throw new \UnexpectedValueException("{$key} must be a collection");
            }
            self::validateTree($data[$key], $key);
        }

        foreach (['logos', 'imagery', 'social_profiles'] as $key) {
            self::rejectUnsafeUris($data[$key], $key);
        }

        return new self(
            $name,
            $data['logos'],
            $data['colors'],
            $data['fonts'],
            $data['imagery'],
            $data['social_profiles'],
            $data['css_variables'],
        );
    }

    private static function validateTree(mixed $value, string $path, int $depth = 0): void
    {
        if ($depth > 8) {
            throw new \UnexpectedValueException("Excessive nesting at {$path}");
        }
        if (is_string($value) && strlen($value) > 4096) {
            throw new \UnexpectedValueException("Oversized string at {$path}");
        }
        if (is_array($value)) {
            if (count($value) > 500) {
                throw new \UnexpectedValueException("Oversized collection at {$path}");
            }
            foreach ($value as $key => $child) {
                self::validateTree($child, $path . '.' . $key, $depth + 1);
            }
        }
    }

    private static function rejectUnsafeUris(mixed $value, string $path): void
    {
        if (is_string($value)
            && preg_match('/^[a-z][a-z0-9+.-]*:/i', $value)
            && !preg_match('/^https?:\/\//i', $value)
        ) {
            throw new \UnexpectedValueException("Unsafe URI at {$path}");
        }

        if (is_array($value)) {
            foreach ($value as $key => $child) {
                self::rejectUnsafeUris($child, $path . '.' . $key);
            }
        }
    }

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

final class ApiFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly ?int $httpStatus = null,
        string $message = 'Brand extraction failed',
    ) {
        parent::__construct($message);
    }
}

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

    public function __construct(
        private readonly Transport $transport,
        private readonly string $token,
        private readonly mixed $sleeper = null,
    ) {}

    public function extract(string $website): BrandKit
    {
        $parts = parse_url($website);
        if (($parts['scheme'] ?? null) !== 'https' || empty($parts['host'])) {
            throw new \InvalidArgumentException('A public HTTPS website URL is required');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::ENDPOINT,
                    [
                        'Authorization' => 'Bearer ' . $this->token,
                        'Accept' => 'application/json',
                        'Content-Type' => 'application/json',
                    ],
                    ['url' => $website],
                    5000,
                    30000,
                );
            } catch (TransportFailure $exception) {
                $this->log('transport_failure', null, $attempt);
                if ($attempt === 3) {
                    throw new ApiFailure('transport_error', null, $exception->getMessage());
                }
                $this->pause(200 * (2 ** ($attempt - 1)));
                continue;
            }

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $decoded = json_decode($response->body, true, 64, JSON_THROW_ON_ERROR);
                    if (!is_array($decoded)) {
                        throw new \UnexpectedValueException('JSON root must be an object');
                    }
                    return BrandKit::fromApi($decoded);
                } catch (\JsonException|\UnexpectedValueException $exception) {
                    throw new ApiFailure('invalid_response', $response->status, $exception->getMessage());
                }
            }

            if (in_array($response->status, [401, 403], true)) {
                throw new ApiFailure('authentication_failed', $response->status);
            }

            if (in_array($response->status, [400, 404, 422], true)) {
                throw new ApiFailure('request_rejected', $response->status);
            }

            $retryable = in_array($response->status, [429, 502, 503, 504], true);
            $this->log($response->status === 429 ? 'rate_limited' : 'api_failure',
                $response->status, $attempt);

            if (!$retryable || $attempt === 3) {
                throw new ApiFailure(
                    $response->status === 429 ? 'rate_limited' : 'upstream_error',
                    $response->status,
                );
            }

            $retryAfter = $response->headers['retry-after'] ?? null;
            $delay = ctype_digit((string) $retryAfter)
                ? min(2000, (int) $retryAfter * 1000)
                : 200 * (2 ** ($attempt - 1));
            $this->pause($delay);
        }

        throw new ApiFailure('upstream_error');
    }

    private function pause(int $milliseconds): void
    {
        if (is_callable($this->sleeper)) {
            ($this->sleeper)($milliseconds);
            return;
        }
        usleep($milliseconds * 1000);
    }

    private function log(string $event, ?int $status, int $attempt): void
    {
        error_log(json_encode([
            'event' => 'brand_kit.' . $event,
            'status' => $status,
            'attempt' => $attempt,
        ], JSON_THROW_ON_ERROR));
    }
}

Greške provjere i autentikacije ne pokušavaju se ponovno. Mrežni kvarovi, odgovori o kvoti i odabrani prolazni kvarovi pristupnika dobivaju najviše tri pokušaja s ograničenim odmakom. Zapisivač bilježi klasifikaciju, status i pokušaj, ali nikada token, tijelo odgovora ni URL korisnika.

Pohranite atomski i prikazujte konzervativno

Zadržite potpunu provjerenu snimku za reviziju, ali nemojte ubrizgavati proizvoljni izdvojeni CSS u dokument. Ovaj renderer traži običnu heksadecimalnu boju i HTTPS logotip, izbjegava tekst namijenjen korisniku i u suprotnom se sigurno vraća na zadanu vrijednost.

<?php
// src/Proposal.php
declare(strict_types=1);

namespace ProposalApp;

final class BrandKitRepository
{
    public function __construct(private readonly string $directory) {}

    public function save(string $slug, string $sourceUrl, BrandKit $kit): void
    {
        $path = $this->path($slug);
        if (!is_dir($this->directory)
            && !mkdir($this->directory, 0700, true)
            && !is_dir($this->directory)
        ) {
            throw new \RuntimeException('Cannot create brand-kit directory');
        }

        $document = [
            'schema_version' => 1,
            'source_url' => $sourceUrl,
            'fetched_at' => gmdate(DATE_ATOM),
            'brand_kit' => $kit,
        ];

        $temporary = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
        $json = json_encode($document, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
        if (file_put_contents($temporary, $json, LOCK_EX) === false) {
            throw new \RuntimeException('Cannot write brand-kit snapshot');
        }
        chmod($temporary, 0600);
        if (!rename($temporary, $path)) {
            unlink($temporary);
            throw new \RuntimeException('Cannot publish brand-kit snapshot');
        }
    }

    public function load(string $slug): BrandKit
    {
        $document = json_decode(
            file_get_contents($this->path($slug)) ?: '',
            true,
            64,
            JSON_THROW_ON_ERROR,
        );
        return BrandKit::fromApi($document['brand_kit'] ?? []);
    }

    private function path(string $slug): string
    {
        if (!preg_match('/^[a-z0-9][a-z0-9-]{0,62}$/', $slug)) {
            throw new \InvalidArgumentException('Invalid customer slug');
        }
        return $this->directory . '/' . $slug . '.json';
    }
}

final class ProposalRenderer
{
    public function render(BrandKit $kit, string $subject, string $summary): string
    {
        $color = $this->firstMatch($kit->colors, '/^#[0-9a-f]{6}$/i') ?? '#243447';
        $logo = $this->firstMatch($kit->logos, '/^https:\/\//i');
        $escape = static fn(string $value): string =>
            htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

        $logoHtml = $logo === null ? '' :
            '<img src="' . $escape($logo) . '" alt="" style="max-height:64px">';

        return '<!doctype html><html><body>'
            . $logoHtml
            . '<h1 style="color:' . $escape($color) . '">'
            . $escape($kit->brandName) . ': ' . $escape($subject)
            . '</h1><p>' . $escape($summary) . '</p>'
            . '</body></html>';
    }

    private function firstMatch(array $values, string $pattern): ?string
    {
        foreach ($values as $value) {
            if (is_string($value) && preg_match($pattern, $value)) {
                return $value;
            }
            if (is_array($value) && ($found = $this->firstMatch($value, $pattern))) {
                return $found;
            }
        }
        return null;
    }
}

Povežite dvije svakodnevne naredbe

<?php
// bin/import-brand-kit
declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

use ProposalApp\{BrandKitClient, BrandKitRepository, CurlTransport};

[$script, $slug, $website] = array_pad($argv, 3, null);
$token = getenv('BRAND_KIT_TOKEN');

if (!$slug || !$website || !is_string($token) || $token === '') {
    fwrite(STDERR, "Usage: BRAND_KIT_TOKEN=... php {$script} SLUG HTTPS_URL\n");
    exit(2);
}

try {
    $kit = (new BrandKitClient(new CurlTransport(), $token))->extract($website);
    (new BrandKitRepository(dirname(__DIR__) . '/var/brand-kits'))
        ->save($slug, $website, $kit);
    fwrite(STDOUT, "Imported {$kit->brandName}\n");
} catch (Throwable $exception) {
    fwrite(STDERR, get_class($exception) . ': ' . $exception->getMessage() . "\n");
    exit(1);
}
<?php
// bin/generate-proposal
declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload.php';

use ProposalApp\{BrandKitRepository, ProposalRenderer};

[$script, $slug] = array_pad($argv, 2, null);
if (!$slug) {
    fwrite(STDERR, "Usage: php {$script} SLUG\n");
    exit(2);
}

$root = dirname(__DIR__);
$kit = (new BrandKitRepository($root . '/var/brand-kits'))->load($slug);
$html = (new ProposalRenderer())->render(
    $kit,
    'Website delivery proposal',
    'Scope, milestones, responsibilities, and commercial terms.',
);

if (!is_dir($root . '/var/proposals')) {
    mkdir($root . '/var/proposals', 0700, true);
}
file_put_contents($root . '/var/proposals/' . $slug . '.html', $html, LOCK_EX);

Testirajte ponovne pokušaje i granice neuspjeha bez mreže

Lažni transport isporučuje nepromjenjivi red odgovora. Testovi se stoga izvode brzo i nikada ne troše kvotu niti ovise o dostupnosti usluge.

<?php
// tests/BrandKitClientTest.php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use ProposalApp\{ApiFailure, BrandKitClient, HttpResponse, Transport};

final class FakeTransport implements Transport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function postJson(
        string $url,
        array $headers,
        array $payload,
        int $connectTimeoutMs,
        int $timeoutMs,
    ): HttpResponse {
        $this->calls++;
        return array_shift($this->responses);
    }
}

final class BrandKitClientTest extends TestCase
{
    private function validBody(): string
    {
        return json_encode([
            'brand_name' => 'Example Studio',
            'logos' => ['https://example.com/logo.svg'],
            'colors' => ['#18324A'],
            'fonts' => ['Inter'],
            'imagery' => [],
            'social_profiles' => [],
            'css_variables' => ['--brand-primary' => '#18324A'],
        ], JSON_THROW_ON_ERROR);
    }

    public function testRetriesRateLimitThenMapsResponse(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(429, ['retry-after' => '0'], ''),
            new HttpResponse(200, [], $this->validBody()),
        ]);

        $kit = (new BrandKitClient($transport, 'test-token', static fn(int $ms) => null))
            ->extract('https://example.com');

        self::assertSame('Example Studio', $kit->brandName);
        self::assertSame(2, $transport->calls);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $transport = new FakeTransport([new HttpResponse(401, [], '')]);

        try {
            (new BrandKitClient($transport, 'invalid', static fn(int $ms) => null))
                ->extract('https://example.com');
            self::fail('Expected ApiFailure');
        } catch (ApiFailure $failure) {
            self::assertSame('authentication_failed', $failure->kind);
            self::assertSame(1, $transport->calls);
        }
    }
}
composer test
php bin/import-brand-kit example https://example.com
php bin/generate-proposal example

Produkcijsko učvršćivanje i česti neuspjesi

Pokrećite uvoze kao kontrolirane zadatke ili zakazane naredbe, a ne unutar zahtjeva za javno prikazivanje. Dopustite samo ovlaštene domene korisnika, ograničite istodobnost i primijenite vlastito ograničenje stope po računu. Iako ova aplikacija sama ne dohvaća poslano web-mjesto, ograničavanje unosa smanjuje zloupotrebu i slučajnu ekstrakciju nepovezanih brendova.

Upotrijebite mehanizam umetanja tajni samo za čitanje, redigirajte autorizacijska zaglavlja na proxyjima, držite direktorije snimki i ponuda izvan javnog korijena dokumenata te poslužujte generirane datoteke putem autentificirane putanje za preuzimanje. Ako se udaljene slike kasnije pretvaraju u PDF-ove, dohvaćajte ih putem zasebnog preuzimača s DNS-om i zaštitama raspona IP adresa; nikada ne pretpostavljajte da je URL siguran samo zato što se pojavio u izdvojenim podacima.

Nadzirite broj uspješnih uvoza, kvarova transporta, neuspjeha autentikacije, ograničenja stope, neispravnih odgovora i ukupnu latenciju. Upozorite na trajne neuspjehe autentikacije nakon rotacije tokena i na neuspjehe neispravnog odgovora, koji mogu ukazivati na promjenu ugovora kojom treba promišljeno upravljati u BrandKit::fromApi().

  • 401 ili 403: provjerite aktivaciju plana i token ograničen na uslugu. Nemojte naslijepo ponavljati pokušaj.
  • 429: poštujte ograničeni Retry-After, smanjite istodobnost i provjerite upotrebu plana.
  • 400 ili 422: provjerite je li url potpuni javni URL web-mjesta.
  • Neispravan odgovor: zadržite klasifikaciju neuspjeha, ali nemojte pohranjivati djelomično provjerene podatke.
  • Nedostaje logotip u izlazu: renderer namjerno prihvaća samo HTTPS niz koji može sigurno prepoznati; upotrijebite lokalnu odobrenu zamjenu kada izdvojena struktura nema odgovarajućeg kandidata.

Završni kontrolni popis za provjeru

  1. Račun, plan i token usluge aktivni su, a nijedna vjerodajnica nije potvrđena ni zabilježena.
  2. Minimalni POST zahtjev uspijeva s točnom krajnjom točkom ekstrakcije i JSON-om url.
  3. Svih sedam područja podataka brenda prolazi provjeru granice prije objavljivanja atomske snimke.
  4. Neuspjesi autentikacije i provjere ne dobivaju ponovni pokušaj; prolazni neuspjesi ne dobivaju više od tri pokušaja.
  5. PHPUnit prolazi bez stvarnog mrežnog zahtjeva.
  6. Uvoz stvara var/brand-kits/example.json, a generiranje stvara var/proposals/example.html.
  7. Ponuda prikazuje izbjegnuti naziv brenda, sigurnu boju i siguran logotip ili namjernu zamjenu.

Vrijedan rezultat nije samo API poziv. To je pouzdana granica između javnih dokaza o brendu i dokumenata koje vaše poslovanje može ponovno generirati svaki dan. Kada ta granica strogo provjerava, atomski pohranjuje snimke i oprezno prikazuje, brendiranje postaje ponovljiv dio izrade ponuda umjesto krhkog završnog zadatka.

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.