From 016abb1ea3928d90c09e98900e3983ca7b055492 Mon Sep 17 00:00:00 2001 From: Rico Date: Mon, 2 Mar 2026 00:04:01 +0100 Subject: [PATCH] feat: implement boris --- .env.example | 2 + app/Services/BorisStaticDataClient.php | 163 ++++++++++++++++++ config/database.php | 8 +- config/services.php | 5 + ...4_12_31_223500_add_performance_indexes.php | 22 +++ database/seeders/ChampionRolesSeeder.php | 4 +- database/seeders/ChampionSeeder.php | 7 +- database/seeders/ChampionSkinSeeder.php | 7 +- database/seeders/SkinChromaSeeder.php | 7 +- 9 files changed, 205 insertions(+), 20 deletions(-) create mode 100644 app/Services/BorisStaticDataClient.php diff --git a/.env.example b/.env.example index 91ba170..21c7850 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,8 @@ APP_MAINTENANCE_STORE=database BCRYPT_ROUNDS=12 LMI_API_KEY= +BORIS_URL=https://boris.heimerdinger.lol +BORIS_API_KEY= APP_STAGING=false diff --git a/app/Services/BorisStaticDataClient.php b/app/Services/BorisStaticDataClient.php new file mode 100644 index 0000000..c295018 --- /dev/null +++ b/app/Services/BorisStaticDataClient.php @@ -0,0 +1,163 @@ +fetchWithFallback( + self::CHAMPIONS_ENDPOINT, + self::MERAKI_CHAMPIONS_URL, + fn (mixed $payload): bool => $this->isChampionPayload($payload) + ); + } + + public function getChampionRates(): array + { + return $this->fetchWithFallback( + self::CHAMPION_RATES_ENDPOINT, + self::MERAKI_CHAMPION_RATES_URL, + fn (mixed $payload): bool => $this->isChampionRatesPayload($payload) + ); + } + + private function fetchWithFallback(string $borisEndpoint, string $merakiUrl, callable $validator): array + { + try { + $payload = $this->fetchFromBoris($borisEndpoint); + + if ($validator($payload)) { + return $payload; + } + + $this->logInvalidPayload('boris', $borisEndpoint, 'Payload shape validation failed.'); + } catch (Throwable $exception) { + Log::warning('Boris static data request failed.', [ + 'source' => 'boris', + 'endpoint' => $borisEndpoint, + 'status' => $exception->getCode() ?: null, + 'message' => $exception->getMessage(), + ]); + } + + Log::warning('Falling back to Meraki static data.', [ + 'source' => 'meraki', + 'endpoint' => $merakiUrl, + 'message' => 'Boris request failed or returned invalid payload.', + ]); + + try { + $payload = $this->fetchFromMeraki($merakiUrl); + + if ($validator($payload)) { + Log::warning('Using Meraki static data fallback.', [ + 'source' => 'meraki', + 'endpoint' => $merakiUrl, + 'message' => 'Fallback request succeeded.', + ]); + + return $payload; + } + + $this->logInvalidPayload('meraki', $merakiUrl, 'Payload shape validation failed.'); + } catch (Throwable $exception) { + Log::error('Meraki static data fallback failed.', [ + 'source' => 'meraki', + 'endpoint' => $merakiUrl, + 'status' => $exception->getCode() ?: null, + 'message' => $exception->getMessage(), + ]); + + throw new RuntimeException( + sprintf('Unable to fetch static data from Boris or Meraki for [%s].', $borisEndpoint), + 0, + $exception + ); + } + + Log::error('Boris and Meraki returned invalid static data payloads.', [ + 'source' => 'meraki', + 'endpoint' => $merakiUrl, + 'status' => null, + 'message' => 'Payload shape validation failed for both sources.', + ]); + + throw new RuntimeException( + sprintf('Unable to validate static data payload from Boris or Meraki for [%s].', $borisEndpoint) + ); + } + + private function fetchFromBoris(string $endpoint): mixed + { + $response = Http::withHeaders([ + 'X-API-Key' => (string) config('services.boris.api_key'), + ])->get($this->borisUrl() . $endpoint); + + return $this->decodeResponse($response, $endpoint, 'boris'); + } + + private function fetchFromMeraki(string $url): mixed + { + $response = Http::get($url); + + return $this->decodeResponse($response, $url, 'meraki'); + } + + private function decodeResponse(Response $response, string $endpoint, string $source): mixed + { + if (! $response->successful()) { + throw new RuntimeException( + sprintf('%s request failed with status %d.', ucfirst($source), $response->status()), + $response->status() + ); + } + + $payload = $response->json(); + + if ($payload === null) { + throw new RuntimeException(sprintf('%s returned an invalid JSON response.', ucfirst($source))); + } + + return $payload; + } + + private function isChampionPayload(mixed $payload): bool + { + return is_array($payload) && array_is_list($payload); + } + + private function isChampionRatesPayload(mixed $payload): bool + { + return is_array($payload) && isset($payload['data']) && is_array($payload['data']); + } + + private function logInvalidPayload(string $source, string $endpoint, string $message): void + { + Log::warning('Static data payload validation failed.', [ + 'source' => $source, + 'endpoint' => $endpoint, + 'status' => null, + 'message' => $message, + ]); + } + + private function borisUrl(): string + { + return rtrim((string) config('services.boris.url'), '/'); + } +} diff --git a/config/database.php b/config/database.php index 6f2a889..d95c815 100644 --- a/config/database.php +++ b/config/database.php @@ -1,5 +1,7 @@ env('DB_CONNECTION', 'mysql'), @@ -25,10 +27,10 @@ return [ 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? [ + 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::ATTR_TIMEOUT => 15, - PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true, - ] : [], + class_exists(Mysql::class) ? Mysql::ATTR_USE_BUFFERED_QUERY : PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true, + ], static fn ($value) => $value !== null) : [], 'sticky' => true, ], ], diff --git a/config/services.php b/config/services.php index 8bff255..0f325c9 100644 --- a/config/services.php +++ b/config/services.php @@ -13,4 +13,9 @@ return [ 'api_key' => env('LMI_API_KEY'), ], + 'boris' => [ + 'url' => env('BORIS_URL', 'https://boris.heimerdinger.lol'), + 'api_key' => env('BORIS_API_KEY'), + ], + ]; diff --git a/database/migrations/2024_12_31_223500_add_performance_indexes.php b/database/migrations/2024_12_31_223500_add_performance_indexes.php index 672e89b..cc5322b 100644 --- a/database/migrations/2024_12_31_223500_add_performance_indexes.php +++ b/database/migrations/2024_12_31_223500_add_performance_indexes.php @@ -7,11 +7,33 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { private function indexExists(string $table, string $indexName): bool + { + return match (DB::getDriverName()) { + 'sqlite' => $this->sqliteIndexExists($table, $indexName), + default => $this->mysqlIndexExists($table, $indexName), + }; + } + + private function mysqlIndexExists(string $table, string $indexName): bool { $result = DB::select("SHOW INDEX FROM {$table} WHERE Key_name = ?", [$indexName]); + return count($result) > 0; } + private function sqliteIndexExists(string $table, string $indexName): bool + { + $indexes = DB::select("PRAGMA index_list('{$table}')"); + + foreach ($indexes as $index) { + if (($index->name ?? null) === $indexName) { + return true; + } + } + + return false; + } + public function up(): void { Schema::table('champion_skins', function (Blueprint $table) { diff --git a/database/seeders/ChampionRolesSeeder.php b/database/seeders/ChampionRolesSeeder.php index 010e9d1..54d6e1c 100644 --- a/database/seeders/ChampionRolesSeeder.php +++ b/database/seeders/ChampionRolesSeeder.php @@ -4,6 +4,7 @@ namespace Database\Seeders; use App\Models\Champion; use App\Models\ChampionRoles; +use App\Services\BorisStaticDataClient; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Log; @@ -15,8 +16,7 @@ class ChampionRolesSeeder extends Seeder */ public function run(): void { - $roleDataUrl = 'https://cdn.merakianalytics.com/riot/lol/resources/latest/en-US/championrates.json'; - $roleData = json_decode(file_get_contents($roleDataUrl), true); + $roleData = app(BorisStaticDataClient::class)->getChampionRates(); $changeCount = 0; foreach ($roleData['data'] as $championId => $roles) { diff --git a/database/seeders/ChampionSeeder.php b/database/seeders/ChampionSeeder.php index 7ace898..f65def5 100644 --- a/database/seeders/ChampionSeeder.php +++ b/database/seeders/ChampionSeeder.php @@ -3,9 +3,9 @@ namespace Database\Seeders; use App\Models\Champion; +use App\Services\BorisStaticDataClient; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class ChampionSeeder extends Seeder @@ -15,10 +15,7 @@ class ChampionSeeder extends Seeder */ public function run(): void { - $championData = Http::get('https://static.heimerdinger.lol/champions.json')->json(); - if (!is_array($championData)) { - $championData = Http::get('https://cdn.merakianalytics.com/riot/lol/resources/latest/en-US/champions.json')->json(); - } + $championData = app(BorisStaticDataClient::class)->getChampions(); $changeCount = 0; foreach ($championData as $champion) { diff --git a/database/seeders/ChampionSkinSeeder.php b/database/seeders/ChampionSkinSeeder.php index 17301c4..2086508 100644 --- a/database/seeders/ChampionSkinSeeder.php +++ b/database/seeders/ChampionSkinSeeder.php @@ -3,9 +3,9 @@ namespace Database\Seeders; use App\Models\ChampionSkin; +use App\Services\BorisStaticDataClient; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class ChampionSkinSeeder extends Seeder @@ -15,10 +15,7 @@ class ChampionSkinSeeder extends Seeder */ public function run(): void { - $championData = Http::get('https://static.heimerdinger.lol/champions.json')->json(); - if (!is_array($championData)) { - $championData = Http::get('https://cdn.merakianalytics.com/riot/lol/resources/latest/en-US/champions.json')->json(); - } + $championData = app(BorisStaticDataClient::class)->getChampions(); $changeCount = 0; foreach ($championData as $champion) { diff --git a/database/seeders/SkinChromaSeeder.php b/database/seeders/SkinChromaSeeder.php index 1e45adf..490f4c5 100644 --- a/database/seeders/SkinChromaSeeder.php +++ b/database/seeders/SkinChromaSeeder.php @@ -3,9 +3,9 @@ namespace Database\Seeders; use App\Models\SkinChroma; +use App\Services\BorisStaticDataClient; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class SkinChromaSeeder extends Seeder @@ -15,10 +15,7 @@ class SkinChromaSeeder extends Seeder */ public function run(): void { - $championData = Http::get('https://static.heimerdinger.lol/champions.json')->json(); - if (!is_array($championData)) { - $championData = Http::get('https://cdn.merakianalytics.com/riot/lol/resources/latest/en-US/champions.json')->json(); - } + $championData = app(BorisStaticDataClient::class)->getChampions(); $changeCount = 0; foreach ($championData as $champion) {