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
- Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
- Otvorite stranicu usluge Brand Kit Extractor.
- Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte token ograničen na uslugu.
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:
CurlTransportupravlja HTTP mehanikom i vremenskim ograničenjima.BrandKitClientupravlja autentikacijom, ponovnim pokušajima, dekodiranjem odgovora i klasifikacijom neuspjeha.BrandKitmapira i provjerava odgovor domene prije pohrane.BrandKitRepositoryatomski zapisuje snimke.ProposalRendererkoristi 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
urlpotpuni 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
- Račun, plan i token usluge aktivni su, a nijedna vjerodajnica nije potvrđena ni zabilježena.
- Minimalni POST zahtjev uspijeva s točnom krajnjom točkom ekstrakcije i JSON-om
url. - Svih sedam područja podataka brenda prolazi provjeru granice prije objavljivanja atomske snimke.
- Neuspjesi autentikacije i provjere ne dobivaju ponovni pokušaj; prolazni neuspjesi ne dobivaju više od tri pokušaja.
- PHPUnit prolazi bez stvarnog mrežnog zahtjeva.
- Uvoz stvara
var/brand-kits/example.json, a generiranje stvaravar/proposals/example.html. - 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.