Vodiči

Symfony Quote Forms: Enrich Company Data from Website URLs Without Lag

Symfony obrasci za ponude: Obogatite podatke o tvrtki pomoću URL-ova web-stranica bez zastoja

Obrazac za ponudu trebao bi djelovati trenutačno. Ipak, URL web-mjesta koji potencijalni klijent unese može otključati koristan kontekst: identitet tvrtke, javno dostupne podatke za kontakt, telefonski broj i ključne osobe. Pogrešna implementacija prisiljava preglednik da čeka vanjski API. Dizajn prilagođen produkciji najprije prihvaća ponudu, odmah preusmjerava i obavlja obogaćivanje u pozadinskom radniku.

Ovaj vodič izrađuje takav dizajn pomoću PHP-a 8.3, Symfonyja 7.4, Doctrinea, HttpClienta i Messengera. Vanjski podaci mapiraju se na strogoj granici aplikacije, neuspjesi postaju eksplicitna stanja, a API kašnjenja nikada ne usporavaju obrazac okrenut korisniku.

Dobijte pristup i kopirajte servisni token

Počnite tako da registrirate račun ili upotrijebite stranicu za prijavu ako ga već imate.

  1. Otvorite stranicu usluge Website to Company data.
  2. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte token ograničen na tu uslugu.
  5. Pohranite ga u konfiguraciju projekta podržanu varijablama okruženja, nikada u PHP ili YAML datoteke predane u repozitorij.

Ponovno generiranje tokena opoziva prethodno aktivni token, stoga uskladite rotaciju s implementacijom. Ova usluga nema način rada bez tokena: svaki zahtjev mora navesti token={serviceToken} kao parametar upita.

Točna API operacija je GET https://ai.mihajlo.mk/api/website-to-company-data/v1/extract. Prihvaća web-mjesto u parametru upita website. Testirajte pristup prije pisanja integracijskog koda:

curl --get 'https://ai.mihajlo.mk/api/website-to-company-data/v1/extract' \
  --data-urlencode 'website=https://example.com' \
  --data-urlencode 'token=YOUR_SERVICE_TOKEN'

Stavite stvarnu vjerodajnicu u .env.local, koji Symfony projekti obično isključuju iz kontrole verzija:

MIHAJLO_WEBSITE_COMPANY_TOKEN=YOUR_SERVICE_TOKEN
MESSENGER_TRANSPORT_DSN=doctrine://default?queue_name=company_enrichment&auto_setup=false

Arhitektura: spremi, preusmjeri, obogati

Put zahtjeva ima četiri namjerna koraka: validirajte ponudu, trajno je spremite sa stanjem obogaćivanja pending, pošaljite malu poruku koja sadrži samo njezin identifikator baze podataka i preusmjerite. Messenger radnik kasnije preuzima zapis, poziva uslugu, mapira vraćene podatke company, contact, email, phone i people, a zatim sprema rezultat.

Time se uvodi eventualna konzistentnost: podaci o tvrtki mogu se pojaviti nekoliko sekundi nakon ponude. Taj je kompromis primjeren jer obogaćivanje podržava naknadni rad; nije potrebno za potvrdu slanja. Održavanje male poruke također izbjegava dupliciranje osobnih podataka u redu čekanja.

Preduvjeti i struktura projekta

Potrebni su vam PHP 8.3 ili noviji, Composer, baza podataka koju Doctrine podržava i upravitelj procesa sposoban održati konzolnog radnika aktivnim. Izradite projekt i instalirajte samo komponente koje ovaj tijek rada koristi:

composer create-project symfony/skeleton:"7.4.*" quote-enrichment
cd quote-enrichment
composer require symfony/orm-pack symfony/form symfony/validator \
  symfony/twig-bundle symfony/http-client symfony/messenger \
  symfony/doctrine-messenger
composer require --dev symfony/test-pack
php bin/console doctrine:database:create
php bin/console messenger:setup-transports

Važne datoteke su:

  • src/Entity/QuoteRequest.php za ponudu i stanje obogaćivanja.
  • src/Integration/CompanyEnrichment.php i WebsiteCompanyClient.php za API granicu.
  • src/Message/EnrichQuoteRequest.php i njegov obrađivač za pozadinsko izvršavanje.
  • src/Form/QuoteRequestType.php i src/Controller/QuoteController.php za slanje obrasca.
  • tests/Integration/WebsiteCompanyClientTest.php za determinističke testove transporta.

Konfigurirajte ograničene zahtjeve i pažljiva ponavljanja

Klijent ograničen opsegom pruža vremensko ograničenje neaktivnosti i ograničenje ukupnog trajanja. Budući da je ovo idempotentan GET zahtjev, razumno je ponovno pokušati mali broj prolaznih odgovora. Odgovori povezani s autentikacijom i validacijom namjerno nisu na popisu za ponovno pokušavanje.

# config/packages/framework.yaml
framework:
  http_client:
    scoped_clients:
      company_enrichment.client:
        base_uri: 'https://ai.mihajlo.mk/api/website-to-company-data/'
        timeout: 3
        max_duration: 8
        retry_failed:
          http_codes: [429, 502, 503, 504]
          max_retries: 2
          delay: 300
          multiplier: 2
          max_delay: 2000
          jitter: 0.2

  messenger:
    transports:
      async:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        retry_strategy:
          max_retries: 2
          delay: 1000
          multiplier: 2
          max_delay: 5000
    routing:
      App\Message\EnrichQuoteRequest: async

# config/services.yaml
services:
  App\Integration\WebsiteCompanyClient:
    arguments:
      $http: '@company_enrichment.client'
      $token: '%env(string:MIHAJLO_WEBSITE_COMPANY_TOKEN)%'

HTTP ponavljanja pokrivaju odgovore o kvoti i kratke prekide rada uzvodne usluge. Ako se ti pokušaji iscrpe, ponuda bilježi strukturirani neuspjeh umjesto da blokira neograničeno. Messengerova ponavljanja i dalje su korisna za neočekivane neuspjehe obrađivača ili baze podataka.

Mapirajte neizvjestan JSON na granici

Vanjski JSON ne smije izravno ulaziti u entitete ili predloške. Mapper prihvaća samo pet ugovornih polja, defenzivno primjenjuje tipove i ignorira dodatna polja. Nepostojeći ugovorni oblik tretira se kao nevaljan odgovor umjesto da se tiho pohrani.

<?php
// src/Integration/CompanyEnrichment.php
namespace App\Integration;

final readonly class CompanyEnrichment
{
    public function __construct(
        public ?array $company,
        public ?array $contact,
        public ?string $email,
        public ?string $phone,
        public array $people,
    ) {}

    public static function fromPayload(array $payload): self
    {
        $known = ['company', 'contact', 'email', 'phone', 'people'];

        if (!array_filter($known, fn (string $key) => array_key_exists($key, $payload))) {
            throw new WebsiteCompanyFailure('invalid_response', 'Expected fields are absent.');
        }

        $people = is_array($payload['people'] ?? null)
            ? array_values(array_filter($payload['people'], 'is_array'))
            : [];

        return new self(
            is_array($payload['company'] ?? null) ? $payload['company'] : null,
            is_array($payload['contact'] ?? null) ? $payload['contact'] : null,
            self::text($payload['email'] ?? null),
            self::text($payload['phone'] ?? null),
            $people,
        );
    }

    public function toArray(): array
    {
        return get_object_vars($this);
    }

    private static function text(mixed $value): ?string
    {
        if (!is_string($value) || trim($value) === '') {
            return null;
        }

        return trim($value);
    }
}

// src/Integration/WebsiteCompanyFailure.php
namespace App\Integration;

final class WebsiteCompanyFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        string $message,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

Klijent koristi obavezni ugovor autentikacije putem parametra upita. Nikada ne stavlja token ili tijelo odgovora u poruke iznimki.

<?php
// src/Integration/WebsiteCompanyClient.php
namespace App\Integration;

use JsonException;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class WebsiteCompanyClient
{
    public function __construct(
        private HttpClientInterface $http,
        private string $token,
    ) {}

    public function extract(string $website): CompanyEnrichment
    {
        try {
            $response = $this->http->request('GET', 'v1/extract', [
                'query' => [
                    'website' => $website,
                    'token' => $this->token,
                ],
            ]);

            $status = $response->getStatusCode();

            if ($status === 401 || $status === 403) {
                throw new WebsiteCompanyFailure('authentication', 'Service authentication failed.');
            }
            if (in_array($status, [400, 404, 422], true)) {
                throw new WebsiteCompanyFailure('invalid_request', 'The website was rejected.');
            }
            if ($status === 429) {
                throw new WebsiteCompanyFailure('rate_limited', 'Service quota is temporarily unavailable.');
            }
            if ($status >= 500) {
                throw new WebsiteCompanyFailure('temporary', 'The service is temporarily unavailable.');
            }
            if ($status < 200 || $status >= 300) {
                throw new WebsiteCompanyFailure('remote_error', 'Unexpected service response.');
            }

            $payload = json_decode(
                $response->getContent(false),
                true,
                512,
                JSON_THROW_ON_ERROR,
            );

            if (!is_array($payload)) {
                throw new WebsiteCompanyFailure('invalid_response', 'Response is not a JSON object.');
            }

            return CompanyEnrichment::fromPayload($payload);
        } catch (WebsiteCompanyFailure $failure) {
            throw $failure;
        } catch (JsonException $exception) {
            throw new WebsiteCompanyFailure('invalid_response', 'Response is not valid JSON.', $exception);
        } catch (TransportExceptionInterface $exception) {
            throw new WebsiteCompanyFailure('temporary', 'Service request could not complete.', $exception);
        }
    }
}

Trajno spremite eksplicitno stanje obogaćivanja

Entitet QuoteRequest trebao bi sadržavati uobičajena polja ponude kao što su customerEmail, website i summary, uz enrichmentStatus, nullable JSON enrichmentData i nullable enrichmentError. Inicijalizirajte stanje na pending i dodajte ove metode prijelaza:

<?php
// Relevant methods in src/Entity/QuoteRequest.php
public function enrichmentSucceeded(CompanyEnrichment $result): void
{
    $this->enrichmentStatus = 'succeeded';
    $this->enrichmentData = $result->toArray();
    $this->enrichmentError = null;
}

public function enrichmentFailed(string $kind): void
{
    $this->enrichmentStatus = 'failed';
    $this->enrichmentData = null;
    $this->enrichmentError = $kind;
}

public function getId(): ?int { return $this->id; }
public function getWebsite(): string { return $this->website; }

Upotrijebite eksplicitne nazive Doctrine stupaca za enrichment_status, enrichment_data i enrichment_error. Generirajte i pregledajte migraciju:

php bin/console make:migration
php bin/console doctrine:migrations:migrate --no-interaction

Pokrenite obogaćivanje u Messengeru

Poruka sadrži samo ID ponude. Atomsko ažuriranje sprječava da dvostruke isporuke obogate isti zapis na čekanju dva puta. Obrađivač zapisuje identifikatore i kategorije neuspjeha, ali ne web-mjesta, podatke za kontakt, tijela odgovora ni tokene.

<?php
// src/Message/EnrichQuoteRequest.php
namespace App\Message;

final readonly class EnrichQuoteRequest
{
    public function __construct(public int $quoteId) {}
}

// src/MessageHandler/EnrichQuoteRequestHandler.php
namespace App\MessageHandler;

use App\Entity\QuoteRequest;
use App\Integration\WebsiteCompanyClient;
use App\Integration\WebsiteCompanyFailure;
use App\Message\EnrichQuoteRequest;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final readonly class EnrichQuoteRequestHandler
{
    public function __construct(
        private Connection $connection,
        private EntityManagerInterface $entityManager,
        private WebsiteCompanyClient $client,
        private LoggerInterface $logger,
    ) {}

    public function __invoke(EnrichQuoteRequest $message): void
    {
        $claimed = $this->connection->executeStatement(
            'UPDATE quote_request
             SET enrichment_status = :processing
             WHERE id = :id AND enrichment_status = :pending',
            ['processing' => 'processing', 'pending' => 'pending', 'id' => $message->quoteId],
        );

        if ($claimed !== 1) {
            return;
        }

        $quote = $this->entityManager->find(QuoteRequest::class, $message->quoteId);
        if (!$quote) {
            return;
        }

        try {
            $quote->enrichmentSucceeded($this->client->extract($quote->getWebsite()));
            $this->logger->info('Quote enrichment succeeded.', ['quote_id' => $message->quoteId]);
        } catch (WebsiteCompanyFailure $failure) {
            $quote->enrichmentFailed($failure->kind);
            $this->logger->warning('Quote enrichment failed.', [
                'quote_id' => $message->quoteId,
                'failure_kind' => $failure->kind,
            ]);
        }

        $this->entityManager->flush();
    }
}

Neka kontroler bude brz

Primijenite Symfonyjevo ograničenje Url samo s HTTP i HTTPS protokolima, razumnim ograničenjem duljine i uobičajenim rukovanjem obrascem zaštićenim CSRF-om. Kontroler mora izvršiti flush prije slanja poruke kako radnik ne bi mogao vidjeti nedostajući zapis baze podataka.

<?php
// src/Controller/QuoteController.php
namespace App\Controller;

use App\Entity\QuoteRequest;
use App\Form\QuoteRequestType;
use App\Message\EnrichQuoteRequest;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;

final class QuoteController extends AbstractController
{
    #[Route('/quote', name: 'quote_new', methods: ['GET', 'POST'])]
    public function new(
        Request $request,
        EntityManagerInterface $entityManager,
        MessageBusInterface $bus,
    ): Response {
        $quote = new QuoteRequest();
        $form = $this->createForm(QuoteRequestType::class, $quote);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $entityManager->persist($quote);
            $entityManager->flush();

            $bus->dispatch(new EnrichQuoteRequest($quote->getId()));

            return $this->redirectToRoute('quote_received');
        }

        return $this->render('quote/new.html.twig', ['form' => $form]);
    }

    #[Route('/quote/received', name: 'quote_received', methods: ['GET'])]
    public function received(): Response
    {
        return new Response('<p>Your quote request has been received.</p>');
    }
}

Ako slanje u red čekanja ne uspije nakon što je ponuda predana, prijava i dalje postoji u stanju pending. Zakazana naredba za usklađivanje može ponovno poslati stare retke na čekanju; zadržite tu operaciju idempotentnom oslanjajući se na atomsko preuzimanje u obrađivaču.

Testirajte bez pozivanja aktivne usluge

MockHttpClient čini ponašanje transporta determinističkim i provjerava točnu metodu, putanju i parametre upita.

<?php
// tests/Integration/WebsiteCompanyClientTest.php
namespace App\Tests\Integration;

use App\Integration\WebsiteCompanyClient;
use App\Integration\WebsiteCompanyFailure;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class WebsiteCompanyClientTest extends TestCase
{
    public function testMapsContractFields(): void
    {
        $http = new MockHttpClient(function (string $method, string $url): MockResponse {
            self::assertSame('GET', $method);
            self::assertSame('/api/website-to-company-data/v1/extract', parse_url($url, PHP_URL_PATH));

            parse_str(parse_url($url, PHP_URL_QUERY) ?? '', $query);
            self::assertSame('https://example.com', $query['website']);
            self::assertSame('test-token', $query['token']);

            return new MockResponse(json_encode([
                'company' => ['name' => 'Example Company'],
                'contact' => ['city' => 'Example City'],
                'email' => '[email protected]',
                'phone' => '+1 555 0100',
                'people' => [['name' => 'Alex Example']],
            ], JSON_THROW_ON_ERROR));
        }, 'https://ai.mihajlo.mk/');

        $result = (new WebsiteCompanyClient($http, 'test-token'))
            ->extract('https://example.com');

        self::assertSame('Example Company', $result->company['name']);
        self::assertSame('[email protected]', $result->email);
        self::assertCount(1, $result->people);
    }

    public function testRejectsUnknownJsonShape(): void
    {
        $client = new WebsiteCompanyClient(
            new MockHttpClient(new MockResponse('{"unexpected":true}')),
            'test-token',
        );

        $this->expectException(WebsiteCompanyFailure::class);
        $client->extract('https://example.com');
    }

    public function testClassifiesAuthenticationFailure(): void
    {
        $client = new WebsiteCompanyClient(
            new MockHttpClient(new MockResponse('', ['http_code' => 401])),
            'test-token',
        );

        try {
            $client->extract('https://example.com');
            self::fail('Expected authentication failure.');
        } catch (WebsiteCompanyFailure $failure) {
            self::assertSame('authentication', $failure->kind);
        }
    }
}

Sigurnost, operacije i česti neuspjesi

Autentikacija putem parametra upita zahtijeva posebnu pažnju jer URL-ove mogu zabilježiti proxyji, profileri ili HTTP zapisnici. Nikada ne zapisujte potpuni URL zahtjeva. Onemogućite profiliranje u produkciji, ograničite pristup infrastrukturnim zapisnicima, redigirajte vrijednosti upita token na svakom obrnutom proxyju i odmah rotirajte token ako je izložen.

Vraćene podatke o tvrtki i ljudima tretirajte kao nepouzdan unos. Escapeajte ih u predlošcima, autorizirajte pristup administrativnim zaslonima ponuda, definirajte pravilo zadržavanja i izbjegavajte kopiranje JSON-a obogaćivanja u analitiku ili izvješća o iznimkama. Validacija obrasca trebala bi odbiti ne-HTTP sheme, dok ograničavanje brzine na razini aplikacije i CSRF zaštita smanjuju zlouporabu kvote.

Implementirajte migracije i Messenger transport prije pokretanja radnika. Zatim nadzirite potrošača pomoću upravitelja usluga:

php bin/console doctrine:migrations:migrate --no-interaction
php bin/console messenger:setup-transports
php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M

Ponovno pokrenite radnike pri svakom izdanju kako bi učitali novi kod. Pratite starost reda čekanja, zapise na čekanju, broj uspjeha i neuspjeha po kategoriji neuspjeha, izlaze radnika i trajanje obogaćivanja. Upotrijebite php bin/console messenger:failed:show za pregled iscrpljenih Messenger isporuka, a messenger:failed:retry tek nakon otklanjanja uzroka.

  • Neuspjesi autentikacije: potvrdite implementiranu tajnu i zapamtite da je ponovno generiranje opozvalo stari token. Nemojte ponavljati odgovore 401 ili 403.
  • Neuspjesi nevaljanog web-mjesta: zadržite ponudu, zabilježite invalid_request i omogućite članu osoblja da je ispravi i ponovno pošalje.
  • Ograničenja brzine: dopustite ograničenim HTTP ponavljanjima da riješe kratkotrajni pritisak, zatim zabilježite rate_limited. Nemojte stvarati neograničenu petlju ponavljanja.
  • Neispravan JSON: klasificirajte ga kao invalid_response; nikada ne nagađajte novu strukturu odgovora unutar domenskog koda.
  • Rastući red čekanja: provjerite radi li radnik, pregledajte neuspjele poruke i usporedite stopu dolaska s kapacitetom obrade prije dodavanja potrošača.

Završni kontrolni popis provjere

  • Preglednik se preusmjerava nakon trajnog spremanja u bazu podataka i slanja poruke, bez čekanja na obogaćivanje.
  • Odlazni zahtjev je točno GET prema navedenom krajnjem odredištu za ekstrakciju s parametrima upita website i token.
  • Servisni token postoji samo u konfiguraciji podržanoj varijablama okruženja i redigiran je u zapisnicima.
  • Podaci o tvrtki, kontaktu, e-pošti, telefonu i ljudima prolaze kroz defenzivnu granicu aplikacije.
  • Vremenska ograničenja i ponavljanja su ograničeni; neuspjesi validacije i autentikacije ne ponavljaju se naslijepo.
  • Duplicirane poruke ne mogu obraditi već preuzetu ponudu.
  • Testovi prolaze s php bin/phpunit, a radnik ažurira stvarnu ponudu na čekanju u staging okruženju.

Važan rezultat nisu samo bogatiji podaci o ponudama. To je obrazac koji ostaje pouzdan kada je usluga obogaćivanja spora, ograničena kvotom, privremeno nedostupna ili vrati nešto neočekivano. Najprije prihvatite namjeru korisnika; obogatite je prema vlastitom operativnom ritmu. To razdvajanje pretvara praktični API poziv u produkcijsku integraciju.

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.