Туториали

Laravel: Graceful Email Validation for Registration Amidst API Downtime

Laravel: Елегантна валидација на е-пошта при регистрација за време на прекин на API-то

Формуларот за регистрација се наоѓа на деликатна граница: треба да ги запре очигледно неупотребливите адреси, но не треба да заклучи вистински клиент затоа што зависна услуга имала лош ден. Затоа, правилниот продукциски дизајн не е ниту „секогаш верувај му на API-то“ ниту „дозволувај без видливост“. Тоа е мал, експлицитен систем за одлучување.

Ова упатство го гради тој систем во Laravel. Апликацијата локално го валидира основниот влез, бара докази за ризикот од испорака од Email Validator, одбива само јасна и структурно доверлива негативна препорака и дозволува регистрација со состојба на ризик што чека обработка кога услугата не е достапна или нејзиниот одговор е неизвесен.

Предуслови

Ви треба PHP 8.3 или понов, Composer, Laravel апликација со конфигурирана база на податоци и вообичаената табела users. Примерите ги користат вградениот HTTP клиент на Laravel, фасадата за автентикација, валидација, миграции, логирање, настани и HTTP лажни одговори за тестови. Не е потребен пакет за интеграција од трета страна.

Ако почнувате од почеток, креирајте ја апликацијата и подгответе ја нејзината база на податоци:

composer create-project laravel/laravel graceful-registration
cd graceful-registration
cp .env.example .env
php artisan key:generate
php artisan migrate

Добијте пристап пред да пишувате код за интеграција

  1. Креирајте сметка на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате.
  2. Отворете ја страницата на услугата Email Validator. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
  3. Отворете ја официјалната документација за Email Validator.
  4. Најдете го панелот Service token и копирајте го токенот ограничен на услугата. Оваа услуга го бара тој токен; не станува збор за API без автентикација.

Повторното генерирање на токенот за услугата го поништува претходно активниот токен. Третирајте ја ротацијата како операција за распоредување: веднаш ажурирајте ја тајната на апликацијата, повторно изградете ја кешираната конфигурација, потврдете барање и дури потоа сметајте дека ротацијата е завршена.

Потврдете ја крајната точка со минимално барање

Точниот повик е GET https://ai.mihajlo.mk/api/email-validator/v1/check-email. Автентикацијата го користи параметарот за пребарување token={serviceToken}, додека адресата се доставува преку параметарот за пребарување email.

За еднократен заштитен тест во терминал, ставете го токенот во привремена shell променлива наместо да го внесувате директно во URL-то:

export EMAIL_VALIDATOR_TEST_TOKEN="YOUR_SERVICE_TOKEN"

curl --get \
  "https://ai.mihajlo.mk/api/email-validator/v1/check-email" \
  --data-urlencode "token=${EMAIL_VALIDATOR_TEST_TOKEN}" \
  --data-urlencode "[email protected]"

Договорот за одговор обезбедува status, score, recommendation, checks и quota. Услугата ги разгледува синтаксата, информациите за доменот и MX, сигналите од давателите и практичниот ризик од испорака. Нашата граница ќе ги бара сите пет полиња пред да ѝ верува на препораката.

Зачувајте ги ингеренциите во Laravel конфигурацијата

Додадете ја тајната во .env. Никогаш не внесувајте вистинска вредност во commit:

EMAIL_VALIDATOR_TOKEN=YOUR_SERVICE_TOKEN
EMAIL_VALIDATOR_URL=https://ai.mihajlo.mk
EMAIL_VALIDATOR_CONNECT_TIMEOUT=2
EMAIL_VALIDATOR_TIMEOUT=4
EMAIL_VALIDATOR_RETRY_DELAY_MS=200

Додадете го овој запис во низата вратена од config/services.php:

'email_validator' => [
    'url' => env('EMAIL_VALIDATOR_URL', 'https://ai.mihajlo.mk'),
    'token' => env('EMAIL_VALIDATOR_TOKEN'),
    'connect_timeout' => (int) env('EMAIL_VALIDATOR_CONNECT_TIMEOUT', 2),
    'timeout' => (int) env('EMAIL_VALIDATOR_TIMEOUT', 4),
    'retry_delay_ms' => (int) env('EMAIL_VALIDATOR_RETRY_DELAY_MS', 200),
],

Кодот на апликацијата мора да чита од config(), а не директно да повикува env(). Така интеграцијата останува компатибилна со кешот за конфигурација на Laravel.

Изберете намерно конзервативна архитектура

Валидацијата на е-пошта тука е синхрона бидејќи одлуката може веднаш да го подобри одговорот за регистрација. Задача во редица би завршила предоцна за да запре јасно одбиена адреса и би додала оперативна сложеност без да го подобри корисничкото искуство.

Патеката на барањето има три исходи:

  • Прифати: одговорот е целосен и неговата препорака се совпаѓа со прифатениот речник на апликацијата.
  • Одбиј: одговорот е целосен и содржи препознаена негативна препорака.
  • Одложи: API-то истекува, достигнува квота или ограничувања на стапката, ја одбива автентикацијата, враќа неформатирани податоци, пријавува непрепознаен статус или доставува непозната препорака.

Само вториот исход ја блокира регистрацијата. Одложените корисници се креираат со состојба на ризик што чека обработка и можат да продолжат низ вообичаениот тек на апликацијата за потврда на е-пошта.

Примерот ги препознава accept/valid и reject/invalid како локални ознаки на политика. Непознатите ознаки безбедно се одложуваат. Проверете ги официјалните примери за одговор за вашата активирана услуга и приспособете ја оваа мала allowlist ако документираниот речник се разликува. Намерно не измислуваме праг за резултатот ниту толкуваме недокументирани клучеви во checks или quota.

Мапирајте го API одговорот во доменска одлука

Креирајте app/Domain/EmailAssessment.php. Маперот ги одбива делумните или необично типизираните товари на границата. Резултатот, проверките и метаподатоците за квотата мора да бидат присутни пред препораката да може да се примени; во спротивно исходот станува одложен.

<?php

declare(strict_types=1);

namespace App\Domain;

use UnexpectedValueException;

enum EmailVerdict: string
{
    case Accept = 'accept';
    case Reject = 'reject';
    case Defer = 'defer';
}

final readonly class EmailAssessment
{
    public function __construct(
        public EmailVerdict $verdict,
        public string $status,
        public ?float $score,
        public string $recommendation,
        public array $checks,
        public array $quota,
        public string $reason,
    ) {}

    public static function fromPayload(array $payload): self
    {
        foreach (['status', 'score', 'recommendation', 'checks', 'quota'] as $field) {
            if (!array_key_exists($field, $payload)) {
                throw new UnexpectedValueException("Missing field: {$field}");
            }
        }

        if (!is_string($payload['status'])
            || !is_string($payload['recommendation'])
            || !(is_int($payload['score']) || is_float($payload['score']))
            || !is_array($payload['checks'])
            || !is_array($payload['quota'])) {
            throw new UnexpectedValueException('Unexpected response types');
        }

        $status = strtolower(trim($payload['status']));
        $recommendation = strtolower(trim($payload['recommendation']));
        $score = (float) $payload['score'];

        if (!is_finite($score)
            || $payload['checks'] === []
            || $payload['quota'] === []
            || $status !== 'success') {
            return new self(
                EmailVerdict::Defer,
                $status,
                $score,
                $recommendation,
                $payload['checks'],
                $payload['quota'],
                'untrusted_response'
            );
        }

        $verdict = match ($recommendation) {
            'accept', 'valid' => EmailVerdict::Accept,
            'reject', 'invalid' => EmailVerdict::Reject,
            default => EmailVerdict::Defer,
        };

        return new self(
            $verdict,
            $status,
            $score,
            $recommendation,
            $payload['checks'],
            $payload['quota'],
            $verdict === EmailVerdict::Defer
                ? 'unknown_recommendation'
                : 'recommendation'
        );
    }

    public static function deferred(string $reason): self
    {
        return new self(
            EmailVerdict::Defer,
            'unavailable',
            null,
            '',
            [],
            [],
            $reason
        );
    }
}

Ова е анти-корупциски слој: контролерите никогаш не расудуваат за произволен JSON. Забележете дека резултатот учествува во довербата на одговорот, но не се споредува со недокументиран праг. Истата воздржаност важи за вгнездените полиња за проверки и квота.

Изградете ограничен, селективен HTTP клиент

Креирајте app/Services/EmailValidatorClient.php:

<?php

declare(strict_types=1);

namespace App\Services;

use App\Domain\EmailAssessment;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use UnexpectedValueException;

final class EmailValidatorClient
{
    public function check(string $email): EmailAssessment
    {
        $token = config('services.email_validator.token');

        if (!is_string($token) || $token === '') {
            Log::error('email_validator.configuration_missing');

            return EmailAssessment::deferred('configuration_missing');
        }

        $response = null;
        $delay = max(0, (int) config(
            'services.email_validator.retry_delay_ms',
            200
        ));

        for ($attempt = 1; $attempt <= 2; $attempt++) {
            try {
                $response = Http::baseUrl(
                        (string) config('services.email_validator.url')
                    )
                    ->acceptJson()
                    ->connectTimeout((int) config(
                        'services.email_validator.connect_timeout',
                        2
                    ))
                    ->timeout((int) config(
                        'services.email_validator.timeout',
                        4
                    ))
                    ->get('/api/email-validator/v1/check-email', [
                        'token' => $token,
                        'email' => $email,
                    ]);
            } catch (ConnectionException) {
                if ($attempt === 1) {
                    if ($delay > 0) {
                        usleep($delay * 1000);
                    }

                    continue;
                }

                Log::warning('email_validator.connection_failed');

                return EmailAssessment::deferred('connection_failed');
            }

            if ($attempt === 1
                && in_array($response->status(), [502, 503, 504], true)) {
                if ($delay > 0) {
                    usleep($delay * 1000);
                }

                continue;
            }

            break;
        }

        if ($response->status() === 429) {
            Log::warning('email_validator.quota_or_rate_limited');

            return EmailAssessment::deferred('quota_or_rate_limited');
        }

        if (in_array($response->status(), [401, 403], true)) {
            Log::error('email_validator.authentication_failed');

            return EmailAssessment::deferred('authentication_failed');
        }

        if (!$response->successful()) {
            Log::warning('email_validator.http_failure', [
                'http_status' => $response->status(),
            ]);

            return EmailAssessment::deferred('http_failure');
        }

        $payload = $response->json();

        if (!is_array($payload)) {
            return EmailAssessment::deferred('malformed_response');
        }

        try {
            return EmailAssessment::fromPayload($payload);
        } catch (UnexpectedValueException) {
            Log::warning('email_validator.contract_mismatch');

            return EmailAssessment::deferred('contract_mismatch');
        }
    }
}

Клиентот прави најмногу два обиди. Тој повторува неуспеси на врската и одговори 502, 503 и 504 од типот на gateway еднаш по кратко чекање. Не повторува неуспеси во автентикацијата, неформатирани барања или одговори 429. Непосредното повторување на тие барања троши капацитет и ретко го менува исходот.

Зачувајте ја постепената состојба

Додадете колона што разликува доверливо прифаќање од регистрација дозволена во услови на неизвесност:

php artisan make:migration add_email_risk_state_to_users_table
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->string('email_risk_state', 20)
                ->default('pending')
                ->index();
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table): void {
            $table->dropColumn('email_risk_state');
        });
    }
};

Подоцнежен закажан преглед може повторно да ги разгледа сметките што чекаат обработка, но самата регистрација не зависи од тоа идно подобрување.

Поврзете го контролерот за регистрација

Креирајте или приспособете app/Http/Controllers/RegisteredUserController.php:

<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Domain\EmailVerdict;
use App\Models\User;
use App\Services\EmailValidatorClient;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;

final class RegisteredUserController extends Controller
{
    public function store(
        Request $request,
        EmailValidatorClient $validator
    ): RedirectResponse {
        $data = $request->validate([
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email:rfc', 'max:255',
                'unique:users,email'],
            'password' => ['required', 'string', 'min:12', 'confirmed'],
        ]);

        $assessment = $validator->check($data['email']);

        if ($assessment->verdict === EmailVerdict::Reject) {
            throw ValidationException::withMessages([
                'email' => 'Please use another email address.',
            ]);
        }

        if ($assessment->verdict === EmailVerdict::Defer) {
            Log::notice('registration.email_validation_deferred', [
                'reason' => $assessment->reason,
            ]);
        }

        $user = new User();
        $user->name = $data['name'];
        $user->email = $data['email'];
        $user->password = Hash::make($data['password']);
        $user->email_risk_state =
            $assessment->verdict === EmailVerdict::Accept
                ? 'accepted'
                : 'pending';
        $user->save();

        event(new Registered($user));
        Auth::login($user);

        return redirect('/dashboard');
    }
}

Чувајте го надворешното барање надвор од трансакција на базата на податоци; ниту едно заклучување на базата не треба да остане отворено додека се чека мрежата. Настанот Registered исто така го зачувува нормалниот тек на Laravel за потврдена е-пошта кога моделот на корисникот имплементира потврда на е-пошта.

Регистрирајте ја крајната точка во routes/web.php, приспособувајќи ја ако комплет за почетници за автентикација веќе ги поседува овие рути:

use App\Http\Controllers\RegisteredUserController;
use Illuminate\Support\Facades\Route;

Route::view('/register', 'auth.register')
    ->middleware('guest')
    ->name('register');

Route::post('/register', [RegisteredUserController::class, 'store'])
    ->middleware(['guest', 'throttle:10,1'])
    ->name('register.store');

Докажете ја политиката за неуспех со автоматизирани тестови

Http::fake() на Laravel ги одржува тестовите детерминистички и спречува вистинско трошење на квотата. Додадете случаи како овие во tests/Feature/RegistrationTest.php:

<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class RegistrationTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        config([
            'services.email_validator.token' => 'test-token',
            'services.email_validator.retry_delay_ms' => 0,
        ]);
    }

    public function test_clear_rejection_blocks_registration(): void
    {
        Http::fake([
            'https://ai.mihajlo.mk/api/email-validator/v1/check-email*'
                => Http::response([
                    'status' => 'success',
                    'score' => 10,
                    'recommendation' => 'reject',
                    'checks' => ['completed' => true],
                    'quota' => ['present' => true],
                ], 200),
        ]);

        $response = $this->post('/register', $this->registrationData());

        $response->assertSessionHasErrors('email');
        $this->assertDatabaseCount('users', 0);
    }

    public function test_service_downtime_does_not_reject_user(): void
    {
        Http::fake([
            'https://ai.mihajlo.mk/api/email-validator/v1/check-email*'
                => Http::sequence()
                    ->push([], 503)
                    ->push([], 503),
        ]);

        $response = $this->post('/register', $this->registrationData());

        $response->assertRedirect('/dashboard');
        $this->assertDatabaseHas('users', [
            'email' => '[email protected]',
            'email_risk_state' => 'pending',
        ]);
    }

    public function test_complete_acceptance_is_recorded(): void
    {
        Http::fake([
            'https://ai.mihajlo.mk/api/email-validator/v1/check-email*'
                => Http::response([
                    'status' => 'success',
                    'score' => 92,
                    'recommendation' => 'accept',
                    'checks' => ['completed' => true],
                    'quota' => ['present' => true],
                ], 200),
        ]);

        $this->post('/register', $this->registrationData())
            ->assertRedirect('/dashboard');

        $this->assertDatabaseHas('users', [
            'email' => '[email protected]',
            'email_risk_state' => 'accepted',
        ]);
    }

    private function registrationData(): array
    {
        return [
            'name' => 'Example Reader',
            'email' => '[email protected]',
            'password' => 'a-long-test-password',
            'password_confirmation' => 'a-long-test-password',
        ];
    }
}

Овие товари се фикстури на политиката на апликацијата, а не тврдења за недокументирани вгнездени клучеви за проверки или квота. Додадете дополнителни тестови за 429, невалиден JSON, полиња што недостигаат, исклучоци при поврзување и непознати препораки.

Безбедност, набљудливост и распоредување

Токенот патува во параметар за пребарување бидејќи тоа е договорот за автентикација на услугата. HTTPS го штити при пренос, но URL-адресите сепак може да се појават во логови на proxy или tracing. Конфигурирајте ја инфраструктурата да ги редигира параметрите за пребарување и никогаш не ги логирајте URL-то на барањето, токенот, адресата на е-пошта или телото на одговорот. Структурираните настани погоре ги изложуваат оперативните причини без да изложуваат лични податоци.

Ограничете ја стапката на регистрација, задржете ја CSRF заштитата на Laravel, држете го хостот на крајната точка фиксиран во конфигурацијата и продолжете да барате вообичаена потврда на сопственоста на е-поштата. Валидацијата на ризикот и потврдата на сопственоста решаваат различни проблеми.

Набљудувајте ги броевите на одложени исходи по причина, неуспесите на автентикација, распределбата на HTTP статусите и процентот на сметки оставени во чекање. Ненадеен пораст на contract_mismatch заслужува истрага; траен пораст на одложувањата поврзани со квота може да укажува или на злоупотреба или на несоодветен план.

Распоредете ја миграцијата пред кодот што ја запишува новата колона. Доставете го токенот преку управувачот со тајни на хостинг-платформата, потоа извршете:

composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan test

По ротација на токен, ажурирајте ја тајната во околината и повторно извршете php artisan config:cache. Стара кеширана вредност ќе продолжи да предизвикува неуспеси на автентикацијата дури и кога основната променлива на околината е точна.

Вообичаени неуспеси што прво треба да ги проверите

  • 401 или 403 обично укажува на токен за услугата што недостига, е поништен или е неправилно копиран. Не го повторувајте во тесна јамка.
  • 429 треба да произведе регистрација во чекање, а не одбивање. Прегледајте ги информациите за квотата и шемите на сообраќај пред да менувате планови.
  • Повторените истекувања може да укажуваат на проблеми со DNS, firewall, излезен HTTPS или proxy. Држете го истекувањето ограничено додека истражувате.
  • Несовпаѓање на договорот значи дека телото нема задолжително поле од највисоко ниво или содржи неочекуван тип. Логирајте го настанот, а не чувствителното тело.
  • Ако секој исход е одложен, споредете го документираниот речник за status и recommendation со локалниот мапер на политики.

Конечна листа за проверка

  • Токенот постои само во конфигурација поддржана од околината.
  • Барањето ја користи точната GET крајна точка со URL-кодирани параметри token и email.
  • Истекувањата за поврзување и вкупен одговор се ограничени.
  • Само неуспесите на поврзување и привремените gateway неуспеси добиваат едно повторување.
  • Непознатите, неформатираните, ограничените од квота и недостапните одговори ја одложуваат регистрацијата.
  • Одбивање се случува само од целосен, препознаен одговор.
  • Логовите содржат причини и HTTP статусни кодови, но без токени, адреси, URL-адреси или тела.
  • Тестовите за функционалности покриваат прифаќање, одбивање и прекин на услугата.
  • Рачен тест во staging потврдува дека прекинот на API-то сепак создава корисник во чекање.

Зрелата интеграција не е онаа што прави најмногу API повици. Таа е онаа што точно знае колкава доверба да стави во секој одговор. Со тоа што неизвесноста станува доменски исход од прва класа, овој формулар за регистрација добива корисна заштита без привремените проблеми со зависната услуга да ги претвора во заклучена влезна врата.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.