Proje dosyaları eklendi
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Contracts;
|
||||
|
||||
use App\Serp\Data\SerpQuery;
|
||||
use App\Serp\Data\SerpSubmission;
|
||||
use App\Serp\Data\SerpTaskResult;
|
||||
|
||||
interface AsyncSerpProvider extends SerpProvider
|
||||
{
|
||||
public function submit(SerpQuery $query): SerpSubmission;
|
||||
|
||||
public function collect(string $taskId, int $limit): SerpTaskResult;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Contracts;
|
||||
|
||||
use App\Serp\Data\SerpQuery;
|
||||
use App\Serp\Data\SerpResponse;
|
||||
|
||||
interface SerpProvider
|
||||
{
|
||||
public function key(): string;
|
||||
|
||||
public function supports(SerpQuery $query): bool;
|
||||
|
||||
public function search(SerpQuery $query): SerpResponse;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Data;
|
||||
|
||||
final readonly class ProviderHealth
|
||||
{
|
||||
public function __construct(public string $providerKey, public int $balanceMicros, public int $statusCode, public \DateTimeInterface $checkedAt) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Data;
|
||||
|
||||
final readonly class SerpQuery
|
||||
{
|
||||
public function __construct(public string $phrase, public string $searchEngine, public string $countryCode, public string $languageCode, public string $device, public int $limit) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Data;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class SerpResponse
|
||||
{
|
||||
public array $results;
|
||||
|
||||
public function __construct(public string $providerKey, array $results, public ?string $referenceId = null, int $limit = 100, public string|int|float|null $cost = null, public string $currency = 'USD', public ?int $statusCode = null)
|
||||
{
|
||||
if (count($results) > $limit) {
|
||||
throw new InvalidArgumentException('Sonuç limiti aşıldı.');
|
||||
}foreach ($results as $r) {
|
||||
if (! $r instanceof SerpResultData) {
|
||||
throw new InvalidArgumentException('Geçersiz sonuç verisi.');
|
||||
}
|
||||
}$positions = array_map(fn ($r) => $r->position, $results);
|
||||
if (count($positions) !== count(array_unique($positions))) {
|
||||
throw new InvalidArgumentException('Tekrarlanan sonuç pozisyonu.');
|
||||
}$this->results = array_values($results);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Data;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class SerpResultData
|
||||
{
|
||||
public function __construct(public int $position, public string $url, public ?string $title = null, public ?string $displayUrl = null, public ?string $snippet = null, public string $resultType = 'organic')
|
||||
{
|
||||
if ($position < 1) {
|
||||
throw new InvalidArgumentException('Position pozitif olmalıdır.');
|
||||
}
|
||||
if ($resultType !== 'organic') {
|
||||
throw new InvalidArgumentException('Desteklenmeyen sonuç türü.');
|
||||
}
|
||||
if (strlen($url) > 2048 || ! filter_var($url, FILTER_VALIDATE_URL) || ! in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) {
|
||||
throw new InvalidArgumentException('Geçersiz sonuç URL adresi.');
|
||||
}
|
||||
if (($title !== null && mb_strlen($title) > 500) || ($displayUrl !== null && mb_strlen($displayUrl) > 500) || ($snippet !== null && mb_strlen($snippet) > 5000)) {
|
||||
throw new InvalidArgumentException('Sonuç alanı izin verilen uzunluğu aşıyor.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Data;
|
||||
|
||||
final readonly class SerpSubmission
|
||||
{
|
||||
public function __construct(public string $providerKey, public string $taskId, public ?float $cost = null, public string $currency = 'USD', public ?int $statusCode = null) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Data;
|
||||
|
||||
final readonly class SerpTaskResult
|
||||
{
|
||||
private function __construct(public bool $ready, public ?SerpResponse $response, public ?float $cost, public string $currency, public ?int $statusCode) {}
|
||||
|
||||
public static function waiting(?int $statusCode = null): self
|
||||
{
|
||||
return new self(false, null, null, 'USD', $statusCode);
|
||||
}
|
||||
|
||||
public static function ready(SerpResponse $response, ?float $cost = null, string $currency = 'USD', ?int $statusCode = null): self
|
||||
{
|
||||
return new self(true, $response, $cost, $currency, $statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Exceptions;
|
||||
|
||||
class PermanentProviderException extends SerpProviderException
|
||||
{
|
||||
public function retryable(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function safeCode(): string
|
||||
{
|
||||
return 'provider_rejected';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Exceptions;
|
||||
|
||||
class ProviderAuthenticationException extends PermanentProviderException
|
||||
{
|
||||
public function safeCode(): string
|
||||
{
|
||||
return 'provider_configuration_error';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Exceptions;
|
||||
|
||||
class ProviderConfigurationException extends PermanentProviderException
|
||||
{
|
||||
public function safeCode(): string
|
||||
{
|
||||
return 'provider_not_configured';
|
||||
}
|
||||
|
||||
public function safeMessage(): string
|
||||
{
|
||||
return 'SERP kontrol hizmeti henüz yapılandırılmamış.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Exceptions;
|
||||
|
||||
class ProviderRateLimitException extends TransientProviderException
|
||||
{
|
||||
public function safeCode(): string
|
||||
{
|
||||
return 'provider_rate_limited';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Exceptions;
|
||||
|
||||
class ProviderTimeoutException extends TransientProviderException
|
||||
{
|
||||
public function safeCode(): string
|
||||
{
|
||||
return 'provider_timeout';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
abstract class SerpProviderException extends RuntimeException
|
||||
{
|
||||
abstract public function retryable(): bool;
|
||||
|
||||
abstract public function safeCode(): string;
|
||||
|
||||
public function safeMessage(): string
|
||||
{
|
||||
return 'SERP kontrolü şu anda tamamlanamadı.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Exceptions;
|
||||
|
||||
class TransientProviderException extends SerpProviderException
|
||||
{
|
||||
public function retryable(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function safeCode(): string
|
||||
{
|
||||
return 'provider_transient';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Exceptions;
|
||||
|
||||
class UnexpectedProviderResponseException extends PermanentProviderException
|
||||
{
|
||||
public function safeCode(): string
|
||||
{
|
||||
return 'provider_invalid_response';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Operations;
|
||||
|
||||
use App\Models\SerpOperationState;
|
||||
use Throwable;
|
||||
|
||||
final class ActivationManager
|
||||
{
|
||||
public function state(): ActivationState
|
||||
{
|
||||
try {
|
||||
$configured = ActivationState::tryFrom((string) config('serp.operation_mode'));
|
||||
if (! $configured) {
|
||||
return ActivationState::Disabled;
|
||||
}$db = SerpOperationState::query()->find(1);
|
||||
if ($db?->emergency_stopped_at) {
|
||||
return ActivationState::EmergencyStopped;
|
||||
}
|
||||
|
||||
return $configured;
|
||||
} catch (Throwable) {
|
||||
return ActivationState::Disabled;
|
||||
}
|
||||
}
|
||||
|
||||
public function allowsNormal(): bool
|
||||
{
|
||||
return config('serp.enabled') === true && config('serp.provider') === 'dataforseo' && $this->state() === ActivationState::Enabled;
|
||||
}
|
||||
|
||||
public function allowsCanary(): bool
|
||||
{
|
||||
return config('serp.enabled') === true && config('serp.provider') === 'dataforseo' && $this->state() === ActivationState::Canary;
|
||||
}
|
||||
|
||||
public function allowsPaid(bool $canary = false): bool
|
||||
{
|
||||
return $canary ? $this->allowsCanary() : $this->allowsNormal();
|
||||
}
|
||||
|
||||
public function assertPaid(bool $canary = false): void
|
||||
{
|
||||
if (! $this->allowsPaid($canary)) {
|
||||
throw new OperationBlockedException($this->state() === ActivationState::EmergencyStopped ? 'emergency_stopped' : 'activation_blocked');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Operations;
|
||||
|
||||
enum ActivationState: string
|
||||
{
|
||||
case Disabled = 'disabled';
|
||||
case ValidationOnly = 'validation_only';
|
||||
case Canary = 'canary';
|
||||
case Enabled = 'enabled';
|
||||
case EmergencyStopped = 'emergency_stopped';
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Operations;
|
||||
|
||||
use App\Models\SerpBudgetEntry;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class BudgetManager
|
||||
{
|
||||
public function __construct(private KillSwitch $kill, private OperationAuditLogger $audit, private RampUpPolicy $ramp) {}
|
||||
|
||||
public function reserve(string $key, string $mode, ?int $checkId = null): SerpBudgetEntry
|
||||
{
|
||||
return Cache::lock('serp-budget-global', 10)->block(5, function () use ($key, $mode, $checkId) {
|
||||
$result = DB::transaction(function () use ($key, $mode, $checkId) {
|
||||
if ($e = SerpBudgetEntry::where('reservation_key', $key)->lockForUpdate()->first()) {
|
||||
return $e;
|
||||
}
|
||||
|
||||
$this->ramp->assertAllowsNewPaid();
|
||||
$estimate = Money::micros((string) config('serp.estimated_costs.'.$mode));
|
||||
$day = SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfDay())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
|
||||
$month = SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfMonth())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
|
||||
if ($day + $estimate > Money::micros((string) config('serp.daily_budget_usd')) || $month + $estimate > Money::micros((string) config('serp.monthly_budget_usd'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SerpBudgetEntry::create(['reservation_key' => $key, 'serp_check_id' => $checkId, 'mode' => $mode, 'reserved_micros' => $estimate, 'currency' => 'USD', 'reserved_at' => now('UTC')]);
|
||||
});
|
||||
if ($result === false) {
|
||||
$this->kill->activate('budget_exceeded');
|
||||
$this->audit->record('budget_blocked', ['cost_micros' => Money::micros((string) config('serp.estimated_costs.'.$mode))]);
|
||||
throw new OperationBlockedException('budget_blocked');
|
||||
}
|
||||
|
||||
$this->audit->record('budget_reserved', ['mode' => $mode, 'cost_micros' => $result->reserved_micros]);
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
|
||||
public function reconcile(string $key, string|int|float|null $actual, ?string $taskId = null): void
|
||||
{
|
||||
DB::transaction(function () use ($key, $actual, $taskId) {
|
||||
$e = SerpBudgetEntry::where('reservation_key', $key)->lockForUpdate()->first();
|
||||
if (! $e || $e->reconciled_at) {
|
||||
return;
|
||||
}$e->forceFill(['provider_task_id' => $taskId, 'actual_micros' => Money::micros($actual), 'reconciled_at' => now('UTC')])->save();
|
||||
$this->audit->record('budget_reconciled', ['cost_micros' => $e->actual_micros]);
|
||||
});
|
||||
}
|
||||
|
||||
public function usage(): array
|
||||
{
|
||||
$daily = (int) SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfDay())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
|
||||
$monthly = (int) SerpBudgetEntry::where('reserved_at', '>=', now('UTC')->startOfMonth())->sum(DB::raw('CASE WHEN actual_micros IS NULL THEN reserved_micros ELSE actual_micros END'));
|
||||
|
||||
return ['daily_micros' => $daily, 'monthly_micros' => $monthly];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Operations;
|
||||
|
||||
use App\Serp\Exceptions\ProviderAuthenticationException;
|
||||
use App\Serp\Exceptions\ProviderConfigurationException;
|
||||
use App\Serp\Exceptions\SerpProviderException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class CircuitBreaker
|
||||
{
|
||||
public function __construct(private KillSwitch $kill, private OperationAuditLogger $audit) {}
|
||||
|
||||
public function assertClosed(): void
|
||||
{
|
||||
try {
|
||||
if (Cache::get('serp:circuit:open')) {
|
||||
throw new OperationBlockedException('circuit_open');
|
||||
}
|
||||
} catch (OperationBlockedException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable) {
|
||||
throw new OperationBlockedException('circuit_cache_unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
public function success(): void
|
||||
{
|
||||
Cache::forget('serp:circuit:failures');
|
||||
Cache::forget('serp:circuit:open');
|
||||
}
|
||||
|
||||
public function failure(SerpProviderException $e): void
|
||||
{
|
||||
if ($e instanceof ProviderAuthenticationException || $e instanceof ProviderConfigurationException) {
|
||||
Cache::put('serp:circuit:open', true, now()->addMinutes((int) config('serp.circuit_cooldown_minutes')));
|
||||
$this->kill->activate('provider_auth_configuration');
|
||||
$this->audit->record('circuit_opened', ['error_category' => $e->safeCode()]);
|
||||
|
||||
return;
|
||||
}$n = (int) Cache::increment('serp:circuit:failures');
|
||||
Cache::put('serp:circuit:failures', $n, now()->addMinutes((int) config('serp.circuit_window_minutes')));
|
||||
if ($n >= (int) config('serp.circuit_failure_threshold')) {
|
||||
Cache::put('serp:circuit:open', true, now()->addMinutes((int) config('serp.circuit_cooldown_minutes')));
|
||||
$this->audit->record('circuit_opened', ['error_category' => $e->safeCode()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function open(): bool
|
||||
{
|
||||
return (bool) Cache::get('serp:circuit:open');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Operations;
|
||||
|
||||
use App\Models\SerpOperationState;
|
||||
use App\Serp\Data\ProviderHealth;
|
||||
use App\Serp\Exceptions\UnexpectedProviderResponseException;
|
||||
use App\Serp\Providers\DataForSeo\DataForSeoClient;
|
||||
use App\Serp\Providers\DataForSeo\DataForSeoErrorClassifier;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class DataForSeoHealthService
|
||||
{
|
||||
public function __construct(private DataForSeoClient $client, private DataForSeoErrorClassifier $errors, private KillSwitch $kill, private OperationAuditLogger $audit) {}
|
||||
|
||||
public function validateConfig(): array
|
||||
{
|
||||
$configured = (string) config('serp.dataforseo.login') !== '' && (string) config('serp.dataforseo.password') !== '';
|
||||
$base = rtrim((string) config('serp.dataforseo.base_url'), '/');
|
||||
$parts = parse_url($base);
|
||||
if (! is_array($parts) || ($parts['scheme'] ?? null) !== 'https' || strtolower($parts['host'] ?? '') !== 'api.dataforseo.com') {
|
||||
throw new \InvalidArgumentException('Invalid provider endpoint.');
|
||||
}if ($configured) {
|
||||
$this->client->validateConfiguration();
|
||||
}
|
||||
foreach (['daily_budget_usd', 'monthly_budget_usd', 'low_balance_usd', 'critical_balance_usd'] as $key) {
|
||||
if (Money::micros((string) config('serp.'.$key)) <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid '.$key);
|
||||
}
|
||||
}
|
||||
|
||||
return ['provider' => 'dataforseo', 'host' => 'api.dataforseo.com', 'tls' => true, 'credentials_configured' => $configured, 'operation_mode' => app(ActivationManager::class)->state()->value];
|
||||
}
|
||||
|
||||
public function remote(bool $fresh = false): ProviderHealth
|
||||
{
|
||||
return Cache::remember('serp:dataforseo:health', $fresh ? 0 : (int) config('serp.health_ttl_seconds'), function () {
|
||||
$data = $this->client->userData();
|
||||
$status = $data['status_code'] ?? null;
|
||||
if (! is_int($status)) {
|
||||
throw new UnexpectedProviderResponseException('Health status missing.');
|
||||
}if ($status !== 20000) {
|
||||
throw $this->errors->api($status);
|
||||
}$task = $data['tasks'][0] ?? null;
|
||||
if (! is_array($task) || ($task['status_code'] ?? null) !== 20000) {
|
||||
throw new UnexpectedProviderResponseException('Health task malformed.');
|
||||
}$balance = $task['result'][0]['money']['balance'] ?? null;
|
||||
if (! is_int($balance) && ! is_float($balance) && ! is_string($balance)) {
|
||||
throw new UnexpectedProviderResponseException('Balance missing.');
|
||||
}$health = new ProviderHealth('dataforseo', Money::micros($balance), 20000, now('UTC'));
|
||||
SerpOperationState::query()->updateOrCreate(['id' => 1], ['last_health_at' => $health->checkedAt, 'last_balance_micros' => $health->balanceMicros, 'last_provider_status_code' => 20000, 'last_provider_success_at' => $health->checkedAt, 'last_error_category' => null]);
|
||||
if ($health->balanceMicros < Money::micros((string) config('serp.critical_balance_usd'))) {
|
||||
$this->kill->activate('critical_low_balance');
|
||||
} elseif ($health->balanceMicros < Money::micros((string) config('serp.low_balance_usd'))) {
|
||||
$this->audit->record('low_balance', ['cost_micros' => $health->balanceMicros]);
|
||||
}
|
||||
|
||||
return $health;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Operations;
|
||||
|
||||
use App\Models\SerpOperationState;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class KillSwitch
|
||||
{
|
||||
public function __construct(private OperationAuditLogger $audit) {}
|
||||
|
||||
public function active(): bool
|
||||
{
|
||||
return SerpOperationState::query()->find(1)?->emergency_stopped_at !== null;
|
||||
}
|
||||
|
||||
public function activate(string $reason, ?User $actor = null): void
|
||||
{
|
||||
DB::transaction(function () use ($reason, $actor) {
|
||||
$s = SerpOperationState::query()->lockForUpdate()->find(1) ?: new SerpOperationState(['id' => 1]);
|
||||
if (! $s->emergency_stopped_at) {
|
||||
$s->forceFill(['emergency_reason_code' => $reason, 'emergency_stopped_at' => now(), 'emergency_actor_id' => $actor?->id])->save();
|
||||
$this->audit->record('kill_switch_activated', ['reason' => $reason], $actor);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function clear(User $actor, string $reason): void
|
||||
{
|
||||
if (! $actor->hasRole('super_admin') || ! $actor->hasPermission('serp_operations.emergency_stop')) {
|
||||
throw new AuthorizationException;
|
||||
}DB::transaction(function () use ($actor, $reason) {
|
||||
$s = SerpOperationState::query()->lockForUpdate()->find(1);
|
||||
if ($s?->emergency_stopped_at) {
|
||||
$s->forceFill(['emergency_reason_code' => null, 'emergency_stopped_at' => null, 'emergency_actor_id' => null])->save();
|
||||
$this->audit->record('kill_switch_cleared', ['reason' => $reason], $actor);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Operations;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class Money
|
||||
{
|
||||
public static function micros(string|int|float|null $value): int
|
||||
{
|
||||
if ($value === null) {
|
||||
return 0;
|
||||
}$s = is_float($value) ? number_format($value, 6, '.', '') : (string) $value;
|
||||
if (! preg_match('/^-?\d+(?:\.\d{1,6})?$/', $s)) {
|
||||
throw new InvalidArgumentException('Invalid money value.');
|
||||
}$negative = str_starts_with($s, '-');
|
||||
$s = ltrim($s, '-');
|
||||
[$whole,$fraction] = array_pad(explode('.', $s, 2), 2, '');
|
||||
$result = ((int) $whole * 1000000) + (int) str_pad($fraction, 6, '0');
|
||||
|
||||
return $negative ? -$result : $result;
|
||||
}
|
||||
|
||||
public static function decimal(int $micros): string
|
||||
{
|
||||
$negative = $micros < 0;
|
||||
$micros = abs($micros);
|
||||
|
||||
return ($negative ? '-' : '').intdiv($micros, 1000000).'.'.str_pad((string) ($micros % 1000000), 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Operations;
|
||||
|
||||
use App\Models\SerpOperationAudit;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class OperationAuditLogger
|
||||
{
|
||||
public function record(string $action, array $context = [], ?User $actor = null, ?string $correlationId = null): void
|
||||
{
|
||||
$safe = array_intersect_key($context, array_flip(['check_ulid', 'provider', 'mode', 'task_reference', 'attempt', 'duration_ms', 'result_count', 'error_category', 'cost_micros', 'reason']));
|
||||
$id = $correlationId ?: Str::uuid()->toString();
|
||||
SerpOperationAudit::create(['actor_user_id' => $actor?->id, 'action' => $action, 'correlation_id' => $id, 'context' => $safe]);
|
||||
Log::info('serp_operation.'.$action, ['correlation_id' => $id] + $safe);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Operations;
|
||||
|
||||
use App\Serp\Exceptions\PermanentProviderException;
|
||||
|
||||
class OperationBlockedException extends PermanentProviderException
|
||||
{
|
||||
public function __construct(private readonly string $reason)
|
||||
{
|
||||
parent::__construct($reason);
|
||||
}
|
||||
|
||||
public function safeCode(): string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function safeMessage(): string
|
||||
{
|
||||
return 'SERP kontrol hizmeti şu anda kullanılamıyor.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Operations;
|
||||
|
||||
use App\Models\SerpBudgetEntry;
|
||||
use App\Models\SerpOperationState;
|
||||
|
||||
final class RampUpPolicy
|
||||
{
|
||||
public function __construct(private ActivationManager $activation) {}
|
||||
|
||||
public function assertAllowsNewPaid(): void
|
||||
{
|
||||
if ($this->activation->state() !== ActivationState::Enabled) {
|
||||
return;
|
||||
}$state = SerpOperationState::query()->lockForUpdate()->find(1);
|
||||
if (! $state) {
|
||||
throw new OperationBlockedException('ramp_state_missing');
|
||||
}
|
||||
|
||||
if (! $state->ramp_started_at) {
|
||||
$state->forceFill(['ramp_started_at' => now('UTC')])->save();
|
||||
}$age = $state->ramp_started_at->diffInMinutes(now('UTC'));
|
||||
$limit = $age < 60 ? (int) config('serp.ramp.first_hour_limit') : ($age < 360 ? (int) config('serp.ramp.six_hour_limit') : (int) config('serp.ramp.first_day_limit'));
|
||||
$count = SerpBudgetEntry::where('reserved_at', '>=', $state->ramp_started_at)->count();
|
||||
if ($count >= $limit) {
|
||||
throw new OperationBlockedException('ramp_up_limit');
|
||||
}
|
||||
}
|
||||
|
||||
public function status(): array
|
||||
{
|
||||
$state = SerpOperationState::find(1);
|
||||
if (! $state?->ramp_started_at) {
|
||||
return ['started_at' => null, 'age_minutes' => null, 'used' => 0, 'limit' => (int) config('serp.ramp.first_hour_limit')];
|
||||
}$age = $state->ramp_started_at->diffInMinutes(now('UTC'));
|
||||
$limit = $age < 60 ? (int) config('serp.ramp.first_hour_limit') : ($age < 360 ? (int) config('serp.ramp.six_hour_limit') : (int) config('serp.ramp.first_day_limit'));
|
||||
|
||||
return ['started_at' => $state->ramp_started_at->toAtomString(), 'age_minutes' => (int) $age, 'used' => SerpBudgetEntry::where('reserved_at', '>=', $state->ramp_started_at)->count(), 'limit' => $limit];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Operations;
|
||||
|
||||
use App\Models\SerpCheck;
|
||||
use App\Models\SerpOperationState;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class SerpOperationsMetrics
|
||||
{
|
||||
public function __construct(private ActivationManager $activation, private BudgetManager $budget, private CircuitBreaker $circuit, private RampUpPolicy $ramp) {}
|
||||
|
||||
public function summary(): array
|
||||
{
|
||||
$since = now('UTC')->subDay();
|
||||
$counts = SerpCheck::where('created_at', '>=', $since)->select('status', 'execution_mode', DB::raw('count(*) aggregate'))->groupBy('status', 'execution_mode')->get();
|
||||
$total = $counts->sum('aggregate');
|
||||
$completed = $counts->where('status', 'completed')->sum('aggregate');
|
||||
$state = SerpOperationState::find(1);
|
||||
|
||||
return ['activation_mode' => $this->activation->state()->value, 'kill_switch' => (bool) $state?->emergency_stopped_at, 'kill_reason' => $state?->emergency_reason_code, 'circuit_open' => $this->circuit->open(), 'last_health_at' => $state?->last_health_at, 'last_balance_micros' => $state?->last_balance_micros, 'last_error_category' => $state?->last_error_category, 'checks_24h' => $total, 'completed_24h' => $completed, 'failed_24h' => $counts->where('status', 'failed')->sum('aggregate'), 'success_percent' => $total ? round($completed * 100 / $total, 1) : 0, 'average_duration_ms' => (int) SerpCheck::where('created_at', '>=', $since)->whereNotNull('duration_ms')->avg('duration_ms'), 'average_polls' => (float) SerpCheck::where('created_at', '>=', $since)->avg('provider_poll_attempts'), 'stuck' => SerpCheck::whereIn('status', ['pending', 'running'])->where('updated_at', '<', now()->subMinutes((int) config('serp.stuck_after_minutes')))->count(), 'waiting_provider' => SerpCheck::where('status', 'running')->whereNotNull('provider_reference')->count(), 'budget' => $this->budget->usage(), 'ramp_up' => $this->ramp->status(), 'queue_backlog' => DB::table('jobs')->where('queue', config('serp.queue'))->count(), 'failed_jobs' => DB::table('failed_jobs')->count()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Providers\DataForSeo;
|
||||
|
||||
use App\Serp\Exceptions\ProviderConfigurationException;
|
||||
use App\Serp\Exceptions\UnexpectedProviderResponseException;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
final class DataForSeoClient
|
||||
{
|
||||
public function __construct(private DataForSeoErrorClassifier $errors) {}
|
||||
|
||||
public function validateConfiguration(): void
|
||||
{
|
||||
$this->credentialsAndBase();
|
||||
}
|
||||
|
||||
public function live(array $payload): array
|
||||
{
|
||||
return $this->request('post', '/v3/serp/google/organic/live/advanced', [$payload]);
|
||||
}
|
||||
|
||||
public function submit(array $payload): array
|
||||
{
|
||||
return $this->request('post', '/v3/serp/google/organic/task_post', [$payload]);
|
||||
}
|
||||
|
||||
public function collect(string $taskId): array
|
||||
{
|
||||
if (! preg_match('/^[0-9a-f-]{36}$/i', $taskId)) {
|
||||
throw new UnexpectedProviderResponseException('Invalid provider task id.');
|
||||
}
|
||||
|
||||
return $this->request('get', '/v3/serp/google/organic/task_get/advanced/'.$taskId);
|
||||
}
|
||||
|
||||
public function userData(): array
|
||||
{
|
||||
return $this->request('get', '/v3/appendix/user_data');
|
||||
}
|
||||
|
||||
private function credentialsAndBase(): array
|
||||
{
|
||||
$login = (string) config('serp.dataforseo.login');
|
||||
$password = (string) config('serp.dataforseo.password');
|
||||
$base = rtrim((string) config('serp.dataforseo.base_url'), '/');
|
||||
if ($login === '' || $password === '') {
|
||||
throw new ProviderConfigurationException('DataForSEO credentials are missing.');
|
||||
}$parts = parse_url($base);
|
||||
if (! is_array($parts) || ($parts['scheme'] ?? null) !== 'https' || strtolower($parts['host'] ?? '') !== 'api.dataforseo.com' || isset($parts['user'],$parts['pass'],$parts['query'],$parts['fragment']) || (($parts['path'] ?? '') !== '' && ($parts['path'] ?? '') !== '/')) {
|
||||
throw new ProviderConfigurationException('Invalid DataForSEO base URL.');
|
||||
}
|
||||
|
||||
return [$login, $password, $base];
|
||||
}
|
||||
|
||||
private function request(string $method, string $path, ?array $json = null): array
|
||||
{
|
||||
[$login,$password,$base] = $this->credentialsAndBase();
|
||||
try {
|
||||
$request = Http::acceptJson()->asJson()->withBasicAuth($login, $password)->connectTimeout((int) config('serp.dataforseo.connect_timeout'))->timeout((int) config('serp.dataforseo.timeout'))->withoutRedirecting();
|
||||
$response = $method === 'post' ? $request->post($base.$path, $json) : $request->get($base.$path);
|
||||
} catch (ConnectionException $e) {
|
||||
throw $this->errors->connection($e);
|
||||
}if (! $response->successful()) {
|
||||
throw $this->errors->http($response->status());
|
||||
}$data = $response->json();
|
||||
if (! is_array($data)) {
|
||||
throw new UnexpectedProviderResponseException('DataForSEO returned invalid JSON.');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Providers\DataForSeo;
|
||||
|
||||
use App\Serp\Exceptions\PermanentProviderException;
|
||||
use App\Serp\Exceptions\ProviderAuthenticationException;
|
||||
use App\Serp\Exceptions\ProviderConfigurationException;
|
||||
use App\Serp\Exceptions\ProviderRateLimitException;
|
||||
use App\Serp\Exceptions\ProviderTimeoutException;
|
||||
use App\Serp\Exceptions\SerpProviderException;
|
||||
use App\Serp\Exceptions\TransientProviderException;
|
||||
use App\Serp\Exceptions\UnexpectedProviderResponseException;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
|
||||
final class DataForSeoErrorClassifier
|
||||
{
|
||||
public function connection(ConnectionException $e): SerpProviderException
|
||||
{
|
||||
return str_contains(strtolower($e->getMessage()), 'timed out') ? new ProviderTimeoutException('DataForSEO request timed out.', previous: $e) : new TransientProviderException('DataForSEO connection failed.', previous: $e);
|
||||
}
|
||||
|
||||
public function http(int $s): SerpProviderException
|
||||
{
|
||||
return match (true) {
|
||||
$s === 401 || $s === 403 => new ProviderAuthenticationException('DataForSEO authentication failed.'),$s === 429 => new ProviderRateLimitException('DataForSEO HTTP rate limit.'),$s >= 500 => new TransientProviderException('DataForSEO server error.'),default => new PermanentProviderException('DataForSEO HTTP request rejected.')
|
||||
};
|
||||
}
|
||||
|
||||
public function api(int $s): SerpProviderException
|
||||
{
|
||||
return match (true) {
|
||||
$s === 40100 || $s === 40104 => new ProviderAuthenticationException('DataForSEO account is unavailable.'),$s === 40200 || $s === 40201 => new ProviderConfigurationException('DataForSEO billing is unavailable.'),in_array($s, [40101, 40103, 50301, 50303, 50304], true) => new TransientProviderException('DataForSEO temporary error.'),in_array($s, [50401, 50402], true) => new ProviderTimeoutException('DataForSEO processing timed out.'),$s === 42900 => new ProviderRateLimitException('DataForSEO rate limit.'),$s >= 40000 && $s < 50000 => new PermanentProviderException('DataForSEO rejected the task.'),$s >= 50000 => new TransientProviderException('DataForSEO service error.'),default => new UnexpectedProviderResponseException('Unexpected DataForSEO status code.')
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Providers\DataForSeo;
|
||||
|
||||
use App\Serp\Data\SerpQuery;
|
||||
use App\Serp\Exceptions\ProviderConfigurationException;
|
||||
|
||||
final class DataForSeoPayloadMapper
|
||||
{
|
||||
private const LOCATIONS = ['TR' => 2792, 'DE' => 2276, 'US' => 2840, 'GB' => 2826];
|
||||
|
||||
private const LANGUAGES = ['tr' => 'tr', 'de' => 'de', 'en' => 'en'];
|
||||
|
||||
private const DEVICES = ['desktop' => 'windows', 'mobile' => 'android'];
|
||||
|
||||
public function supports(SerpQuery $q): bool
|
||||
{
|
||||
return $q->searchEngine === 'google' && isset(self::LOCATIONS[strtoupper($q->countryCode)],self::LANGUAGES[strtolower($q->languageCode)],self::DEVICES[$q->device]);
|
||||
}
|
||||
|
||||
public function map(SerpQuery $q): array
|
||||
{
|
||||
if (! $this->supports($q)) {
|
||||
throw new ProviderConfigurationException('Unsupported DataForSEO target.');
|
||||
}
|
||||
|
||||
return ['keyword' => $q->phrase, 'location_code' => self::LOCATIONS[strtoupper($q->countryCode)], 'language_code' => self::LANGUAGES[strtolower($q->languageCode)], 'device' => $q->device, 'os' => self::DEVICES[$q->device], 'depth' => 10];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Providers\DataForSeo;
|
||||
|
||||
use App\Serp\Contracts\AsyncSerpProvider;
|
||||
use App\Serp\Data\SerpQuery;
|
||||
use App\Serp\Data\SerpResponse;
|
||||
use App\Serp\Data\SerpSubmission;
|
||||
use App\Serp\Data\SerpTaskResult;
|
||||
|
||||
final class DataForSeoProvider implements AsyncSerpProvider
|
||||
{
|
||||
public function __construct(private DataForSeoClient $client, private DataForSeoPayloadMapper $mapper, private DataForSeoResponseNormalizer $normalizer) {}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'dataforseo';
|
||||
}
|
||||
|
||||
public function supports(SerpQuery $q): bool
|
||||
{
|
||||
return $this->mapper->supports($q);
|
||||
}
|
||||
|
||||
public function search(SerpQuery $q): SerpResponse
|
||||
{
|
||||
return $this->normalizer->live($this->client->live($this->mapper->map($q)), 10);
|
||||
}
|
||||
|
||||
public function submit(SerpQuery $q): SerpSubmission
|
||||
{
|
||||
return $this->normalizer->submission($this->client->submit($this->mapper->map($q)));
|
||||
}
|
||||
|
||||
public function collect(string $taskId, int $limit): SerpTaskResult
|
||||
{
|
||||
return $this->normalizer->collection($this->client->collect($taskId), $taskId, min(10, $limit));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Providers\DataForSeo;
|
||||
|
||||
use App\Serp\Data\SerpResponse;
|
||||
use App\Serp\Data\SerpResultData;
|
||||
use App\Serp\Data\SerpSubmission;
|
||||
use App\Serp\Data\SerpTaskResult;
|
||||
use App\Serp\Exceptions\UnexpectedProviderResponseException;
|
||||
|
||||
final class DataForSeoResponseNormalizer
|
||||
{
|
||||
public function __construct(private DataForSeoErrorClassifier $errors) {}
|
||||
|
||||
public function live(array $data, int $limit): SerpResponse
|
||||
{
|
||||
$task = $this->task($data, [20000]);
|
||||
|
||||
return $this->response($task, $limit);
|
||||
}
|
||||
|
||||
public function submission(array $data): SerpSubmission
|
||||
{
|
||||
$task = $this->task($data, [20000, 20100]);
|
||||
$id = $task['id'] ?? null;
|
||||
if (! is_string($id) || ! preg_match('/^[0-9a-f-]{36}$/i', $id)) {
|
||||
throw new UnexpectedProviderResponseException('DataForSEO task id is missing.');
|
||||
}
|
||||
|
||||
return new SerpSubmission('dataforseo', $id, $this->cost($task, $data), 'USD', (int) $task['status_code']);
|
||||
}
|
||||
|
||||
public function collection(array $data, string $taskId, int $limit): SerpTaskResult
|
||||
{
|
||||
$this->envelope($data);
|
||||
$tasks = $data['tasks'];
|
||||
if ($tasks === [] || ! isset($tasks[0]) || ! is_array($tasks[0])) {
|
||||
return SerpTaskResult::waiting();
|
||||
}
|
||||
$task = $tasks[0];
|
||||
if (($task['id'] ?? null) !== $taskId) {
|
||||
throw new UnexpectedProviderResponseException('DataForSEO task id mismatch.');
|
||||
}
|
||||
$status = $task['status_code'] ?? null;
|
||||
if (! is_int($status)) {
|
||||
throw new UnexpectedProviderResponseException('DataForSEO task status is missing.');
|
||||
}
|
||||
if (in_array($status, [20100, 40601, 40602], true) || ! array_key_exists('result', $task) || $task['result'] === null) {
|
||||
return SerpTaskResult::waiting($status);
|
||||
}
|
||||
if ($status !== 20000) {
|
||||
throw $this->errors->api($status);
|
||||
}
|
||||
$response = $this->response($task, $limit);
|
||||
|
||||
return SerpTaskResult::ready($response, $this->cost($task, $data), 'USD', $status);
|
||||
}
|
||||
|
||||
private function envelope(array $data): void
|
||||
{
|
||||
$status = $data['status_code'] ?? null;
|
||||
if (! is_int($status)) {
|
||||
throw new UnexpectedProviderResponseException('DataForSEO envelope status is missing.');
|
||||
}if ($status !== 20000) {
|
||||
throw $this->errors->api($status);
|
||||
}if (! isset($data['tasks']) || ! is_array($data['tasks'])) {
|
||||
throw new UnexpectedProviderResponseException('DataForSEO tasks are missing.');
|
||||
}
|
||||
}
|
||||
|
||||
private function task(array $data, array $accepted): array
|
||||
{
|
||||
$this->envelope($data);
|
||||
$task = $data['tasks'][0] ?? null;
|
||||
if (! is_array($task) || ! is_int($task['status_code'] ?? null)) {
|
||||
throw new UnexpectedProviderResponseException('DataForSEO task is malformed.');
|
||||
}if (! in_array($task['status_code'], $accepted, true)) {
|
||||
throw $this->errors->api($task['status_code']);
|
||||
}
|
||||
|
||||
return $task;
|
||||
}
|
||||
|
||||
private function response(array $task, int $limit): SerpResponse
|
||||
{
|
||||
$result = $task['result'] ?? null;
|
||||
if (! is_array($result) || ! isset($result[0]) || ! is_array($result[0])) {
|
||||
throw new UnexpectedProviderResponseException('DataForSEO result is missing.');
|
||||
}
|
||||
$items = $result[0]['items'] ?? null;
|
||||
if (! is_array($items)) {
|
||||
throw new UnexpectedProviderResponseException('DataForSEO items are missing.');
|
||||
}
|
||||
$rows = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item) || ($item['type'] ?? null) !== 'organic') {
|
||||
continue;
|
||||
}$position = $item['rank_group'] ?? null;
|
||||
$url = $item['url'] ?? null;
|
||||
if (! is_int($position) || $position < 1 || $position > $limit || ! is_string($url) || isset($seen[$position])) {
|
||||
continue;
|
||||
}try {
|
||||
$rows[] = new SerpResultData($position, $url, $this->text($item['title'] ?? null, 500), $this->text($item['breadcrumb'] ?? null, 500), $this->text($item['description'] ?? null, 5000));
|
||||
$seen[$position] = true;
|
||||
} catch (\InvalidArgumentException) {
|
||||
}
|
||||
}
|
||||
usort($rows, fn ($a, $b) => $a->position <=> $b->position);
|
||||
|
||||
return new SerpResponse('dataforseo', $rows, is_string($task['id'] ?? null) ? $task['id'] : null, $limit, $this->cost($task, []), 'USD', is_int($task['status_code'] ?? null) ? $task['status_code'] : null);
|
||||
}
|
||||
|
||||
private function text(mixed $value, int $max): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mb_substr($value, 0, $max);
|
||||
}
|
||||
|
||||
private function cost(array $task, array $data): ?float
|
||||
{
|
||||
$cost = $task['cost'] ?? $data['cost'] ?? null;
|
||||
|
||||
return is_int($cost) || is_float($cost) ? (float) $cost : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp\Providers;
|
||||
|
||||
use App\Serp\Contracts\AsyncSerpProvider;
|
||||
use App\Serp\Data\SerpQuery;
|
||||
use App\Serp\Data\SerpResponse;
|
||||
use App\Serp\Data\SerpResultData;
|
||||
use App\Serp\Data\SerpSubmission;
|
||||
use App\Serp\Data\SerpTaskResult;
|
||||
use App\Serp\Exceptions\PermanentProviderException;
|
||||
use App\Serp\Exceptions\ProviderConfigurationException;
|
||||
use App\Serp\Exceptions\ProviderRateLimitException;
|
||||
use App\Serp\Exceptions\ProviderTimeoutException;
|
||||
|
||||
final class FakeSerpProvider implements AsyncSerpProvider
|
||||
{
|
||||
public static string $mode = 'success';
|
||||
|
||||
public static array $results = [];
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'fake';
|
||||
}
|
||||
|
||||
public function supports(SerpQuery $q): bool
|
||||
{
|
||||
return in_array($q->searchEngine, ['google', 'bing'], true);
|
||||
}
|
||||
|
||||
public function search(SerpQuery $q): SerpResponse
|
||||
{
|
||||
$this->guard();
|
||||
$this->errors();
|
||||
$results = self::$results ?: [new SerpResultData(1, 'https://example.com/result', 'Example', 'example.com', 'Deterministic test result')];
|
||||
|
||||
return new SerpResponse($this->key(), $results, 'fake-reference', $q->limit);
|
||||
}
|
||||
|
||||
public function submit(SerpQuery $q): SerpSubmission
|
||||
{
|
||||
$this->guard();
|
||||
$this->errors();
|
||||
|
||||
return new SerpSubmission($this->key(), '00000000-0000-0000-0000-000000000001', 0.0, 'USD', 20100);
|
||||
}
|
||||
|
||||
public function collect(string $taskId, int $limit): SerpTaskResult
|
||||
{
|
||||
$this->guard();
|
||||
$this->errors();
|
||||
if (self::$mode === 'waiting') {
|
||||
return SerpTaskResult::waiting(40601);
|
||||
}$response = $this->search(new SerpQuery('fake', 'google', 'TR', 'tr', 'desktop', $limit));
|
||||
$response = new SerpResponse($this->key(), $response->results, $taskId, $limit);
|
||||
|
||||
return SerpTaskResult::ready($response, 0.0, 'USD', 20000);
|
||||
}
|
||||
|
||||
private function guard(): void
|
||||
{
|
||||
if (! (app()->environment('testing') || (app()->environment('local') && config('serp.allow_fake_local')))) {
|
||||
throw new ProviderConfigurationException('Fake provider forbidden.');
|
||||
}
|
||||
}
|
||||
|
||||
private function errors(): void
|
||||
{
|
||||
if (self::$mode === 'timeout') {
|
||||
throw new ProviderTimeoutException('secret timeout');
|
||||
}if (self::$mode === 'rate_limit') {
|
||||
throw new ProviderRateLimitException('secret rate');
|
||||
}if (self::$mode === 'permanent') {
|
||||
throw new PermanentProviderException('secret permanent');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Serp;
|
||||
|
||||
use App\Serp\Contracts\SerpProvider;
|
||||
use App\Serp\Exceptions\ProviderConfigurationException;
|
||||
|
||||
final class SerpProviderRegistry
|
||||
{
|
||||
private array $providers = [];
|
||||
|
||||
public function register(SerpProvider $provider): void
|
||||
{
|
||||
$this->providers[$provider->key()] = $provider;
|
||||
}
|
||||
|
||||
public function resolve(?string $key = null): SerpProvider
|
||||
{
|
||||
$key = $key ?: config('serp.provider');
|
||||
if (! $key || ! isset($this->providers[$key])) {
|
||||
throw new ProviderConfigurationException('Unknown provider.');
|
||||
}
|
||||
|
||||
return $this->providers[$key];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user