diff --git a/README.md b/README.md index 535a426..4d97eb4 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,18 @@ $client->graphql( You can view the tests for more examples. +### Checking if Magento is available + +This client keeps track whether Magento is available by storing the count of any >=502 and <=504 response code in cache. +You may use the `available()` method on the client in order to check if Magento is available. + +The threshold, timespan and status codes can be configured in the configuration file per connection. + +#### Jobs + +This package provides a job middleware that releases jobs back onto the queue when Magento is not available. +The middleware is located at `\JustBetter\MagentoClient\Jobs\Middleware\AvailableMiddleware`. + ## Testing This package uses Laravel's HTTP client so that you can fake the requests. diff --git a/config/magento.php b/config/magento.php index 70f59cf..ca02557 100644 --- a/config/magento.php +++ b/config/magento.php @@ -32,6 +32,21 @@ /* Authentication method, choose either "oauth" or "token". */ 'authentication_method' => env('MAGENTO_AUTH_METHOD', 'token'), + + /* Availability configuration. */ + 'availability' => [ + /* The response codes that should trigger the availability check. */ + 'codes' => [502, 503, 504], + + /* The amount of failed requests before the service is marked as unavailable. */ + 'threshold' => 10, + + /* The timespan in minutes in which the failed requests should occur. */ + 'timespan' => 10, + + /* The cooldown in minutes after the threshold is reached. */ + 'cooldown' => 2, + ], ], ], diff --git a/phpstan.neon b/phpstan.neon index ae42faf..ffdbf4b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,4 +7,5 @@ parameters: - src - tests level: 8 - checkMissingIterableValueType: false + ignoreErrors: + - identifier: missingType.iterableValue diff --git a/src/Actions/CheckMagento.php b/src/Actions/CheckMagento.php new file mode 100644 index 0000000..cc04ea6 --- /dev/null +++ b/src/Actions/CheckMagento.php @@ -0,0 +1,20 @@ +get(static::AVAILABLE_KEY.$connection, true); + } + + public static function bind(): void + { + app()->singleton(ChecksMagento::class, static::class); + } +} diff --git a/src/Client/Magento.php b/src/Client/Magento.php index 2fa9da8..e93a0e9 100644 --- a/src/Client/Magento.php +++ b/src/Client/Magento.php @@ -9,6 +9,7 @@ use Illuminate\Support\Enumerable; use Illuminate\Support\LazyCollection; use JustBetter\MagentoClient\Contracts\BuildsRequest; +use JustBetter\MagentoClient\Contracts\ChecksMagento; use JustBetter\MagentoClient\Events\MagentoResponseEvent; use JustBetter\MagentoClient\OAuth\KeyStore\FileKeyStore; @@ -21,7 +22,8 @@ class Magento public ?Closure $interceptor; public function __construct( - protected BuildsRequest $request + protected BuildsRequest $request, + protected ChecksMagento $checksMagento, ) { $this->connection = config('magento.connection'); } @@ -185,6 +187,11 @@ public function lazy(string $path, array $data = [], int $pageSize = 100): LazyC }); } + public function available(): bool + { + return $this->checksMagento->available($this->connection); + } + public function getUrl(string $path, bool $async = false, bool $bulk = false): string { /** @var array $config */ @@ -230,7 +237,7 @@ protected function request(): PendingRequest protected function handleResponse(Response $response): Response { - MagentoResponseEvent::dispatch($response); + MagentoResponseEvent::dispatch($response, $this->connection); return $response; } diff --git a/src/Contracts/ChecksMagento.php b/src/Contracts/ChecksMagento.php new file mode 100644 index 0000000..3edb6ec --- /dev/null +++ b/src/Contracts/ChecksMagento.php @@ -0,0 +1,8 @@ +connection = $connection ?? config('magento.connection'); + $this->seconds = $seconds; + } + + public function handle(object $job, Closure $next): void + { + /** @var Magento $magento */ + $magento = app(Magento::class); + $magento->connection($this->connection); + + if ($magento->available()) { + $next($job); + } elseif (method_exists($job, 'release')) { + $job->release($this->seconds); + } + } +} diff --git a/src/Listeners/StoreAvailabilityListener.php b/src/Listeners/StoreAvailabilityListener.php new file mode 100644 index 0000000..31c799f --- /dev/null +++ b/src/Listeners/StoreAvailabilityListener.php @@ -0,0 +1,44 @@ + $codes */ + $codes = config('magento.connections.'.$event->connection.'.availability.codes', [502, 503, 504]); + + if (! in_array($event->response->status(), $codes)) { + return; + } + + $countKey = static::COUNT_KEY.$event->connection; + + /** @var int $count */ + $count = cache()->get($countKey, 0); + $count++; + + /** @var int $threshold */ + $threshold = config('magento.connections.'.$event->connection.'.availability.threshold', 10); + + /** @var int $timespan */ + $timespan = config('magento.connections.'.$event->connection.'.availability.timespan', 10); + + /** @var int $cooldown */ + $cooldown = config('magento.connections.'.$event->connection.'.availability.cooldown', 2); + + cache()->put($countKey, $count, now()->addMinutes($timespan)); + + if ($count >= $threshold) { + cache()->put(CheckMagento::AVAILABLE_KEY.$event->connection, false, now()->addMinutes($cooldown)); + + cache()->forget($countKey); + } + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 904e65d..38083cc 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -2,12 +2,16 @@ namespace JustBetter\MagentoClient; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider as BaseServiceProvider; use JustBetter\MagentoClient\Actions\AuthenticateRequest; use JustBetter\MagentoClient\Actions\BuildRequest; +use JustBetter\MagentoClient\Actions\CheckMagento; use JustBetter\MagentoClient\Actions\OAuth\RequestAccessToken; +use JustBetter\MagentoClient\Events\MagentoResponseEvent; use JustBetter\MagentoClient\Http\Middleware\OAuthMiddleware; +use JustBetter\MagentoClient\Listeners\StoreAvailabilityListener; class ServiceProvider extends BaseServiceProvider { @@ -30,6 +34,7 @@ protected function registerActions(): static RequestAccessToken::bind(); AuthenticateRequest::bind(); BuildRequest::bind(); + CheckMagento::bind(); return $this; } @@ -38,6 +43,7 @@ public function boot(): void { $this ->bootConfig() + ->bootEvents() ->bootRoutes() ->bootMigrations(); } @@ -51,9 +57,16 @@ protected function bootConfig(): static return $this; } + protected function bootEvents(): static + { + Event::listen(MagentoResponseEvent::class, StoreAvailabilityListener::class); + + return $this; + } + protected function bootRoutes(): static { - if (! $this->app->routesAreCached()) { + if (! app()->routesAreCached()) { Route::prefix(config('magento.oauth.prefix')) ->middleware([OAuthMiddleware::class]) ->group(__DIR__.'/../routes/web.php'); diff --git a/tests/Actions/CheckMagentoTest.php b/tests/Actions/CheckMagentoTest.php new file mode 100644 index 0000000..c51849c --- /dev/null +++ b/tests/Actions/CheckMagentoTest.php @@ -0,0 +1,43 @@ +assertTrue($action->available('default')); + } + + #[Test] + public function it_can_be_unavailable(): void + { + /** @var CheckMagento $action */ + $action = app(CheckMagento::class); + + cache()->put(CheckMagento::AVAILABLE_KEY.config('magento.connection'), false); + + $this->assertFalse($action->available('default')); + } + + #[Test] + public function it_can_handle_multiple_connections(): void + { + /** @var CheckMagento $action */ + $action = app(CheckMagento::class); + + cache()->put(CheckMagento::AVAILABLE_KEY.'connection', false); + cache()->put(CheckMagento::AVAILABLE_KEY.'another-connection', true); + + $this->assertFalse($action->available('connection')); + $this->assertTrue($action->available('another-connection')); + } +} diff --git a/tests/Client/ClientTest.php b/tests/Client/ClientTest.php index 8627b65..5d230da 100644 --- a/tests/Client/ClientTest.php +++ b/tests/Client/ClientTest.php @@ -8,8 +8,10 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use JustBetter\MagentoClient\Client\Magento; +use JustBetter\MagentoClient\Contracts\ChecksMagento; use JustBetter\MagentoClient\Events\MagentoResponseEvent; use JustBetter\MagentoClient\Tests\TestCase; +use Mockery\MockInterface; class ClientTest extends TestCase { @@ -592,4 +594,18 @@ public function test_it_dispatches_event(): void return $event->response->ok() && $event->response->json('items') === ['item']; }); } + + public function test_it_checks_available(): void + { + $this->mock(ChecksMagento::class, function (MockInterface $mock): void { + $mock->shouldReceive('available')->with('default')->once()->andReturnTrue(); + $mock->shouldReceive('available')->with('unavailable')->once()->andReturnFalse(); + }); + + /** @var Magento $magento */ + $magento = app(Magento::class); + + $this->assertTrue($magento->available()); + $this->assertFalse($magento->connection('unavailable')->available()); + } } diff --git a/tests/Enums/AuthenticationMethodTest.php b/tests/Enums/AuthenticationMethodTest.php new file mode 100644 index 0000000..6e4b3d5 --- /dev/null +++ b/tests/Enums/AuthenticationMethodTest.php @@ -0,0 +1,35 @@ +assertInstanceOf($expectedProvider, $authenticationMethod->provider()); + } + + public static function providers(): array + { + return [ + [ + AuthenticationMethod::Token, + BearerTokenProvider::class, + ], + [ + AuthenticationMethod::OAuth, + OAuthProvider::class, + ], + ]; + } +} diff --git a/tests/Fakes/TestJob.php b/tests/Fakes/TestJob.php new file mode 100644 index 0000000..a45bce4 --- /dev/null +++ b/tests/Fakes/TestJob.php @@ -0,0 +1,21 @@ +handle($job, function () use (&$ran): void { + $ran = true; + }); + + $this->assertTrue($ran); + } + + #[Test] + public function it_can_release_jobs(): void + { + $this->mock(Magento::class, function (MockInterface $mock): void { + $mock->shouldReceive('connection')->with('default')->once()->andReturnSelf(); + $mock->shouldReceive('available')->once()->andReturnFalse(); + }); + + $job = $this->mock(TestJob::class, function (MockInterface $mock): void { + $mock->shouldReceive('release')->once(); + }); + + $middleware = new AvailableMiddleware('default'); + $middleware->handle($job, function (): void { + $this->fail('Job should not have run.'); + }); + } +} diff --git a/tests/Listeners/StoreAvailabilityListenerTest.php b/tests/Listeners/StoreAvailabilityListenerTest.php new file mode 100644 index 0000000..c291504 --- /dev/null +++ b/tests/Listeners/StoreAvailabilityListenerTest.php @@ -0,0 +1,60 @@ + Http::response(null, 200), + ])->preventStrayRequests(); + + $magento = app(Magento::class); + $magento->get('/'); + + $this->assertNull( + cache()->get(StoreAvailabilityListener::COUNT_KEY.'default') + ); + } + + #[Test] + public function it_can_keep_track_of_the_count(): void + { + Http::fake([ + '*' => Http::response(null, 503), + ])->preventStrayRequests(); + + /** @var Magento $magento */ + $magento = app(Magento::class); + $magento->get('/'); + + $this->assertEquals(1, cache()->get(StoreAvailabilityListener::COUNT_KEY.'default')); + $this->assertNull(cache()->get(CheckMagento::AVAILABLE_KEY.'default')); + } + + #[Test] + public function it_can_be_unavailable(): void + { + Http::fake([ + '*' => Http::response(null, 503), + ])->preventStrayRequests(); + + config()->set('magento.connections.default.availability.threshold', 1); + + /** @var Magento $magento */ + $magento = app(Magento::class); + $magento->get('/'); + + $this->assertNull(cache()->get(StoreAvailabilityListener::COUNT_KEY.'default')); + $this->assertFalse(cache()->get(CheckMagento::AVAILABLE_KEY.'default')); + } +}