Vodiči

Native PHP 8.3: Autopopulate New Client Workspaces with Brand Kit Extractor API

Izvorni PHP 8.3: automatsko popunjavanje novih radnih prostora klijenata pomoću API-ja Brand Kit Extractor

Novi klijentski radni prostor trebao bi se činiti spremnim pri prvom prijavljivanju, a ne kao prazan obrazac koji traži od nekoga da transkribira vlastitu web stranicu. Praktičan tijek uvođenja može prihvatiti javni URL klijenta, izdvojiti njegov vizualni identitet, potvrditi rezultat i prednapunjati radni prostor upotrebljivim logotipima, bojama i fontovima.

Ovaj vodič gradi taj tijek u izvornom PHP 8.3 koristeći cURL, strogu granicu domene, ograničene pokušaje ponavljanja, atomsko pohranjivanje, strukturirane pogreške i determinističke PHPUnit testove. API za izdvajanje kompleta robne marke ostaje izoliran iza jednog klijenta, tako da kontroleri i kod za pohranu nikada ne ovise o transportnim detaljima ili neprovjerenom udaljenom JSON-u.

Dobijte pristup i kopirajte token opsega usluge

Započnite stvaranjem računa na https://ai.mihajlo.mk/register. Ako ga već imate, prijavite se na https://ai.mihajlo.mk/login.

  1. Otvorite stranicu za izdvajanje kompleta robne marke.
  2. Odaberite dostupni besplatni, Plus ili Pro plan koji odgovara vašoj očekivanoj upotrebi, a zatim dovršite aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Servisni token i kopirajte token opsega usluge.
  5. Pohranite ga u konfiguraciju projekta koja se temelji na okruženju, nikada u PHP izvorni kod.

Ova usluga nije bez tokena: svaki zahtjev za izdvajanje mora se autentificirati pomoću Bearer tokena, X-API-Token zaglavlja ili token parametra upita. Mi ćemo koristiti oblik Bearer jer čuva vjerodajnice izvan URL-ova i redovitih zapisnika pristupa.

Ponovno generiranje servisnog tokena opoziva prethodno aktivni token. Tretirajte ponovno generiranje kao rotaciju vjerodajnica: ažurirajte tajnu implementacije, ponovno pokrenite ili ponovno implementirajte sve procese koji je čitaju, provjerite zahtjev novim tokenom, a tek onda smatrajte implementaciju dovršenom.

Potvrdite HTTP ugovor prije pisanja značajke

Točna operacija je POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Njegovo JSON tijelo zahtjeva sadrži url. Pokrenite jedan minimalni zahtjev s javnom web-stranicom koju ste ovlašteni obraditi:

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

Nemojte ovo prosljeđivati kroz verbose otklanjanje pogrešaka u dijeljenim terminalima ili CI jer se argumenti naredbe i zaglavlja mogu snimiti. Stvarna aplikacija će poslati istu metodu, krajnju točku, zaglavlja i tijelo dok zadržava token u svom okruženju.

Organizirajte izvorni PHP projekt

Dizajn je namjerno mali. Transport rukuje cURL-om, API klijent upravlja ponovnim pokušajima i provjerom valjanosti odgovora, pružatelj primjenjuje pravila domene, a kontroler prevodi pogreške aplikacije u HTTP odgovore. Izdvajanje ostaje sinkrono jer stvaranje radnog prostora zahtijeva rezultat odmah; ako latencija uvođenja kasnije postane neprihvatljiva, pružatelj je šav za premještanje iza pokretača poslova.

brand-workspace/
├── .env
├── .env.example
├── composer.json
├── public/
│   └── create-workspace.php
├── src/
│   ├── BrandKit.php
│   ├── BrandKitClient.php
│   ├── CurlTransport.php
│   ├── Transport.php
│   └── WorkspaceProvisioner.php
├── storage/
│   └── workspaces/
└── tests/
    └── BrandKitClientTest.php

Zahtijeva PHP 8.3 i cURL, konfigurira PSR-4 automatsko učitavanje i instalira PHPUnit 11 za razvoj:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "Workspace\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Workspace\\Tests\\": "tests/"
    }
  },
  "scripts": {
    "test": "phpunit tests"
  }
}
composer install
composer dump-autoload

cp .env.example .env
# Uredite .env lokalno, a zatim ga izvezite prije pokretanja PHP-a:
set -a
. ./.env
set +a
php -S 127.0.0.1:8080 -t public

Izvorni PHP ne učitava .env automatski. Gornje naredbe ljuske prikladne su za lokalni razvoj; produkcija bi trebala ubrizgavati varijable putem svog upravitelja procesa, pokretača spremnika ili pohrane tajnih podataka. Isključite .env iz kontrole verzija.

BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_CONNECT_TIMEOUT=3
BRAND_KIT_RESPONSE_TIMEOUT=20
WORKSPACE_STORAGE=/absolute/path/to/brand-workspace/storage/workspaces

Izgradite uski, zamjenjivi cURL transport

Transport vraća status, zaglavlja i tijelo bez tumačenja podataka o robnoj marki. Ovo razdvajanje daje testovima determinističku lažnu verziju i drži ponašanje specifično za cURL izvan sloja domene.

<?php
// src/Transport.php
namespace Workspace;

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

interface Transport
{
    public function post(
        string $url,
        array $headers,
        string $body,
        float $connectTimeout,
        float $responseTimeout,
    ): HttpResponse;
}

final class TransportException extends \RuntimeException {}
final class ApiException extends \RuntimeException {}
<?php
// src/CurlTransport.php
namespace Workspace;

final class CurlTransport implements Transport
{
    public function post(
        string $url,
        array $headers,
        string $body,
        float $connectTimeout,
        float $responseTimeout,
    ): HttpResponse {
        $handle = curl_init($url);
        if ($handle === false) {
            throw new TransportException('Could not initialize cURL');
        }

        $responseHeaders = [];

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $body,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => (int) ($connectTimeout * 1000),
            CURLOPT_TIMEOUT_MS => (int) ($responseTimeout * 1000),
            CURLOPT_HEADERFUNCTION => static function (
                \CurlHandle $handle,
                string $line
            ) use (&$responseHeaders): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $responseHeaders[strtolower(trim($parts[0]))] =
                        trim($parts[1]);
                }
                return strlen($line);
            },
        ]);

        $bodyResult = curl_exec($handle);
        if ($bodyResult === false) {
            throw new TransportException(curl_error($handle));
        }

        return new HttpResponse(
            (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
            $responseHeaders,
            $bodyResult,
        );
    }
}

Provjerite udaljene podatke na granici aplikacije

Usluga vraća naziv robne marke plus logotipove, boje, fontove, slike, društvene profile i CSS varijable. Udaljeni JSON ne smije postati pouzdano stanje radnog prostora samo zato što je uspješno dekodiran. DTO zahtijeva svaki odjeljak ugovora, odbacuje pogrešne tipove i zadržava nizove temeljene na dokazima bez nagađanja o njihovom unutarnjem obliku.

<?php
// src/BrandKit.php
namespace Workspace;

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 static function fromApi(array $data): self
    {
        if (!isset($data['brand_name'])
            || !is_string($data['brand_name'])
            || trim($data['brand_name']) === '') {
            throw new ApiException('invalid_response: brand_name');
        }

        $arrayFields = [
            'logos', 'colors', 'fonts', 'imagery',
            'social_profiles', 'css_variables',
        ];

        foreach ($arrayFields as $field) {
            if (!array_key_exists($field, $data) || !is_array($data[$field])) {
                throw new ApiException('invalid_response: ' . $field);
            }
        }

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

    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,
        ];
    }
}

Klijent vrši najviše tri pokušaja. Ponovno pokušava mrežne greške, 429 i greške poslužitelja s ograničenim kašnjenjem. Autentifikacija, druge klijentske greške, neispravan JSON i greške sheme se ne pokušavaju ponovno jer ih ponavljanje ne može popraviti.

<?php
// src/BrandKitClient.php
namespace Workspace;

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

    private \Closure $sleep;
    private \Closure $log;

    public function __construct(
        private Transport $transport,
        private string $token,
        private float $connectTimeout = 3.0,
        private float $responseTimeout = 20.0,
        ?\Closure $sleep = null,
        ?\Closure $log = null,
    ) {
        if (trim($token) === '') {
            throw new \InvalidArgumentException('Missing BRAND_KIT_TOKEN');
        }

        $this->sleep = $sleep
            ?? static fn(int $milliseconds) => usleep($milliseconds * 1000);
        $this->log = $log ?? static function (array $event): void {};
    }

    public function extract(string $url): BrandKit
    {
        $payload = json_encode(
            ['url' => $url],
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
        );

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->post(
                    self::ENDPOINT,
                    [
                        'Authorization: Bearer ' . $this->token,
                        'Accept: application/json',
                        'Content-Type: application/json',
                    ],
                    $payload,
                    $this->connectTimeout,
                    $this->responseTimeout,
                );
            } catch (TransportException $exception) {
                if ($attempt === 3) {
                    throw new ApiException(
                        'network_failure',
                        0,
                        $exception
                    );
                }
                $this->backoff($attempt, 'network');
                continue;
            }

            if ($response->status === 401 || $response->status === 403) {
                throw new ApiException('authentication_failed');
            }

            if ($response->status === 429
                || $response->status >= 500) {
                if ($attempt === 3) {
                    throw new ApiException(
                        $response->status === 429
                            ? 'quota_or_rate_limit'
                            : 'upstream_unavailable'
                    );
                }
                $this->backoff($attempt, (string) $response->status);
                continue;
            }

            if ($response->status < 200 || $response->status >= 300) {
                throw new ApiException(
                    'request_rejected:' . $response->status
                );
            }

            try {
                $decoded = json_decode(
                    $response->body,
                    true,
                    512,
                    JSON_THROW_ON_ERROR
                );
            } catch (\JsonException $exception) {
                throw new ApiException('invalid_json', 0, $exception);
            }

            if (!is_array($decoded)) {
                throw new ApiException('invalid_response');
            }

            return BrandKit::fromApi($decoded);
        }

        throw new ApiException('unreachable_failure');
    }

    private function backoff(int $attempt, string $reason): void
    {
        $delay = min(1000, 250 * (2 ** ($attempt - 1)));
        ($this->log)([
            'event' => 'brand_kit_retry',
            'attempt' => $attempt,
            'reason' => $reason,
            'delay_ms' => $delay,
        ]);
        ($this->sleep)($delay);
    }
}

Atomski prednapuni radni prostor

Pružatelj prihvaća samo HTTPS URL-ove s normalnim nazivom hosta i odbija IP literale i localhost. Za proizvod s otvorenom prijavom, također dodajte tijek odobrenih domena ili jače kontrole DNS-a; samo provjera valjanosti sintakse URL-a ne može spriječiti svaki scenarij privatne mreže ili DNS ponovnog vezivanja. Šaljite samo javne web-stranice koje klijent ima pravo obraditi.

<?php
// src/WorkspaceProvisioner.php
namespace Workspace;

final class WorkspaceProvisioner
{
    public function __construct(
        private BrandKitClient $client,
        private string $storageDirectory,
    ) {}

    public function provision(string $workspaceId, string $siteUrl): BrandKit
    {
        if (!preg_match('/\A[a-zA-Z0-9_-]{1,64}\z/', $workspaceId)) {
            throw new \InvalidArgumentException('Invalid workspace ID');
        }

        $parts = parse_url($siteUrl);
        $host = is_array($parts) ? ($parts['host'] ?? '') : '';

        if (filter_var($siteUrl, FILTER_VALIDATE_URL) === false
            || ($parts['scheme'] ?? '') !== 'https'
            || $host === ''
            || strtolower($host) === 'localhost'
            || filter_var($host, FILTER_VALIDATE_IP) !== false) {
            throw new \InvalidArgumentException('Use a public HTTPS URL');
        }

        $kit = $this->client->extract($siteUrl);
        $target = $this->storageDirectory . '/' . $workspaceId . '.json';
        $temporary = tempnam($this->storageDirectory, 'brand-');

        if ($temporary === false) {
            throw new \RuntimeException('Could not create temporary file');
        }

        try {
            $json = json_encode([
                'workspace_id' => $workspaceId,
                'source_url' => $siteUrl,
                'brand_kit' => $kit->toArray(),
            ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);

            if (file_put_contents($temporary, $json, LOCK_EX) === false
                || !rename($temporary, $target)) {
                throw new \RuntimeException('Could not store workspace');
            }
        } finally {
            if (is_file($temporary)) {
                unlink($temporary);
            }
        }

        return $kit;
    }
}

Javni kontroler mora biti iza autentifikacije, autorizacije i zaštite od CSRF-a vaše aplikacije. Vraća stabilne kodove pogrešaka dok bilježi operativni kontekst bez vjerodajnica, tijela odgovora ili izdvojenog sadržaja robne marke.

<?php
// public/create-workspace.php
declare(strict_types=1);

use Workspace\{
    ApiException, BrandKitClient, CurlTransport, WorkspaceProvisioner
};

require dirname(__DIR__) . '/vendor/autoload.php';
header('Content-Type: application/json');

try {
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        http_response_code(405);
        throw new RuntimeException('method_not_allowed');
    }

    $input = json_decode(
        file_get_contents('php://input'),
        true,
        512,
        JSON_THROW_ON_ERROR
    );

    $logger = static function (array $event): void {
        error_log(json_encode($event, JSON_THROW_ON_ERROR));
    };

    $client = new BrandKitClient(
        new CurlTransport(),
        getenv('BRAND_KIT_TOKEN') ?: '',
        (float) (getenv('BRAND_KIT_CONNECT_TIMEOUT') ?: 3),
        (float) (getenv('BRAND_KIT_RESPONSE_TIMEOUT') ?: 20),
        null,
        $logger,
    );

    $provisioner = new WorkspaceProvisioner(
        $client,
        getenv('WORKSPACE_STORAGE') ?: ''
    );

    $kit = $provisioner->provision(
        (string) ($input['workspace_id'] ?? ''),
        (string) ($input['url'] ?? ''),
    );

    http_response_code(201);
    echo json_encode([
        'status' => 'ready',
        'brand_kit' => $kit->toArray(),
    ], JSON_THROW_ON_ERROR);
} catch (InvalidArgumentException | JsonException $exception) {
    http_response_code(422);
    echo json_encode(['status' => 'invalid_input']);
} catch (ApiException $exception) {
    error_log(json_encode([
        'event' => 'brand_kit_failure',
        'code' => $exception->getMessage(),
    ]));
    http_response_code(503);
    echo json_encode([
        'status' => 'extraction_failed',
        'code' => $exception->getMessage(),
    ]);
} catch (Throwable $exception) {
    error_log(json_encode(['event' => 'workspace_failure']));
    http_response_code(500);
    echo json_encode(['status' => 'internal_error']);
}

Testirajte ponovne pokušaje i mapiranje bez mrežnih poziva

Lažni transport čini sekvence pogrešaka točnim i brzim. Ovaj test dokazuje da se ponovno pokušava s ograničenjem brzine prvog pokušaja i da je potvrđeni rezultat ispravno mapiran.

<?php
// tests/BrandKitClientTest.php
namespace Workspace\Tests;

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

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

    public function __construct(private array $responses) {}

    public function post(
        string $url,
        array $headers,
        string $body,
        float $connectTimeout,
        float $responseTimeout,
    ): HttpResponse {
        return $this->responses[$this->calls++];
    }
}

final class BrandKitClientTest extends TestCase
{
    public function testRetriesRateLimitAndMapsBrandKit(): void
    {
        $valid = json_encode([
            'brand_name' => 'Example',
            'logos' => [['url' => 'https://example.com/logo.svg']],
            'colors' => [['value' => '#112233']],
            'fonts' => [['family' => 'Example Sans']],
            'imagery' => [],
            'social_profiles' => [],
            'css_variables' => ['--brand-primary' => '#112233'],
        ], JSON_THROW_ON_ERROR);

        $transport = new FakeTransport([
            new HttpResponse(429, [], '{}'),
            new HttpResponse(200, [], $valid),
        ]);

        $client = new BrandKitClient(
            $transport,
            'test-token',
            sleep: static function (int $milliseconds): void {}
        );

        $kit = $client->extract('https://example.com');

        self::assertSame(2, $transport->calls);
        self::assertSame('Example', $kit->brandName);
        self::assertSame('#112233', $kit->colors[0]['value']);
    }
}
composer test

curl --request POST \
  --url http://127.0.0.1:8080/create-workspace.php \
  --header "Content-Type: application/json" \
  --data '{"workspace_id":"client_acme","url":"https://example.com"}'

Upravljajte integracijom u produkciji

Stvorite direktorij za pohranu tijekom implementacije i dodijelite pravo pisanja samo identitetu PHP procesa. U aplikaciji utemeljenoj na bazi podataka, zamijenite JSON spremište transakcijom koja ažurira novi radni prostor tek nakon što provjera valjanosti uspije. Ostavite API klijenta i DTO nepromijenjene.

Pratite latenciju izdvajanja, brojače uspjeha, brojače ponovnih pokušaja, kodove pogrešaka i starost radnih prostora zaglavljenih u stanju čekanja. Nikada ne bilježite Authorization zaglavlje, token, sirovi odgovor ili potpuni URL kupca kada njegov put može sadržavati osjetljive podatke. Normalizirani naziv hosta i interni ID korelacije obično su dovoljni za dijagnozu.

Uobičajene pogreške imaju različite lijekove: authentication_failed znači provjeru aktivacije i rotacije tokena; quota_or_rate_limit znači poštivanje kapaciteta plana ili odgodu posla; request_rejected ukazuje na probleme s unosom ili ugovorom; invalid_json i invalid_response ukazuju na nesklad ugovora s gornje strane koji bi trebao upozoriti operatore umjesto da zagađuje pohranjene radne prostore.

Završni popis provjere

  • Token dolazi iz konfiguracije utemeljene na okruženju i nikada se ne pojavljuje u kontroli verzija ili zapisnicima.
  • Aplikacija šalje JSON s url na točnu POST krajnju točku koristeći Bearer autentifikaciju.
  • Samo javni HTTPS URL-ovi web-mjesta dolaze do klijenta za izdvajanje.
  • Naziv robne marke, logotipovi, boje, fontovi, slike, društveni profili i CSS varijable provjeravaju se prije pohranjivanja.
  • Mrežne greške, greške ograničenja brzine i greške poslužitelja primaju ograničene pokušaje ponavljanja; greške autentifikacije i provjere valjanosti ne primaju.
  • Pisanja radnog prostora su atomska, odredište je moguće pisati, a pogreške vraćaju strukturirana stanja.
  • PHPUnit testovi prolaze s determinističkim lažnim transportom i bez stvarne ovisnosti o usluzi.
  • Stvarni zahtjev za uvođenje stvara gotov radni prostor čiji su logotip, paleta i fontovi već dostupni klijentu.

Vidljivi rezultat je ugodno jednostavan: klijent isporučuje web-mjesto i otvara radni prostor koji već nalikuje njihovoj robnoj marki. Inženjerstvo ispod trebalo bi biti jednako disciplinirano. Uski API granica, obrambeno mapiranje, namjerni pokušaji ponavljanja, sigurno pohranjivanje i uočljive pogreške pretvaraju prikladan poziv za izdvajanje u pouzdanu značajku uvođenja.

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.