Kalo te dokumentacioni
PHP

Laravel dhe Symfony

Një klient në kontejner, një punë në sfond që nuk mund të dërgojë dy herë, një kontrollues webhook-u dhe teste që nuk e prekin kurrë rrjetin.

Konfigurimi

Paketa nuk ka integrim të vetin me framework-et: as service provider, as bundle, as transport poste. Regjistroni një klient në kontejner dhe e thërrisni API-në nga një punë në sfond, një kontrollues ose një shërbim, njësoj si nga çdo program PHP. Klienti mund të mbahet pa rrezik gjatë gjithë jetës së një procesi, ndaj regjistrojeni një herë si shërbim të përbashkët.

config/services.php
<?php return [    'openemail' => [        'key' => env('OPENEMAIL_API_KEY'),        'webhook_secret' => env('OPENEMAIL_WEBHOOK_SECRET'),    ],];
app/Providers/AppServiceProvider.php
<?php namespace App\Providers; use Illuminate\Support\ServiceProvider;use OpenEmail\OpenEmail; final class AppServiceProvider extends ServiceProvider{    public function register(): void    {        $this->app->singleton(OpenEmail::class, fn(): OpenEmail => new OpenEmail(            apiKey: (string) config('services.openemail.key'),            timeout: 15,        ));    }}

Në Laravel, mbajeni çelësin te .env dhe lexojeni përmes config/services.php, që t’i mbijetojë php artisan config:cache. Singleton-i ndërtohet herën e parë që diçka e kërkon, prandaj një komandë si php artisan migrate ekzekutohet pa problem edhe pa çelës. Që atëherë, çdo konstruktor ose metodë handle që emërton OpenEmail merr të njëjtin klient.

config/services.yaml
services:    OpenEmail\OpenEmail:        arguments:            $apiKey: '%env(OPENEMAIL_API_KEY)%'            $timeout: 15

Në Symfony, deklarojeni klientin si shërbim me emrin e klasës së tij, dhe autowiring-u ia jep çdo konstruktori që kërkon OpenEmail. Shërbimet janë të përbashkëta si parazgjedhje, ndaj i gjithë kontejneri përdor një klient të vetëm.

Shembujt përdorin Laravel 12 ose më të ri dhe Symfony 6.4 ose më të ri. Vetë klienti nuk varet nga asnjë framework, ndaj funksionon edhe në versione më të vjetra, me mënyrën e secilit për të regjistruar një shërbim.

Dërgimi nga një punë në sfond

Dërgoni nga një punë në radhë dhe jo nga kërkesa, që një dërgim i ngadaltë ose që dështon të mos e mbajë kurrë peng një faqe. Nxirreni idempotencyKey: nga regjistri të cilit i përket dërgimi. Një punë që ekzekutohet sërish, sepse radha e riprovoi ose një worker ra pasi API-ja u përgjigj, dërgon atëherë me të njëjtin çelës, dhe API-ja e riluan mesazhin që e ka dërguar tashmë në vend që të dërgojë një të dytë.

app/Jobs/SendInvoiceEmail.php
<?php namespace App\Jobs; use App\Models\Invoice;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Queue\Queueable;use Illuminate\Support\Facades\Storage;use OpenEmail\Exception\ValidationException;use OpenEmail\OpenEmail; final class SendInvoiceEmail implements ShouldQueue{    use Queueable;     public int $tries = 5;     public int $backoff = 30;     public function __construct(public Invoice $invoice) {}     public function handle(OpenEmail $openemail): void    {        try {            $sent = $openemail->emails->send([                'from' => 'Acme Billing <[email protected]>',                'to' => $this->invoice->customer_email,                'subject' => 'Invoice ' . $this->invoice->number,                'html' => '<p>Your invoice ' . $this->invoice->number . ' is attached.</p>',                'attachments' => [[                    'filename' => $this->invoice->number . '.pdf',                    'content' => OpenEmail::toBase64((string) Storage::get($this->invoice->pdf_path)),                    'contentType' => 'application/pdf',                ]],            ], idempotencyKey: 'invoice:' . $this->invoice->id . ':email');        } catch (ValidationException $error) {            $this->fail($error);             return;        }         $this->invoice->update(['email_id' => $sent['id'], 'email_status' => $sent['status']]);    }}

Një NetworkException lihet të dalë jashtë, ndaj radha e riprovon punën pas $backoff sekondash, dhe i njëjti çelës e bën ekzekutimin e radhës një riluajtje nëse i pari arriti vërtet te API-ja. Një ValidationException e dështon punën menjëherë, sepse një mesazh që API-ja e refuzoi ashtu siç ishte shkruar nuk mund të ketë sukses kur dërgohet sërish. Kur një NetworkException arrin te puna, klienti e ka riprovuar tashmë vetë dërgimin, dy herë si parazgjedhje, me të njëjtin çelës.

Mbajeni çelësin të qëndrueshëm për regjistrin dhe qëllimin, ashtu si invoice:42:email. Një çelës i ndërtuar nga një vulë kohore ose nga uniqid() është i ri në çdo ekzekutim, dhe atëherë një punë e riprovuar do të dërgonte dy herë. Ripërdorimi i një çelësi me një trup tjetër, si një faturë që ndryshoi midis dy ekzekutimeve, refuzohet me një 422 idempotency_key_reuse në vend që të dërgohet.

Një handler i Symfony Messenger funksionon njësoj. Messenger e riprovon vetë një mesazh të dështuar, dhe hedhja e UnrecoverableMessageHandlingException i thotë të mos e bëjë.

src/MessageHandler/SendWelcomeEmailHandler.php
<?php namespace App\MessageHandler; use App\Message\SendWelcomeEmail;use OpenEmail\Exception\ValidationException;use OpenEmail\OpenEmail;use Symfony\Component\Messenger\Attribute\AsMessageHandler;use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; #[AsMessageHandler]final class SendWelcomeEmailHandler{    public function __construct(private readonly OpenEmail $openemail) {}     public function __invoke(SendWelcomeEmail $message): void    {        try {            $this->openemail->emails->send([                'from' => 'Acme <[email protected]>',                'to' => $message->email,                'subject' => 'Welcome to Acme',                'text' => 'Glad you are here.',            ], idempotencyKey: 'welcome:' . $message->userId);        } catch (ValidationException $error) {            throw new UnrecoverableMessageHandlingException($error->getMessage(), 0, $error);        }    }}

Laravel ka një ValidationException të vetin. Kur një skedari i duhen të dyja, importojeni njërën me një emër tjetër, si use OpenEmail\Exception\ValidationException as OpenEmailValidationException;.

Marrja e webhook-eve

Verifikoni çdo dërgesë para se të veproni sipas saj. Nënshkrimi mbulon bajtet e papërpunuara të trupit, ndaj jepini verifikuesit $request->getContent(), jo një kopje të analizuar ose të rikoduar, bashkë me $request->headers, nga ku ai lexon header-in e nënshkrimit.

app/Http/Controllers/OpenEmailWebhookController.php
<?php namespace App\Http\Controllers; use App\Jobs\HandleOpenEmailEvent;use Illuminate\Http\Request;use Illuminate\Http\Response;use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail; final class OpenEmailWebhookController{    public function __invoke(Request $request): Response    {        try {            $event = OpenEmail::verifyWebhookSignature(                $request->getContent(),                $request->headers,                (string) config('services.openemail.webhook_secret'),            );        } catch (WebhookSignatureException) {            return response('bad signature', 400);        }         HandleOpenEmailEvent::dispatch($event);         return response()->noContent();    }}
routes/web.php
<?php use App\Http\Controllers\OpenEmailWebhookController;use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;use Illuminate\Support\Facades\Route; Route::post('/webhooks/openemail', OpenEmailWebhookController::class)    ->withoutMiddleware([ValidateCsrfToken::class]);

Një dërgesë nuk mbart token CSRF, ndaj një route në routes/web.php duhet ta lërë jashtë atë middleware, përndryshe Laravel e refuzon POST-in me një 419 para se të ekzekutohet kontrolluesi juaj. Një route në routes/api.php nuk ka fare kontroll CSRF që në fillim.

src/Controller/OpenEmailWebhookController.php
<?php namespace App\Controller; use App\Message\OpenEmailEvent;use OpenEmail\Exception\WebhookSignatureException;use OpenEmail\OpenEmail;use Symfony\Component\DependencyInjection\Attribute\Autowire;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpKernel\Attribute\AsController;use Symfony\Component\Messenger\MessageBusInterface;use Symfony\Component\Routing\Attribute\Route; #[AsController]final class OpenEmailWebhookController{    public function __construct(        private readonly MessageBusInterface $bus,        #[Autowire(env: 'OPENEMAIL_WEBHOOK_SECRET')]        private readonly string $webhookSecret,    ) {}     #[Route('/webhooks/openemail', methods: ['POST'])]    public function __invoke(Request $request): Response    {        try {            $event = OpenEmail::verifyWebhookSignature($request->getContent(), $request->headers, $this->webhookSecret);        } catch (WebhookSignatureException) {            return new Response('bad signature', Response::HTTP_BAD_REQUEST);        }         $this->bus->dispatch(new OpenEmailEvent($event));         return new Response(null, Response::HTTP_NO_CONTENT);    }}

Kalojani ngjarjen radhës dhe përgjigjuni menjëherë: një dërgesë që nuk merr përgjigje brenda 5 sekondave llogaritet e dështuar dhe dërgohet sërish më vonë. id e ngjarjes është e njëjtë në çdo riprovim dhe riluajtje të saj, ndaj ruani id-të që keni trajtuar dhe kapërceni ato që i keni parë.

Përgjigjuni me 400 vetëm për një dërgesë të falsifikuar ose të vjetruar dhe për asgjë tjetër. Një sekret që mungon është një dështim tjetër: OpenEmail::verifyWebhookSignature() hedh për të InvalidArgumentException, të cilin kontrolluesit më sipër nuk e kapin, ndaj një aplikacion i konfiguruar keq përgjigjet me 500 dhe dërgesa provohet sërish sapo ta rregulloni, në vend që çdo ngjarje të refuzohet si e falsifikuar.

Worker-a që punojnë gjatë

Nën PHP-FPM kontejneri ndërtohet për çdo kërkesë, po ashtu edhe klienti. Nën Laravel Octane, RoadRunner, FrankenPHP në modalitetin worker ose një worker radhe, singleton-i jeton aq sa jeton worker-i, ndaj një klient dhe lidhja e tij e hapur u shërbejnë të gjitha kërkesave që trajton worker-i. Ai nuk mban asnjë gjendje që i përket një kërkese të vetme: një apiKey: për thirrje e zëvendëson kredencialin vetëm për atë thirrje.

Një worker që bën fork, siç bën pcntl_fork, i jep procesit bir një lidhje të vetën: procesi bir ndalon së përdoruri atë që trashëgoi dhe hap një të re në kërkesën e tij të parë. Megjithatë, një proces bir që përfundon e mbyll lidhjen e trashëguar edhe për prindin, ndaj thirrni $client->close() para se të bëni fork, dhe prindi hap një lidhje të re që nuk e ndan me asnjë proces bir.

Testimi

Vendosni në kontejner, për kohëzgjatjen e një testi, një klient me një klient HTTP imitim dhe asgjë nuk largohet nga makina. Funksionon çdo gjë që implementon OpenEmail\Http\HttpClient, përfshirë një klasë anonime, dhe ajo sheh saktësisht atë që do të kishte dalë.

tests/Feature/SendInvoiceEmailTest.php
<?php namespace Tests\Feature; use App\Jobs\SendInvoiceEmail;use App\Models\Invoice;use Illuminate\Foundation\Testing\RefreshDatabase;use OpenEmail\Http\HttpClient;use OpenEmail\Http\HttpRequest;use OpenEmail\Http\HttpResponse;use OpenEmail\OpenEmail;use Tests\TestCase; final class SendInvoiceEmailTest extends TestCase{    use RefreshDatabase;     public function testARetriedJobSendsUnderOneKey(): void    {        $api = new class implements HttpClient {            public array $requests = [];             public function send(HttpRequest $request): HttpResponse            {                $this->requests[] = $request;                 return new HttpResponse(200, ['content-type' => 'application/json'], '{"id":"msg_test","status":"sent"}');            }        };         $this->app->instance(OpenEmail::class, new OpenEmail(            apiKey: 'oe_test_fake',            httpClient: $api,            maxRetries: 0,            disableUpdateNotice: true,        ));         $invoice = Invoice::factory()->create();        SendInvoiceEmail::dispatchSync($invoice);        SendInvoiceEmail::dispatchSync($invoice);         $keys = array_map(fn(HttpRequest $request): ?string => $request->header('Idempotency-Key'), $api->requests);         $this->assertSame(array_fill(0, 2, 'invoice:' . $invoice->id . ':email'), $keys);        $this->assertSame('/emails', parse_url($api->requests[0]->url, PHP_URL_PATH));    }}

Çdo kërkesë e regjistruar është një OpenEmail\Http\HttpRequest me method, url, headers, body dhe timeout, dhe body i saj është JSON-i që do të ishte dërguar, ndaj json_decode($request->body, true) tregon vetë mesazhin. Ktheni një status gabimi me zarfin e gabimit të API-së për të testuar si e trajton kodi juaj një refuzim. Në Symfony, përkufizojeni sërish shërbimin për mjedisin e testimit te config/services_test.yaml, me një klient HTTP imitim si argumentin e tij $httpClient.

tests/FakeOpenEmailHttpClient.php
<?php namespace App\Tests; use OpenEmail\Http\HttpClient;use OpenEmail\Http\HttpRequest;use OpenEmail\Http\HttpResponse; final class FakeOpenEmailHttpClient implements HttpClient{    public array $requests = [];     public function send(HttpRequest $request): HttpResponse    {        $this->requests[] = $request;         return new HttpResponse(200, ['content-type' => 'application/json'], '{"id":"msg_test","status":"sent"}');    }}
config/services_test.yaml
services:    App\Tests\FakeOpenEmailHttpClient: ~     OpenEmail\OpenEmail:        arguments:            $apiKey: 'oe_test_fake'            $httpClient: '@App\Tests\FakeOpenEmailHttpClient'            $maxRetries: 0            $disableUpdateNotice: true