Native PHP 8.3: Автоматизирајте го увозот на бренд-ресурси за предлози со Brand Kit API
Генераторот на понуди изгледа исполиран сè додека некој не мора да пребарува низ веб-страницата на клиентот за точниот логотип, боите на брендот, фонтовите и врските до социјалните мрежи. Тој рачен чекор е бавен, недоследен и изненадувачки лесно може да тргне наопаку.
Ова упатство изработува production-ориентирана интеграција во Native PHP 8.3 што прифаќа URL на јавна веб-страница, го извлекува нејзиниот визуелен идентитет заснован на докази, го валидира одговорот и зачувува непроменлива снимка на брендот за понуди и периодични извештаи. Дизајнот го задржува надворешното API зад мала граница, користи ограничени повторни обиди и останува тестабилен без мрежни повици.
Добијте пристап и копирајте го сервисниот токен
Почнете со создавање сметка на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате.
- Отворете ја страницата на услугата Brand Kit Extractor.
- Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Најдете го панелот Service token и копирајте го токенот со опсег за услугата.
- Чувајте го во конфигурација поддржана од околински променливи, никогаш во PHP изворен код.
Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Третирајте го повторното генерирање како ротација на акредитиви: веднаш ажурирајте ја секоја распоредена околина, потврдете го новиот токен и отстранете ги сите застарени референци до тајни.
Услугата бара автентикација. Прифаќа Bearer токен, заглавие X-API-Token или параметар за токен во query-низа. Овој проект користи Bearer токен бидејќи заглавијата имаат помала веројатност од URL-адресите да протечат преку историјата на прелистувачот, логовите на проксија и системите за мониторинг.
Потврдете ја крајната точка пред да ја изградите функционалноста
Точното барање е 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 \
--connect-timeout 3 \
--max-time 20 \
-X POST \
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit' \
-H "Authorization: Bearer ${BRAND_KIT_TOKEN}" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
--data '{"url":"https://example.com"}'
Успешниот одговор треба да ги содржи името на брендот, логотипите, боите, фонтовите, сликите, профилите на социјалните мрежи и CSS променливите. Сè уште не испраќајте вистинска понуда за клиент низ процесот. Прво прегледајте го одговорот според официјалната документација и потврдете дека вашиот план и токен се активни.
За локален развој, ставете го акредитивот во .env датотека што не се комитира:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_CONNECT_TIMEOUT_MS=3000
BRAND_KIT_RESPONSE_TIMEOUT_MS=20000
BRAND_KIT_MAX_ATTEMPTS=3
BRAND_SNAPSHOT_DIR=var/brands
Додајте ги .env и var/brands/ во .gitignore. Во продукција, внесете ги истите променливи преку управувачот со тајни на платформата за распоредување, наместо да испраќате датотека со околински променливи.
Изберете намерно мала архитектура
На генераторот на понуди му е потребна стабилна снимка, а не жива зависност од веб-страницата на клиентот секојпат кога се рендерира PDF. Затоа извлекувањето се случува при воведување на брендот или при изречно освежување.
- CurlTransport управува со HTTP, временските ограничувања, ограничувањата на големината на одговорот и заглавијата на одговорот.
- BrandKitClient управува со автентикацијата, политиката за повторни обиди, класификацијата на статусот и JSON декодирањето.
- BrandKitMapper ја валидира надворешната претстава и создава доменски објект.
- BrandRepository запишува атомска JSON снимка за генераторот на понуди.
- import-brand.php обезбедува оперативна команда погодна за локална употреба или за закажан работен тек.
Практичен распоред на проектот е:
proposal-generator/
├── bin/import-brand.php
├── src/BrandKit.php
├── src/BrandKitClient.php
├── src/BrandKitMapper.php
├── src/BrandRepository.php
├── src/CurlTransport.php
├── src/HttpResponse.php
├── src/Transport.php
├── tests/BrandKitClientTest.php
├── var/brands/
├── .env
├── .gitignore
├── composer.json
└── phpunit.xml
Конфигурирајте PSR-4 autoloading и инсталирајте PHPUnit 11, кој поддржува PHP 8.3:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer install
composer dump-autoload
set -a
. ./.env
set +a
Изградете ограничен native cURL transport
Transport-от не смее да чека неограничено ниту да прифати произволно голем одговор. Ограничувањето од два мегабајта подолу е заштитна мерка на апликацијата; приспособете го само откако ќе ги набљудувате легитимните големини на payload.
<?php
// src/Transport.php, src/HttpResponse.php, src/CurlTransport.php
namespace App;
interface Transport
{
public function postJson(string $url, string $token, array $payload): HttpResponse;
}
final readonly class HttpResponse
{
public function __construct(
public int $status,
public string $body,
public array $headers,
public int $durationMs,
) {}
}
final class CurlTransport implements Transport
{
public function __construct(
private int $connectTimeoutMs,
private int $responseTimeoutMs,
private int $maxBytes = 2_097_152,
) {}
public function postJson(string $url, string $token, array $payload): HttpResponse
{
$body = '';
$headers = [];
$started = hrtime(true);
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_CONNECTTIMEOUT_MS => $this->connectTimeoutMs,
CURLOPT_TIMEOUT_MS => $this->responseTimeoutMs,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Accept: application/json',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
CURLOPT_HEADERFUNCTION => static function ($handle, string $line) use (&$headers): int {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
CURLOPT_WRITEFUNCTION => function ($handle, string $chunk) use (&$body): int {
if (strlen($body) + strlen($chunk) > $this->maxBytes) {
return 0;
}
$body .= $chunk;
return strlen($chunk);
},
]);
if (curl_exec($curl) === false) {
throw new \RuntimeException('Brand service transport failed: ' . curl_error($curl));
}
return new HttpResponse(
curl_getinfo($curl, CURLINFO_RESPONSE_CODE),
$body,
$headers,
(int) ((hrtime(true) - $started) / 1_000_000),
);
}
}
Не вклучувајте го токенот, целосното тело на одговорот или query-низата на URL-адресата на клиентот во исклучоци и логови. Името на домаќинот, локалниот идентификатор за корелација, HTTP статусот, времетраењето и бројот на обидот вообичаено се доволни.
Мапирајте надворешен JSON во доверлив доменски објект
API границата е местото каде недоверливиот JSON станува апликациски податок. Маперот подолу ги бара сите седум доставени можности, отфрла преголеми или длабоко вгнездени структури и применува построги проверки на CSS променливите. Апликацијата не зачувува ништо сè додека ова мапирање не успее.
<?php
// src/BrandKit.php and src/BrandKitMapper.php
namespace App;
final readonly class BrandKit
{
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 function toArray(): 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 BrandKitMapper
{
public function map(array $data): BrandKit
{
$name = $data['brand_name'] ?? null;
if (!is_string($name) || trim($name) === '' || strlen($name) > 200) {
throw new \UnexpectedValueException('Invalid brand_name');
}
foreach (['logos', 'colors', 'fonts', 'imagery', 'social_profiles'] as $field) {
if (!isset($data[$field]) || !is_array($data[$field])) {
throw new \UnexpectedValueException("Invalid {$field}");
}
$this->assertTree($data[$field], $field);
}
$css = $data['css_variables'] ?? null;
if (!is_array($css)) {
throw new \UnexpectedValueException('Invalid css_variables');
}
foreach ($css as $property => $value) {
if (!is_string($property)
|| preg_match('/^--[A-Za-z0-9_-]{1,80}$/', $property) !== 1
|| !is_string($value)
|| strlen($value) > 512
|| preg_match('/[{};<>]/', $value) === 1
|| stripos($value, 'url(') !== false) {
throw new \UnexpectedValueException('Unsafe CSS variable');
}
}
return new BrandKit(
trim($name),
$data['logos'],
$data['colors'],
$data['fonts'],
$data['imagery'],
$data['social_profiles'],
$css,
);
}
private function assertTree(mixed $value, string $path, int $depth = 0): void
{
if ($depth > 8 || (is_array($value) && count($value) > 500)) {
throw new \UnexpectedValueException("Oversized {$path}");
}
if (is_string($value) && strlen($value) > 4096) {
throw new \UnexpectedValueException("Oversized string in {$path}");
}
if (is_array($value)) {
foreach ($value as $child) {
$this->assertTree($child, $path, $depth + 1);
}
} elseif (!is_null($value) && !is_scalar($value)) {
throw new \UnexpectedValueException("Unsupported value in {$path}");
}
}
}
Овие проверки воспоставуваат структурна доверба, а не сопственост на трговска марка или дозвола за користење на средствата. Ако понуди може да создаваат произволни корисници, додајте чекор за одобрување и ограничете ги увозите на домени што тие се овластени да ги претставуваат.
Ракувајте со повторните обиди без да ги умножувате неуспесите
Мрежни неуспеси, HTTP 429 и привремени неуспеси на gateway може да оправдаат уште еден обид. Не и неуспесите на автентикација и валидација. Три вкупни обиди со експоненцијален backoff ја одржуваат операцијата ограничена.
<?php
// src/BrandKitClient.php
namespace App;
final class ApiFailure extends \RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly ?int $status,
string $message,
) {
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 Transport $transport,
private BrandKitMapper $mapper,
private string $token,
private int $maxAttempts,
private \Closure $sleep,
private \Closure $log,
) {
if ($token === '') {
throw new \InvalidArgumentException('BRAND_KIT_TOKEN is missing');
}
}
public function extract(string $website): BrandKit
{
$this->assertPublicHttpsUrl($website);
for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
try {
$response = $this->transport->postJson(
self::ENDPOINT,
$this->token,
['url' => $website],
);
} catch (\RuntimeException $error) {
($this->log)(['event' => 'brand.transport_failure', 'attempt' => $attempt]);
if ($attempt === $this->maxAttempts) {
throw new ApiFailure('transport', null, 'Brand extraction is unavailable');
}
($this->sleep)(200 * (2 ** ($attempt - 1)));
continue;
}
($this->log)([
'event' => 'brand.response',
'status' => $response->status,
'duration_ms' => $response->durationMs,
'attempt' => $attempt,
]);
if ($response->status >= 200 && $response->status < 300) {
try {
return $this->mapper->map(
json_decode($response->body, true, 32, JSON_THROW_ON_ERROR)
);
} catch (\JsonException|\UnexpectedValueException $error) {
throw new ApiFailure('invalid_response', $response->status, $error->getMessage());
}
}
if (in_array($response->status, [401, 403], true)) {
throw new ApiFailure('authentication', $response->status, 'Check the service token');
}
$retryable = $response->status === 429
|| in_array($response->status, [502, 503, 504], true);
if (!$retryable || $attempt === $this->maxAttempts) {
$kind = $response->status === 429 ? 'rate_limit' : 'service';
throw new ApiFailure($kind, $response->status, 'Brand extraction failed');
}
$retryAfter = ctype_digit($response->headers['retry-after'] ?? '')
? min(5000, (int) $response->headers['retry-after'] * 1000)
: 200 * (2 ** ($attempt - 1));
($this->sleep)($retryAfter);
}
throw new ApiFailure('service', null, 'Brand extraction failed');
}
private function assertPublicHttpsUrl(string $url): void
{
$host = parse_url($url, PHP_URL_HOST);
if (filter_var($url, FILTER_VALIDATE_URL) === false
|| parse_url($url, PHP_URL_SCHEME) !== 'https'
|| !is_string($host)
|| strtolower($host) === 'localhost'
|| filter_var($host, FILTER_VALIDATE_IP) !== false) {
throw new \InvalidArgumentException('A public HTTPS website URL is required');
}
}
}
Почитувајте ги нумеричките вредности Retry-After, но ограничете го доцнењето за командата да не може да виси неограничено. Достапноста на квотата зависи од активираниот план; прикажете им ги неуспесите поради ограничување на стапката на операторите наместо да ги прикривате како неправилни податоци за брендот.
Зачувајте атомска снимка за рендерирање понуди
Чувајте ги заедно изворната URL-адреса, времето на увоз и валидираниот комплет. Генераторот на понуди може да се повика на добиениот ID на снимката, со што се осигурува дека стара понуда нема тивко да се измени кога веб-страницата ќе биде редизајнирана.
<?php
// src/BrandRepository.php
namespace App;
final class BrandRepository
{
public function __construct(private string $directory) {}
public function save(string $sourceUrl, BrandKit $kit): string
{
if (!is_dir($this->directory)
&& !mkdir($this->directory, 0750, true)
&& !is_dir($this->directory)) {
throw new \RuntimeException('Cannot create brand snapshot directory');
}
$id = hash('sha256', $sourceUrl . "\0" . microtime(true));
$target = $this->directory . '/' . $id . '.json';
$temporary = tempnam($this->directory, 'brand-');
if ($temporary === false) {
throw new \RuntimeException('Cannot create temporary snapshot');
}
$document = [
'source_url' => $sourceUrl,
'imported_at' => gmdate(DATE_ATOM),
'brand' => $kit->toArray(),
];
try {
$bytes = file_put_contents(
$temporary,
json_encode($document, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
LOCK_EX,
);
if ($bytes === false || !chmod($temporary, 0640) || !rename($temporary, $target)) {
throw new \RuntimeException('Cannot commit brand snapshot');
}
} finally {
if (is_file($temporary)) {
unlink($temporary);
}
}
return $id;
}
}
Командата ги составува компонентите и го печати само идентификаторот на снимката:
<?php
// bin/import-brand.php
require dirname(__DIR__) . '/vendor/autoload.php';
use App\{BrandKitClient, BrandKitMapper, BrandRepository, CurlTransport};
$url = $argv[1] ?? '';
$logger = static fn(array $event) =>
error_log(json_encode($event, JSON_THROW_ON_ERROR));
$client = new BrandKitClient(
new CurlTransport(
(int) (getenv('BRAND_KIT_CONNECT_TIMEOUT_MS') ?: 3000),
(int) (getenv('BRAND_KIT_RESPONSE_TIMEOUT_MS') ?: 20000),
),
new BrandKitMapper(),
(string) getenv('BRAND_KIT_TOKEN'),
(int) (getenv('BRAND_KIT_MAX_ATTEMPTS') ?: 3),
static fn(int $milliseconds) => usleep($milliseconds * 1000),
$logger,
);
$repository = new BrandRepository(
(string) (getenv('BRAND_SNAPSHOT_DIR') ?: dirname(__DIR__) . '/var/brands')
);
try {
$id = $repository->save($url, $client->extract($url));
fwrite(STDOUT, $id . PHP_EOL);
exit(0);
} catch (\Throwable $error) {
$logger(['event' => 'brand.import_failed', 'type' => $error::class]);
fwrite(STDERR, "Brand import failed\n");
exit(1);
}
php bin/import-brand.php 'https://example.com'
Рендерерот на извештаи треба да ја вчита таа снимка по ID и да ги мапира одобрените записи за логотип, боја, фонт, слики и профили на социјалните мрежи во својот постоечки модел на шаблон. Ескејпирајте ги текстуалните вредности, користете прокси или изречно дозволете далечински image hosts и никогаш не вметнувајте директно вратен CSS во документ. Дури и структурно валидните надворешни податоци остануваат недоверлив влез за рендерирање.
Тестирајте повторни обиди и валидација без мрежата
Детерминистички лажен transport ги прави патеките на неуспех брзи и повторливи:
<?php
// tests/BrandKitClientTest.php
use App\{ApiFailure, BrandKitClient, BrandKitMapper, HttpResponse, Transport};
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function postJson(string $url, string $token, array $payload): HttpResponse
{
$this->calls++;
return array_shift($this->responses);
}
}
final class BrandKitClientTest extends TestCase
{
public function testRetriesRateLimitThenMapsBrand(): void
{
$valid = json_encode([
'brand_name' => 'Example',
'logos' => [], 'colors' => ['#112233'],
'fonts' => ['Inter'], 'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--brand-primary' => '#112233'],
], JSON_THROW_ON_ERROR);
$fake = new FakeTransport([
new HttpResponse(429, '{}', ['retry-after' => '1'], 5),
new HttpResponse(200, $valid, [], 8),
]);
$delays = [];
$client = new BrandKitClient(
$fake,
new BrandKitMapper(),
'test-token',
3,
static function (int $ms) use (&$delays): void { $delays[] = $ms; },
static fn(array $event) => null,
);
self::assertSame('Example', $client->extract('https://example.com')->brandName);
self::assertSame(2, $fake->calls);
self::assertSame([1000], $delays);
}
public function testAuthenticationFailureIsNotRetried(): void
{
$fake = new FakeTransport([new HttpResponse(401, '{}', [], 4)]);
$client = new BrandKitClient(
$fake, new BrandKitMapper(), 'bad-token', 3,
static fn(int $ms) => null,
static fn(array $event) => null,
);
try {
$client->extract('https://example.com');
self::fail('Expected ApiFailure');
} catch (ApiFailure $failure) {
self::assertSame('authentication', $failure->kind);
self::assertSame(1, $fake->calls);
}
}
}
vendor/bin/phpunit --testdox
Распоредете со оперативни заштитни мерки
Подготвеноста за продукција главно е дисциплинирано однесување околу успешната патека. Внесете го токенот од управувач со тајни, потврдете дека екстензиите cURL и JSON се овозможени, направете var/brands да биде запишлив само за идентитетот на апликацијата и зачувувајте снимки на трајно складиште ако распоредувањата користат ефемерни датотечни системи.
Емитувајте структурирани настани за латентност, статус, број на обиди, одбивање при валидација и краен исход. Поставете известување за континуирани неуспеси на автентикација, бидејќи тие често укажуваат на нецелосна ротација на токенот. Следете ги неуспесите поради ограничување на стапката одделно од сервисните неуспеси за да биде видливо исцрпувањето на планот.
Вообичаени неуспеси
- 401 or 403: потврдете ја активацијата и токенот со опсег за услугата; повторното генерирање можеби ја поништило распоредена вредност.
- 429: почитувајте го ограниченото доцнење за повторен обид, потоа одложете го увозот или проверете ја достапноста на планот.
- Invalid response: не задржувајте делумна снимка; споредете го документираниот одговор со маперот пред да ја промените валидацијата.
- Timeouts: обидете се повторно само во рамките на конфигурираниот буџет за обиди и истражете ги DNS, TLS или достапноста на upstream.
- Snapshot write failure: проверете ги сопственоста на директориумот, слободниот простор и дали датотечниот систем во runtime е траен.
Конечна контролна листа за потврда
- Сметката и избраниот план се активни.
- Токенот доаѓа од конфигурација поддржана од околински променливи и не е присутен во изворната контрола и логовите.
- Апликацијата ја повикува точната POST крајна точка со JSON
url. - Името на брендот, логотипите, боите, фонтовите, сликите, профилите на социјалните мрежи и CSS променливите се валидираат пред зачувување.
- Неуспесите на автентикација и валидација никогаш не се повторуваат слепо.
- Ограничувањата на стапката и привремените неуспеси на gateway користат ограничен backoff.
- Тестовите поминуваат без надворешен HTTP сообраќај.
- Вистински увоз создава атомска снимка што генераторот на понуди или извештаи може да ја вчита по ID.
Вредниот резултат не е само успешен API повик. Тоа е контролирано предавање од променлива јавна веб-страница кон стабилна, прегледлива тема за понуда. Штом таа граница е изречна, увозите на брендови престануваат да бидат збирка копирани датотеки и стануваат сигурен дел од работниот тек за документи.