PHP 8.3: Интегрирајте средства на брендот во предлози со API-то за извлекување од Brand Kit
Генераторот на понуди станува навистина корисен кога ќе престане да го третира брендирањето како вежба за копирање и вметнување во последен момент. Со дадена веб-страница на клиент, овој проект еднаш го увезува неговиот визуелен идентитет, го валидира на границата на апликацијата, складира непроменлива снимка и користи безбеден подзбир при рендерирање понуди и извештаи.
Имплементацијата користи PHP 8.3, изворен cURL, акредитиви поддржани од околински променливи, ограничени повторни обиди, атомско складирање и PHPUnit со детерминистички лажен транспорт. Extractor-от за Brand Kit останува зад посебна API-граница, така што промените во презентацијата или трајното складирање не се прелеваат во мрежниот код.
Добијте пристап пред да пишувате интеграциски код
- Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
- Отворете ја страницата на услугата Brand Kit Extractor.
- Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Најдете го панелот Service token и копирајте го токенот ограничен на услугата.
Оваа услуга бара автентикација. Прифаќа Bearer токен, заглавие X-API-Token или параметар за барање token. Ќе ја користиме Bearer-формата бидејќи акредитивите во низите за барање почесто се појавуваат во прокси и дневници за пристап.
Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Третирајте ја ротацијата како операција за распоредување: ажурирајте ја тајната, распоредете или повторно вчитајте го секој процес што ја користи, потврдете ја екстракцијата и дури потоа сметајте дека ротацијата е завршена.
Потврдете ја крајната точка со минимално барање
Точната операција е POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Нејзиното JSON-барање содржи 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"}'
Пред да ја изградите функционалноста, создадете непотврдена .env.local датотека:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
Додајте ги .env.local, генерираните снимки на брендот и излезот на понудите во .gitignore. Изворниот PHP не чита автоматски dotenv-датотеки, па локалните команди подолу ги извезуваат во околината на процесот. Production треба да ја внесе истата променлива преку својот менаџер за тајни, runtime за контејнери или менаџер за услуги.
Архитектура: увезете еднаш, рендерирајте повеќепати
Повикот на надворешен екстрактор додека некој чека PDF или преглед на понуда создава непотребна поврзаност. Наместо тоа, експлицитна команда за увоз го повикува API-то и зачувува верзионирана JSON-снимка. Генераторот на извештаи ја чита таа локална снимка без мрежен пристап.
Добиената граница е мала:
CurlTransportе задолжен за HTTP-механиката и временските ограничувања.BrandKitClientе задолжен за автентикација, повторни обиди, декодирање на одговори и класификација на неуспеси.BrandKitго мапира и валидира доменскиот одговор пред складирање.BrandKitRepositoryзапишува снимки атомски.ProposalRendererкористи само вредности безбедни за презентација.
„Потврдено“ тука значи дека снимката доаѓа од детерминистичката услуга за екстракција заснована на докази и ги поминала нашите структурни и безбедносни проверки. Тоа не утврдува сопственост на трговска марка ниту дава дозвола за користење средства на друга организација.
Структура на проектот и зависности
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
Користете PHP 8.3 со екстензиите cURL и JSON. PHPUnit 11.5 е единствениот пакет од трета страна и е само за развој.
{
"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
Изградете ограничен изворен cURL транспорт
Транспортот извршува еден обид. Политиката за повторни обиди припаѓа во сервисниот клиент, каде што се достапни HTTP-статусот и значењето на апликацијата.
<?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),
);
}
}
Временското ограничување од пет секунди за поврзување и вкупното ограничување од 30 секунди што се користат подолу се конечни намерно. Прилагодете ги според забележаната латентност во production, но никогаш не дозволувајте работниците за понуди да чекаат неограничено.
Мапирајте го одговорот во строг доменски објект
Апликацијата мора да ги валидира вратеното име на брендот, логоата, боите, фонтовите, сликите, социјалните профили и CSS-променливите пред складирање. Маперот отфрла полиња што недостигаат, неправилно форматирани колекции, прекумерно вгнездување, предолги низи и URI-шеми што никогаш не треба да стигнат до рендерерот.
<?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));
}
}
Грешките при валидација и автентикација не се повторуваат. Мрежните дефекти, одговорите за квота и одбрани преодни неуспеси на gateway добиваат најмногу три обиди со ограничено повлекување. Логерот ги бележи класификацијата, статусот и обидот, но никогаш токенот, телото на одговорот или URL-адресата на клиентот.
Складирајте атомски и рендерирајте претпазливо
Чувајте ја целосната валидирана снимка за ревизија, но не вметнувајте произволен извлечен CSS во документ. Овој рендерер бара обична хексадецимална боја и HTTPS-лого, го енкодира текстот видлив за корисникот и во спротивно безбедно користи резервна вредност.
<?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;
}
}
Поврзете ги двете секојдневни команди
<?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);
Тестирајте ги повторните обиди и границите на неуспех без мрежа
Лажниот транспорт обезбедува фиксна редица на одговори. Затоа тестовите се извршуваат брзо и никогаш не трошат квота ниту зависат од достапноста на услугата.
<?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
Зацврстување за production и чести неуспеси
Извршувајте ги увозите како контролирани задачи или закажани команди, наместо во јавно барање за рендерирање. Дозволете само овластени домени на клиенти, ограничете ја конкурентноста и применете сопствено ограничување на стапката по сметка. Иако оваа апликација не ја презема самата поднесена страница, ограничувањето на внесот ја намалува злоупотребата и случајното извлекување на неповрзани брендови.
Користете механизам за внесување тајни само за читање, редигирајте ги заглавијата за авторизација кај прокси-серверите, чувајте ги директориумите за снимки и понуди надвор од јавниот корен на документите и сервирајте ги генерираните датотеки преку патека за преземање со автентикација. Ако далечинските слики подоцна се претвораат во PDF, преземајте ги преку посебен преземач со заштити за DNS и IP-опсег; никогаш не претпоставувајте дека URL е безбеден само затоа што се појавил во извлечени податоци.
Следете ги броевите на успешни увози, транспортни неуспеси, неуспеси при автентикација, ограничувања на стапка, невалидни одговори и вкупна латентност. Поставете предупредување за трајни неуспеси при автентикација по ротација на токен и за неуспеси со невалиден одговор, кои може да укажуваат на промена на договорот што треба намерно да се обработи во BrandKit::fromApi().
- 401 или 403: потврдете ја активацијата на планот и токенот ограничен на услугата. Не повторувајте наслепо.
- 429: почитувајте го ограниченото
Retry-After, намалете ја конкурентноста и проверете ја употребата на планот. - 400 или 422: проверете дали
urlе целосна URL-адреса на јавна веб-страница. - Невалиден одговор: задржете ја класификацијата на неуспехот, но не складирајте делумно валидирани податоци.
- Недостасува лого во излезот: рендерерот намерно прифаќа само HTTPS-низа што може безбедно да ја идентификува; користете локална одобрена резервна вредност кога извлечената структура нема соодветен кандидат.
Конечна контролна листа за верификација
- Сметката, планот и сервисниот токен се активни и ниту еден акредитив не е потврден во репозиториум или запишан во дневник.
- Минималното POST-барање успева со точната крајна точка за екстракција и JSON
url. - Сите седум области на податоци за брендот ја минуваат граничната валидација пред да биде објавена атомска снимка.
- Неуспесите при автентикација и валидација не добиваат повторен обид; преодните неуспеси добиваат не повеќе од три обиди.
- PHPUnit поминува без вистинско мрежно барање.
- Увозот создава
var/brand-kits/example.json, а генерирањето создаваvar/proposals/example.html. - Понудата прикажува енкодирано име на брендот, безбедна боја и безбедно лого или намерна резервна вредност.
Вредниот резултат не е само API-повик. Тоа е сигурна граница меѓу јавните докази за брендот и документите што вашиот бизнис може повторно да ги генерира секој ден. Кога таа граница агресивно валидира, атомски складира снимки и претпазливо рендерира, брендирањето станува повторлив дел од изработката на понуди наместо кревка завршна задача.