Izvorni PHP: Ekstraktor kompleta za brendiranje za jednostavne prijedloge i izvješća
Prijedlog može biti tehnički savršen, a ipak izgledati nedovršeno ako su njegov logotip, boje i tipografija pogrešni. Ručno kopiranje tih pojedinosti zamorno je, dok je prihvaćanje neprovjerenih podataka o dizajnu iz udaljene usluge rizično. Korisna sredina je mala granica za uvoz: izdvojiti vizualni identitet javne web-stranice, provjeriti svaki obavezni odjeljak i objaviti nepromjenjivu snimku brenda koju generator prijedloga ili izvješća može koristiti.
Ovaj vodič gradi tu granicu pomoću Native PHP 8.3, cURL-a i PHPUnit-a. Rezultat je naredba koja uvozi podatke o brendu utemeljene na dokazima bez povezivanja ostatka aplikacije s odgovorom vanjskog API-ja.
Pribavite pristup prije pisanja integracijskog koda
Najprije registrirajte račun ili upotrijebite stranicu za prijavu ako ga već imate.
- Otvorite stranicu usluge Brand Kit Extractor.
- Odaberite dostupni paket Free, Plus ili Pro i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite panel Service token i kopirajte token ograničen na uslugu.
- Pohranite ga u okruženje projekta, nikada u kontrolu izvornog koda.
Ponovno generiranje tokena usluge opoziva prethodno aktivni token, stoga rotacija mora ažurirati svaku implementiranu instancu koja ga koristi. Ova usluga nije bez tokena: svaki zahtjev mora se autentificirati Bearer tokenom, zaglavljem X-API-Token ili parametrom upita token. Upotrijebit ćemo Bearer token jer se vjerodajnice u nizu upita češće pojavljuju u zapisnicima pristupa, povijesti preglednika i sustavima za nadzor.
Potvrdite krajnju točku minimalnim zahtjevom
Točna operacija je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Njezino JSON tijelo sadrži url, koji pokazuje na javnu web-stranicu čiji identitet treba izdvojiti.
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
curl --request POST \
--url https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit \
--header "Authorization: Bearer ${BRAND_KIT_TOKEN}" \
--header "Content-Type: application/json" \
--data '{"url":"https://www.example.com"}'
Ovaj probni test trebao bi proizvesti JSON. Nemojte ga još pohraniti: uspješan HTTP prijenos nije dokaz da je njegov oblik siguran za vašu aplikaciju.
Postavite Native PHP projekt
Uvoznik odvaja prijenos, API pravila, mapiranje domene i pohranu. To čini ponovne pokušaje testabilnima i sprječava da pojedinosti vanjskog odgovora procure u predloške prijedloga.
brand-proposals/
├── bin/import-brand
├── config/bootstrap.php
├── src/
│ ├── BrandKit.php
│ ├── BrandKitClient.php
│ ├── BrandKitMapper.php
│ ├── CurlTransport.php
│ ├── HttpResponse.php
│ ├── Transport.php
│ └── BrandKitRepository.php
├── storage/brands/
├── tests/BrandKitClientTest.php
├── .env
├── .env.example
├── .gitignore
├── composer.json
└── phpunit.xml
Upotrijebite cURL za mrežnu granicu, a vlucas/phpdotenv samo za učitavanje lokalne konfiguracije okruženja. PHPUnit pruža deterministički testni okvir.
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
composer install
Stavite rezervirano mjesto u .env.example, kopirajte tu datoteku u .env i lokalno zamijenite rezervirano mjesto. Dodajte /.env i /storage/brands/*.tmp u .gitignore.
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
Datoteka za pokretanje učitava lokalnu konfiguraciju i rano prekida rad kada implementaciji nedostaje vjerodajnica.
<?php
// config/bootstrap.php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
Dotenv\Dotenv::createImmutable(dirname(__DIR__))->safeLoad();
$token = $_ENV['BRAND_KIT_TOKEN'] ?? getenv('BRAND_KIT_TOKEN') ?: null;
if (!is_string($token) || trim($token) === '') {
throw new RuntimeException('BRAND_KIT_TOKEN is not configured.');
}
return ['brand_kit_token' => $token];
Izgradite ograničen, zamjenjiv prijenos
Prijenos upravlja cURL mehanikom, a ne pravilima aplikacije. Primjenjuje trominutno vremensko ograničenje povezivanja i ukupno vremensko ograničenje od petnaest sekundi, bilježi zaglavlja odgovora i vraća tijelo bez njegova zapisivanja.
<?php
// src/Transport.php
namespace App;
interface Transport
{
public function post(string $url, array $headers, string $body): HttpResponse;
}
// src/HttpResponse.php
namespace App;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
// src/CurlTransport.php
namespace App;
final class CurlTransport implements Transport
{
public function post(string $url, array $headers, string $body): HttpResponse
{
$responseHeaders = [];
$handle = curl_init($url);
if ($handle === false) {
throw new \RuntimeException('Unable to initialize cURL.');
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 15,
CURLOPT_HEADERFUNCTION => static function ($curl, string $line)
use (&$responseHeaders): int {
$length = strlen($line);
if (str_starts_with($line, 'HTTP/')) {
$responseHeaders = [];
} elseif (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$responseHeaders[strtolower(trim($name))] = trim($value);
}
return $length;
},
]);
$bodyResult = curl_exec($handle);
if ($bodyResult === false) {
$message = curl_error($handle);
curl_close($handle);
throw new \RuntimeException('Brand service transport failed: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $responseHeaders, $bodyResult);
}
}
Primijenite promišljena pravila ponovnih pokušaja
Prolazne pogreške prijenosa, HTTP 429 odgovori i odgovori poslužitelja 5xx mogu uspjeti kasnije. Neuspjele autentifikacije i nevaljani zahtjevi neće se poboljšati ponavljanjem, stoga ih klijent odmah odbacuje. Tri ukupna pokušaja ograničavaju i latenciju i potrošnju kvote.
<?php
// src/BrandKitClient.php
namespace App;
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 \Closure $sleep,
) {}
public function extract(string $sourceUrl): array
{
$body = json_encode(
['url' => $sourceUrl],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->post(self::ENDPOINT, [
'Authorization: Bearer ' . $this->token,
'Content-Type: application/json',
'Accept: application/json',
], $body);
} catch (\RuntimeException $exception) {
if ($attempt === 3) {
throw $exception;
}
($this->sleep)(250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$decoded = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException $exception) {
throw new \UnexpectedValueException(
'Brand service returned invalid JSON.',
0,
$exception
);
}
if (!is_array($decoded)) {
throw new \UnexpectedValueException(
'Brand service response must be a JSON object.'
);
}
return $decoded;
}
if (in_array($response->status, [400, 401, 403, 404, 422], true)) {
throw new \DomainException(
'Brand extraction was rejected with HTTP '
. $response->status . '.'
);
}
$retryable = $response->status === 429
|| $response->status >= 500;
if (!$retryable || $attempt === 3) {
throw new \RuntimeException(
'Brand extraction failed with HTTP '
. $response->status . '.'
);
}
$retryAfter = ctype_digit($response->headers['retry-after'] ?? '')
? (int) $response->headers['retry-after'] * 1000
: 250 * (2 ** ($attempt - 1));
($this->sleep)(min($retryAfter, 8000));
}
throw new \LogicException('Unreachable retry state.');
}
}
Ovisnost o pauzi ubrizgava se kao closure. Produkcija koristi usleep($milliseconds * 1000); testovi ga zamjenjuju neoperativnom funkcijom. HTTP-date oblik zaglavlja Retry-After vraća se na eksponencijalno odgađanje umjesto oslanjanja na neizvjesnu sinkronizaciju sata.
Mapirajte odgovor u objekt domene
Obavezna granica prihvaća vraćeni naziv brenda, logotipe, boje, fontove, slike, društvene profile i CSS varijable samo kada svaki odjeljak ima očekivani široki JSON tip. Ugniježđene strukture resursa ostaju netaknute jer bi izmišljanje nedokumentiranih polja za logotipe ili fontove integraciju učinilo krhkom.
<?php
// src/BrandKit.php
namespace App;
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 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,
];
}
}
// src/BrandKitMapper.php
namespace App;
final class BrandKitMapper
{
public function map(array $payload): BrandKit
{
$name = $payload['brand_name'] ?? null;
if (!is_string($name) || trim($name) === '') {
throw new \UnexpectedValueException(
'Response has no valid brand_name.'
);
}
$sections = [
'logos', 'colors', 'fonts', 'imagery',
'social_profiles', 'css_variables',
];
foreach ($sections as $section) {
if (!isset($payload[$section])
|| !is_array($payload[$section])) {
throw new \UnexpectedValueException(
"Response has no valid {$section} section."
);
}
}
return new BrandKit(
trim($name),
$payload['logos'],
$payload['colors'],
$payload['fonts'],
$payload['imagery'],
$payload['social_profiles'],
$payload['css_variables'],
);
}
}
Ovdje “provjereno” znači da je uvezena snimka prošla granicu sheme aplikacije. To ne potvrđuje vlasništvo nad zaštitnim znakom niti neovisno certificira operatera web-stranice.
Objavite atomsku snimku
Renderiranje prijedloga nikada ne bi trebalo vidjeti napola zapisanu JSON datoteku. Repozitorij zapisuje pokraj odredišta, postavlja restriktivna dopuštenja, a zatim atomski preimenuje dovršenu datoteku.
<?php
// src/BrandKitRepository.php
namespace App;
final class BrandKitRepository
{
public function __construct(private readonly string $directory) {}
public function save(string $key, BrandKit $kit): void
{
if (!preg_match('/\A[a-z0-9][a-z0-9-]{0,63}\z/', $key)) {
throw new \InvalidArgumentException('Invalid brand key.');
}
if (!is_dir($this->directory)
&& !mkdir($this->directory, 0770, true)
&& !is_dir($this->directory)) {
throw new \RuntimeException('Cannot create brand storage.');
}
$temporary = tempnam($this->directory, $key . '-');
if ($temporary === false) {
throw new \RuntimeException('Cannot allocate temporary file.');
}
$json = json_encode(
$kit,
JSON_THROW_ON_ERROR
| JSON_PRETTY_PRINT
| JSON_UNESCAPED_SLASHES
);
try {
if (file_put_contents($temporary, $json, LOCK_EX) === false) {
throw new \RuntimeException('Cannot write brand snapshot.');
}
chmod($temporary, 0660);
if (!rename($temporary, $this->directory . '/' . $key . '.json')) {
throw new \RuntimeException('Cannot publish brand snapshot.');
}
} finally {
if (is_file($temporary)) {
unlink($temporary);
}
}
}
}
Generator prijedloga sada može pri renderiranju čitati storage/brands/acme.json. Držite pohranjeno izdvajanje odvojeno od interpretacije specifične za predložak: PDF prilagodnik može odabrati primarni logotip i konzervativnu paletu boja, dok prilagodnik HTML izvješća može koristiti odobreni podskup CSS varijabli.
Povežite naredbu za uvoz
Naredba odbacuje URL-ove koji nisu HTTPS i ugrađene vjerodajnice prije pozivanja usluge. Bilježi sažet događaj bez tokena, tijela odgovora ili izdvojenih resursa.
<?php
#!/usr/bin/env php
// bin/import-brand
declare(strict_types=1);
use App\BrandKitClient;
use App\BrandKitMapper;
use App\BrandKitRepository;
use App\CurlTransport;
$config = require dirname(__DIR__) . '/config/bootstrap.php';
[$script, $key, $url] = $argv + [null, null, null];
$parts = is_string($url) ? parse_url($url) : false;
$validUrl = filter_var($url, FILTER_VALIDATE_URL) !== false
&& is_array($parts)
&& ($parts['scheme'] ?? '') === 'https'
&& !isset($parts['user'], $parts['pass']);
if (!is_string($key) || !$validUrl) {
fwrite(STDERR, "Usage: php bin/import-brand BRAND_KEY HTTPS_URL\n");
exit(64);
}
try {
$client = new BrandKitClient(
new CurlTransport(),
$config['brand_kit_token'],
static fn (int $milliseconds) =>
usleep($milliseconds * 1000),
);
$kit = (new BrandKitMapper())->map($client->extract($url));
(new BrandKitRepository(
dirname(__DIR__) . '/storage/brands'
))->save($key, $kit);
fwrite(STDOUT, json_encode([
'event' => 'brand_kit.imported',
'brand_key' => $key,
'status' => 'ok',
], JSON_THROW_ON_ERROR) . PHP_EOL);
} catch (Throwable $exception) {
fwrite(STDERR, json_encode([
'event' => 'brand_kit.import_failed',
'brand_key' => $key,
'error_type' => $exception::class,
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(1);
}
chmod +x bin/import-brand
php bin/import-brand acme https://www.example.com
Testirajte ponovne pokušaje bez mrežnih poziva
Lažni prijenos čini ponašanje pri neuspjehu determinističkim. Ovaj test dokazuje da zahtjev ograničen stopom pokušava ponovno jednom i da mapirani rezultat sadrži svaki obavezni odjeljak.
<?php
// tests/BrandKitClientTest.php
declare(strict_types=1);
use App\BrandKitClient;
use App\BrandKitMapper;
use App\HttpResponse;
use App\Transport;
use PHPUnit\Framework\TestCase;
final class BrandKitClientTest extends TestCase
{
public function testRetriesRateLimitAndMapsResponse(): void
{
$transport = new class implements Transport {
public int $calls = 0;
public function post(
string $url,
array $headers,
string $body
): HttpResponse {
$this->calls++;
if ($this->calls === 1) {
return new HttpResponse(429, ['retry-after' => '0'], '{}');
}
return new HttpResponse(200, [], json_encode([
'brand_name' => 'Example',
'logos' => [],
'colors' => [],
'fonts' => [],
'imagery' => [],
'social_profiles' => [],
'css_variables' => [],
], JSON_THROW_ON_ERROR));
}
};
$client = new BrandKitClient(
$transport,
'test-token',
static fn (int $milliseconds) => null,
);
$kit = (new BrandKitMapper())->map(
$client->extract('https://www.example.com')
);
self::assertSame(2, $transport->calls);
self::assertSame('Example', $kit->brandName);
}
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
colors="true"
failOnWarning="true">
<testsuites>
<testsuite name="Brand importer">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
vendor/bin/phpunit
Sigurnost, operacije i implementacija
Pokrenite uvoze kao autentificiranu administrativnu radnju ili naredbu zakazanog održavanja, nikada kao anonimnu javnu krajnju točku. Iako ekstraktor pristupa javnoj web-stranici, ograničite poslane URL-ove na HTTPS i razmotrite popis dopuštenih domena kada vaše poslovanje već poznaje domene svojih klijenata.
Nemojte vraćeni CSS izravno postavljati na stranicu. Dopustite samo varijable i formate vrijednosti koje vaš renderer razumije. Također, dohvaćajte udaljene logotipe putem kontroliranog kanala za resurse koji ograničava veličinu, vrstu sadržaja, preusmjeravanja i vrijeme preuzimanja prije ugrađivanja u HTML ili PDF-ove.
U produkciji ubrizgajte BRAND_KIT_TOKEN kroz upravitelj tajni hosting platforme umjesto implementacije datoteke .env. Osigurajte da korisnik tijekom izvođenja može zapisivati u storage/brands i zadržite taj direktorij na dijeljenoj ili trajnoj pohrani ako više instanci renderira izvješća.
Nadzirite strukturirane brojke za uspješne uvoze, neuspjele autentifikacije, ograničenja stope, neuspjehe poslužitelja, nevaljane sheme i ukupno trajanje. Upozoravajte na trajne neuspjehe umjesto zapisivanja tijela odgovora. Kod 401 ili 403 obično ukazuje na token koji nedostaje, opozvan je ili je pogrešno implementiran. Kod 400 ili 422 ukazuje na odbijeni URL ili zahtjev. Ponovljeni odgovori 429 zahtijevaju smanjenu učestalost uvoza ili pregled paketa; ponovljeni odgovori 5xx trebali bi sačuvati posljednju poznatu dobru snimku.
Završni kontrolni popis za provjeru
- Račun i paket Free, Plus ili Pro su aktivni.
- Token ograničen na uslugu učitava se iz okruženja i nije prisutan u Git povijesti.
- Probni test uspješno doseže točnu POST krajnju točku.
- Naredba prihvaća samo valjane HTTPS izvorne URL-ove.
- Svih sedam obaveznih odjeljaka brenda prolazi mapiranje prije pohrane.
- Neuspjele autentifikacije i validacije nikada se ne pokušavaju ponovno naslijepo.
- Ograničenja stope, neuspjesi prijenosa i neuspjesi poslužitelja dobivaju ograničene ponovne pokušaje.
- PHPUnit prolazi bez kontaktiranja vanjske usluge.
- Uspješan uvoz stvara očekivanu JSON snimku brenda.
- Renderer prijedloga ili izvješća koristi samo odobrene resurse i CSS vrijednosti.
Važan artefakt nije samo uspješan API poziv. To je pouzdana predaja između javne web-stranice, provjerene snimke brenda i dokumenata koje ljudi svakodnevno izrađuju. Kada je ta granica stabilna, prijedlozi i izvješća mogu ostati prepoznatljivo usklađeni s brendom bez toga da svako renderiranje ovisi o mreži — ili da svaki razvojni programer ovisi o ručnom kopiranju i lijepljenju.