diff --git a/app/Console/Commands/RefreshIntegrationCache.php b/app/Console/Commands/RefreshIntegrationCache.php new file mode 100644 index 0000000..36c545d --- /dev/null +++ b/app/Console/Commands/RefreshIntegrationCache.php @@ -0,0 +1,58 @@ +forceRefresh = true; + + if (($connection = EtsyConnection::first()) && $etsy->configured()) { + $this->refresh('etsy receipts', fn () => $etsy->receipts($connection)); + $this->refresh('etsy listings', fn () => $etsy->listings($connection)); + } + + $this->refresh('parcel2go', function () use ($parcel2Go): void { + $parcel2Go->verifyCredentials(); + foreach (['me/orders/detail', 'me/detail', 'me/prepay/balance'] as $path) { + $this->refresh('parcel2go '.$path, fn () => $parcel2Go->fetch($path)); + } + }); + + if ($bambuddy->configured()) { + $this->refresh('bambuddy printers', fn () => $bambuddy->printers()); + } + + if ($dryer->configured()) { + $this->refresh('dryer status', fn () => $dryer->status()); + $this->refresh('dryer history', fn () => $dryer->history()); + } + + return self::SUCCESS; + } + + private function refresh(string $label, Closure $fetch): void + { + try { + $fetch(); + $this->line($label.': refreshed'); + } catch (Throwable $exception) { + $this->warn($label.': '.$exception->getMessage()); + } + } +} diff --git a/app/Http/Controllers/ShippingController.php b/app/Http/Controllers/ShippingController.php index 0152e84..140bccb 100644 --- a/app/Http/Controllers/ShippingController.php +++ b/app/Http/Controllers/ShippingController.php @@ -92,7 +92,7 @@ class ShippingController extends Controller 'height' => ['required', 'numeric', 'gt:0', 'lte:200'], ]); try { - $receipt = collect($this->pendingOrders($etsy))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $details['receipt_id']); + $receipt = collect($this->pendingOrders($etsy, true))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $details['receipt_id']); if (! $receipt) { return redirect()->route('shipping')->withErrors(['quote' => 'This Etsy order is no longer pending. Refresh the order list.']); } @@ -184,7 +184,7 @@ class ShippingController extends Controller } try { - $receipt = collect($this->pendingOrders($etsy))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $request->session()->get('shipping_receipt_id')); + $receipt = collect($this->pendingOrders($etsy, true))->first(fn (array $order): bool => (string) ($order['receipt_id'] ?? '') === (string) $request->session()->get('shipping_receipt_id')); if (! $receipt || ($receipt['zip'] ?? null) !== $details['delivery_postcode']) { return redirect()->route('shipping')->withErrors(['collection' => 'The Etsy order or delivery postcode changed. Request a fresh quote.']); } @@ -236,10 +236,10 @@ class ShippingController extends Controller } /** @return array> */ - private function pendingOrders(Etsy $etsy): array + private function pendingOrders(Etsy $etsy, bool $fresh = false): array { $connection = EtsyConnection::first(); - return $connection && $etsy->configured() ? ($etsy->receipts($connection)['results'] ?? []) : []; + return $connection && $etsy->configured() ? ($etsy->receipts($connection, $fresh)['results'] ?? []) : []; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..acbef09 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Services\IntegrationCache; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -11,7 +12,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(IntegrationCache::class); } /** diff --git a/app/Services/Bambuddy.php b/app/Services/Bambuddy.php index 988e299..ff8aedc 100644 --- a/app/Services/Bambuddy.php +++ b/app/Services/Bambuddy.php @@ -8,7 +8,7 @@ use Throwable; class Bambuddy { - public function __construct(private IntegrationSettings $settings) {} + public function __construct(private IntegrationSettings $settings, private IntegrationCache $cache) {} public function url(): string { @@ -26,6 +26,11 @@ class Bambuddy throw new RuntimeException('BamBuddy API key is not configured.'); } + return $this->cache->remember('bambuddy.printers.'.md5($this->url()), 45, fn (): array => $this->fetchPrinters()); + } + + private function fetchPrinters(): array + { $url = rtrim($this->url(), '/').'/api/v1/printers'; $apiKey = $this->settings->for('bambuddy')['api_key']; diff --git a/app/Services/DryerClient.php b/app/Services/DryerClient.php index 2d898c4..c0d5d60 100644 --- a/app/Services/DryerClient.php +++ b/app/Services/DryerClient.php @@ -7,7 +7,7 @@ use RuntimeException; class DryerClient { - public function __construct(private IntegrationSettings $settings) {} + public function __construct(private IntegrationSettings $settings, private IntegrationCache $cache) {} public function configured(): bool { @@ -45,14 +45,14 @@ class DryerClient public function status(): array { - return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson() - ->connectTimeout(3)->timeout(7)->get($this->url('status'))->throw()->json() ?? []; + return $this->cache->remember('dryer.status', 20, fn (): array => Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson() + ->connectTimeout(3)->timeout(7)->get($this->url('status'))->throw()->json() ?? []); } public function history(): array { - return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson() - ->connectTimeout(3)->timeout(7)->get($this->url('history'), ['limit' => 60])->throw()->json() ?? []; + return $this->cache->remember('dryer.history', 120, fn (): array => Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson() + ->connectTimeout(3)->timeout(7)->get($this->url('history'), ['limit' => 60])->throw()->json() ?? []); } public function start(string $profile, int $targetTemperature, int $dryingTime): array @@ -74,6 +74,9 @@ class DryerClient private function command(string $path, array $payload = []): array { + $this->cache->forget('dryer.status'); + $this->cache->forget('dryer.history'); + return Http::withHeaders(['X-API-Key' => $this->apiKey()])->acceptJson() ->connectTimeout(3)->timeout(7)->post($this->url($path), [ 'requestId' => 'dashboard-'.bin2hex(random_bytes(12)), ...$payload, diff --git a/app/Services/Etsy.php b/app/Services/Etsy.php index 630aa76..66f7586 100644 --- a/app/Services/Etsy.php +++ b/app/Services/Etsy.php @@ -11,6 +11,8 @@ class Etsy { private const API_URL = 'https://api.etsy.com/v3'; + public function __construct(private IntegrationCache $cache) {} + /** @var array{keystring: ?string, shared_secret: ?string, shop_name: string, redirect_uri: ?string}|null */ private ?array $credentials = null; @@ -62,18 +64,20 @@ class Etsy ]); } - public function receipts(EtsyConnection $connection): array + public function receipts(EtsyConnection $connection, bool $fresh = false): array { - return $this->get('application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), [ - 'was_paid' => 'true', 'was_shipped' => 'false', 'limit' => 20, - ]); + return $this->cache->remember('etsy.receipts.'.$connection->shop_id, 300, fn (): array => $this->get( + 'application/shops/'.$connection->shop_id.'/receipts', $this->accessToken($connection), [ + 'was_paid' => 'true', 'was_shipped' => 'false', 'limit' => 20, + ]), $fresh); } public function listings(EtsyConnection $connection): array { - return $this->get('application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [ - 'state' => 'active', 'limit' => 20, - ]); + return $this->cache->remember('etsy.listings.'.$connection->shop_id, 600, fn (): array => $this->get( + 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), [ + 'state' => 'active', 'limit' => 20, + ])); } private function accessToken(EtsyConnection $connection): string diff --git a/app/Services/IntegrationCache.php b/app/Services/IntegrationCache.php new file mode 100644 index 0000000..4b6f200 --- /dev/null +++ b/app/Services/IntegrationCache.php @@ -0,0 +1,68 @@ +forceRefresh; + + if (! $bypass && is_array($entry) && time() < $entry['fetched_at'] + $freshSeconds) { + return $entry['value']; + } + if (! $bypass && ! is_array($entry) && ($failure = Cache::get($cacheKey.':failed'))) { + throw new RuntimeException((string) $failure); + } + + try { + $value = $fetch(); + } catch (Throwable $exception) { + if (is_array($entry)) { + report($exception); + + return $entry['value']; + } + Cache::put($cacheKey.':failed', $exception->getMessage(), self::FAILURE_COOLDOWN_SECONDS); + + throw $exception; + } + + Cache::put($cacheKey, ['value' => $value, 'fetched_at' => time()], self::RETAIN_SECONDS); + Cache::forget($cacheKey.':failed'); + + return $value; + } + + public function forget(string $key): void + { + Cache::forget('integration:'.$key); + Cache::forget('integration:'.$key.':failed'); + } +} diff --git a/app/Services/Parcel2Go.php b/app/Services/Parcel2Go.php index ea49769..feaa340 100644 --- a/app/Services/Parcel2Go.php +++ b/app/Services/Parcel2Go.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\IntegrationSetting; use Illuminate\Http\Client\Response; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use RuntimeException; @@ -19,7 +20,7 @@ class Parcel2Go private ?string $profileToken = null; - public function __construct(private IntegrationSettings $settings) {} + public function __construct(private IntegrationSettings $settings, private IntegrationCache $cache) {} public function hasScope(string $scope): bool { @@ -95,12 +96,13 @@ class Parcel2Go public function fetch(string $path): array { - $token = str_starts_with(ltrim($path, '/'), 'me/') && $this->profileConnected() - ? $this->profileAccessToken() - : $this->accessToken(); + $usesProfile = str_starts_with(ltrim($path, '/'), 'me/') && $this->profileConnected(); + $settings = $this->settings->for('parcel2go'); + $key = 'parcel2go.'.($usesProfile ? 'profile' : 'client').'.'.md5(implode('|', [$this->url($path), $settings['client_id'] ?? '', $settings['profile_client_id'] ?? ''])); - return Http::withToken($token)->acceptJson()->connectTimeout(3)->timeout(15) - ->get($this->url($path))->throw()->json() ?? []; + return $this->cache->remember($key, 300, fn (): array => Http::withToken($usesProfile ? $this->profileAccessToken() : $this->accessToken()) + ->acceptJson()->connectTimeout(3)->timeout(15) + ->get($this->url($path))->throw()->json() ?? []); } public function quote(array $details): array @@ -240,6 +242,14 @@ class Parcel2Go throw new RuntimeException('Parcel2Go credentials are not configured.'); } + $tokenKey = 'parcel2go.token.'.md5(implode('|', [$settings['auth_url'], $settings['client_id'], $settings['client_secret'], $settings['scope'] ?? ''])); + $cached = Cache::get($tokenKey); + if (is_array($cached) && filled($cached['token'] ?? null)) { + $this->activeScope = $cached['scope']; + + return $this->token = $cached['token']; + } + $scope = (string) ($settings['scope'] ?? 'public-api'); $response = $this->tokenResponse($settings, $scope); if ($scope !== 'public-api' && $response->status() === 400 && $response->json('error') === 'invalid_scope') { @@ -254,6 +264,7 @@ class Parcel2Go } $this->activeScope = $scope; + Cache::put($tokenKey, ['token' => $token, 'scope' => $scope], max(60, (int) $response->json('expires_in', 3600) - 120)); return $this->token = $token; } diff --git a/routes/console.php b/routes/console.php index b3d9bbc..833c00b 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1 +1,8 @@ everyThirtySeconds()->withoutOverlapping(5); + +DevCommands::artisan('schedule:work', 'schedule'); diff --git a/tests/Unit/IntegrationCacheTest.php b/tests/Unit/IntegrationCacheTest.php new file mode 100644 index 0000000..bc5143d --- /dev/null +++ b/tests/Unit/IntegrationCacheTest.php @@ -0,0 +1,72 @@ + $calls]; + }; + + $this->assertSame(['call' => 1], $cache->remember('orders', 60, $fetch)); + $this->assertSame(['call' => 1], $cache->remember('orders', 60, $fetch)); + $this->assertSame(['call' => 2], $cache->remember('orders', 60, $fetch, true)); + $this->assertSame(2, $calls); + } + + public function test_last_good_value_is_served_when_the_api_fails_after_expiry(): void + { + $cache = new IntegrationCache; + $cache->remember('orders', 0, fn (): array => ['ok' => true]); + + $this->assertSame(['ok' => true], $cache->remember('orders', 0, function (): never { + throw new RuntimeException('API down'); + })); + } + + public function test_failures_with_no_cached_value_are_not_retried_during_cooldown(): void + { + $cache = new IntegrationCache; + $calls = 0; + $failing = function () use (&$calls): never { + $calls++; + + throw new RuntimeException('API down'); + }; + + foreach (range(1, 3) as $attempt) { + try { + $cache->remember('orders', 60, $failing); + } catch (RuntimeException) { + } + } + + $this->assertSame(1, $calls); + } + + public function test_refresh_flag_bypasses_fresh_entries(): void + { + $cache = new IntegrationCache; + $cache->remember('orders', 60, fn (): array => ['v' => 1]); + $cache->forceRefresh = true; + + $this->assertSame(['v' => 2], $cache->remember('orders', 60, fn (): array => ['v' => 2])); + } +}