From 462582c35aa81b41302f5af705e009f8f5f4e3d8 Mon Sep 17 00:00:00 2001 From: iman Date: Sun, 6 Sep 2026 09:38:15 +0330 Subject: [PATCH 1/2] ci: run the test workflow on master and unblock Laravel 11 installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow triggered on `branches: [main]`, but this repository's default branch is `master`, so it had never run a single time. The README badge also pointed at `tests.yml` while the file was named `php.yml`. Renamed the file to `tests.yml` (matching the badge) and triggered it on both `master` and `main`. Laravel 11 is past its security-fix window, so every 11.x release now carries an open advisory and Composer refuses to install it — the `laravel: '11.*'` matrix legs would have failed at dependency resolution even once they started running. The install step opts the CI checkout out of that block; applications should leave the check on. Also: install the framework with `--dev` rather than adding it to `require`, add `--prefer-stable` to the update, cache Composer downloads, restrict `permissions` to `contents: read`, cancel superseded runs, and move Pint and PHPStan into their own job instead of repeating them across all six matrix legs. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/php.yml | 49 --------------------- .github/workflows/tests.yml | 87 +++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 49 deletions(-) delete mode 100644 .github/workflows/php.yml create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml deleted file mode 100644 index d374204..0000000 --- a/.github/workflows/php.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: tests - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - test: - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - php: ['8.2', '8.3', '8.4'] - laravel: ['11.*', '12.*'] - include: - - laravel: '11.*' - testbench: '9.*' - - laravel: '12.*' - testbench: '10.*' - - name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: mbstring, json - coverage: none - - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update - composer update --prefer-dist --no-interaction --no-progress - - - name: Run tests - run: composer test - - - name: Check formatting - run: vendor/bin/pint --test - - - name: Static analysis - run: composer stan diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b958aa0 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,87 @@ +name: tests + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + laravel: ['11.*', '12.*'] + include: + - laravel: '11.*' + testbench: '9.*' + - laravel: '12.*' + testbench: '10.*' + + name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, json + coverage: none + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ~/.cache/composer/files + key: composer-${{ matrix.php }}-${{ matrix.laravel }}-${{ hashFiles('composer.json') }} + restore-keys: composer-${{ matrix.php }}-${{ matrix.laravel }}- + + - name: Install dependencies + run: | + # Laravel 11 is past its security-fix window, so every 11.x release + # carries an open advisory and Composer refuses to install it. We still + # support and test 11.x, so opt this CI checkout out of that block. + composer config --no-plugins audit.block-insecure false + composer require --dev --no-interaction --no-update \ + "laravel/framework:${{ matrix.laravel }}" \ + "orchestra/testbench:${{ matrix.testbench }}" + composer update --prefer-dist --prefer-stable --no-interaction --no-progress + + - name: Run tests + run: composer test + + quality: + runs-on: ubuntu-latest + name: Lint & static analysis + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: mbstring, json + coverage: none + + - name: Install dependencies + run: composer update --prefer-dist --prefer-stable --no-interaction --no-progress + + - name: Check formatting + run: vendor/bin/pint --test + + - name: Static analysis + run: composer stan From 4d142d91c299a0b0b3b1f55522319d3b724cabed Mon Sep 17 00:00:00 2001 From: iman Date: Sun, 6 Sep 2026 09:38:25 +0330 Subject: [PATCH 2/2] fix: resolve drivers from live config; replace Postex with a sample API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DriverRegistrar snapshotted `config('http-adapter.drivers')` when the manager was constructed and captured each driver's array by value, so any driver added or reconfigured after that point was silently ignored. The suite only passed because every test happened to mutate config before the first resolution. Driver resolution now reads the live config repository in AdapterManager::createDriver(), with a new InvalidDriverConfigException:: unknownDriver() for names that are absent from config entirely. Two tests cover the behaviour, including one that resolves a driver registered after the manager was already built. Note that Illuminate\Support\Manager memoises resolved drivers, so changing a driver's config at runtime still requires forgetDrivers() — documented in the README and declared on the facade. The Postex references throughout the docs, fixtures and workbench are replaced with JSONPlaceholder, a free public sample API: - tests: `SampleApiClient` fixture with posts/show_post/create_post endpoints. The suite still never touches the network; every test stubs the transport. - workbench: a matching `sample` driver plus a new `http-adapter:sample` command that makes a real call and then demonstrates the mock path. - README: rebuilt around the sample API, with new notes on `@method` docblocks for endpoint methods (they go through __call, so static analysers can't see them), resolve-time config, and the forgetDrivers() caveat. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 +- README.md | 111 +++++++++++++----- config/http-adapter.php | 12 +- src/AdapterManager.php | 35 +++++- .../InvalidDriverConfigException.php | 5 + src/Facades/HttpAdapter.php | 1 + src/HttpAdapterServiceProvider.php | 8 +- src/Support/DriverRegistrar.php | 37 ++++-- tests/Fixtures/AttributelessClient.php | 2 +- tests/Fixtures/FakeApiClient.php | 40 ------- tests/Fixtures/LateSampleApiClient.php | 24 ++++ tests/Fixtures/SampleApiClient.php | 44 +++++++ tests/Pest.php | 6 +- tests/TestCase.php | 16 +-- tests/Unit/CircuitBreakerTest.php | 20 ++-- tests/Unit/DriverRegistrarTest.php | 30 ++++- tests/Unit/EndpointResolutionTest.php | 16 +-- tests/Unit/EventDispatchTest.php | 26 ++-- tests/Unit/LoggingListenerTest.php | 4 +- tests/Unit/MockLiveSwitchTest.php | 22 ++-- tests/Unit/RetryTest.php | 12 +- workbench/app/Adapters/DemoClient.php | 27 ----- workbench/app/Adapters/SampleApiClient.php | 55 +++++++++ .../Console/Commands/CircuitDemoCommand.php | 8 +- .../app/Console/Commands/SampleApiCommand.php | 53 +++++++++ .../Providers/WorkbenchServiceProvider.php | 22 ++-- 26 files changed, 436 insertions(+), 203 deletions(-) delete mode 100644 tests/Fixtures/FakeApiClient.php create mode 100644 tests/Fixtures/LateSampleApiClient.php create mode 100644 tests/Fixtures/SampleApiClient.php delete mode 100644 workbench/app/Adapters/DemoClient.php create mode 100644 workbench/app/Adapters/SampleApiClient.php create mode 100644 workbench/app/Console/Commands/SampleApiCommand.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 48d6cf2..36da600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 configurable retry with backoff, and per-driver circuit breaking. - Config- and attribute-driven driver registry (`#[Driver('name')]` + `config/http-adapter.php`) resolved through an `Illuminate\Support\Manager`, so consuming apps register drivers - without modifying the package. + without modifying the package. Driver config is read at resolve time, so drivers added + or reconfigured after boot are picked up. - Lifecycle events — `RequestSending`, `RequestRetrying`, `ResponseReceived`, `RequestFailed`, `CircuitStateChanged` — plus a default `LogHttpAdapterActivity` listener writing to a configurable log channel. diff --git a/README.md b/README.md index b40e0f0..07dfae1 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ php artisan vendor:publish --tag=http-adapter-config ## Quick start +The examples below integrate [JSONPlaceholder](https://jsonplaceholder.typicode.com), +a free public sample REST API — the same one the test suite and the workbench app use, +so you can copy any of this and run it as-is. + ### 1. Write a client for the API Extend `AbstractApiClient`, tag it with the `#[Driver]` attribute, and describe its @@ -55,28 +59,32 @@ endpoints. Each endpoint method name is the snake-cased array key. use Ifds\HttpAdapter\Attributes\Driver; use Ifds\HttpAdapter\Client\AbstractApiClient; -#[Driver('postex')] -class PostexClient extends AbstractApiClient +#[Driver('sample')] +class SampleApiClient extends AbstractApiClient { protected function defineEndpoints(): array { return [ - 'quotes' => [ - 'method' => 'POST', - 'path' => '/api/v1/quotes', + 'posts' => [ + 'method' => 'GET', + 'path' => '/posts', ], - 'tracking' => [ + 'show_post' => [ 'method' => 'GET', - 'path' => '/api/v1/tracking/{code}', + 'path' => '/posts/{id}', + ], + 'create_post' => [ + 'method' => 'POST', + 'path' => '/posts', ], ]; } // Optional: a typed convenience method over the generic builder. - public function trackingFor(string $code): array + public function commentsFor(int $postId): array { - return $this->tracking() - ->withPathParam('code', $code) + return $this->showPost() + ->withPathParam('id', $postId) ->withHeaders(['x-api-key' => $this->config->extra('api_key')]) ->send() ->json(); @@ -84,6 +92,19 @@ class PostexClient extends AbstractApiClient } ``` +Endpoint methods are resolved through `__call`, so static analysers can't see them. +Declare them on the class if you run PHPStan/Psalm: + +```php +/** + * @method $this posts() + * @method $this showPost() + * @method $this createPost(array|AbstractAdapterRequest $payload = []) + */ +#[Driver('sample')] +class SampleApiClient extends AbstractApiClient { /* ... */ } +``` + ### 2. Register it in config Add an entry to `config/http-adapter.php`. The config key **must** match the @@ -91,29 +112,33 @@ Add an entry to `config/http-adapter.php`. The config key **must** match the ```php 'drivers' => [ - 'postex' => [ - 'client' => \App\Adapters\PostexClient::class, - 'base_url' => env('POSTEX_API_URL'), - 'stage_url' => 'https://stage.postex.ir', // fallback when base_url is empty + 'sample' => [ + 'client' => \App\Adapters\SampleApiClient::class, + 'base_url' => env('SAMPLE_API_URL'), + 'stage_url' => 'https://jsonplaceholder.typicode.com', // fallback when base_url is empty 'timeout' => 30, - 'mock_enabled' => env('POSTEX_MOCK', false), + 'mock_enabled' => env('SAMPLE_API_MOCK', false), 'retry' => ['times' => 3, 'backoff_ms' => 200], 'circuit_breaker' => ['enabled' => true, 'failure_threshold' => 5, 'cooldown_seconds' => 30], - 'extra' => ['api_key' => env('POSTEX_API_KEY')], + 'extra' => ['api_key' => env('SAMPLE_API_KEY')], ], ], ``` +Driver config is read from the container's config repository **at resolve time**, not +snapshotted at boot, so drivers registered later in the request lifecycle (tests, +feature flags, runtime tenancy) resolve correctly. + ### 3. Call it ```php use Ifds\HttpAdapter\Facades\HttpAdapter; -$response = HttpAdapter::driver('postex')->quotes(['weight' => 500])->send(); +$response = HttpAdapter::driver('sample')->posts()->send(); $data = $response->json(); // Or via a convenience method on your client: -$tracking = HttpAdapter::driver('postex')->trackingFor('ABC123'); +$comments = HttpAdapter::driver('sample')->commentsFor(1); ``` ## The fluent builder @@ -121,9 +146,9 @@ $tracking = HttpAdapter::driver('postex')->trackingFor('ABC123'); Every endpoint call returns the client so you can shape the request before `send()`: ```php -HttpAdapter::driver('postex') - ->quotes($payload) // sets the active endpoint + JSON body - ->withPathParam('code', 'X1') // interpolate {code} in the path +HttpAdapter::driver('sample') + ->createPost($payload) // sets the active endpoint + JSON body + ->withPathParam('id', 1) // interpolate {id} in the path ->withQueryParam('page', 2) // ?page=2 ->withHeaders(['x-api-key' => '...']) ->setTimeout(10) // per-call timeout override @@ -135,6 +160,14 @@ HttpAdapter::driver('postex') Endpoint methods accept either an array or an `AbstractAdapterRequest` DTO as their first argument; a DTO is converted to a snake-cased payload automatically. +A resolved driver is memoised for the lifetime of the manager. If you change a +driver's config at runtime, drop the cached instance first: + +```php +config()->set('http-adapter.drivers.sample.mock_enabled', true); +HttpAdapter::forgetDrivers(); +``` + ## Resilience ### Retry @@ -191,19 +224,20 @@ Disable it entirely with `'logging' => ['enabled' => false]`. ## Testing -Flip `mock_enabled` (or set the `{DRIVER}_MOCK` env var) and any endpoint with a `mock` +Flip `mock_enabled` (or set the driver's mock env var) and any endpoint with a `mock` entry serves its canned response with no network call: ```php -'quotes' => [ +'create_post' => [ 'method' => 'POST', - 'path' => '/api/v1/quotes', - 'mock' => fn (array $payload, array $headers) => ['price' => 1000], + 'path' => '/posts', + 'mock' => fn (array $payload, array $headers) => ['id' => 101] + $payload, ], ``` The mock may be an array, an `Illuminate\Http\Client\Response`, or a closure returning -either. Mock data lives in your app, never in this package. +either. Mock data lives in your app, never in this package. Endpoints without a `mock` +entry always go live, even when `mock_enabled` is true. ## DTOs & tolerant parsing @@ -215,7 +249,7 @@ casing (`isSuccess` vs `IsSuccess`, `data` vs `Data`): ```php use Ifds\HttpAdapter\Support\ExtractsTolerantFields; -class QuoteResult +class PostResult { use ExtractsTolerantFields; @@ -236,6 +270,29 @@ composer pint # format composer stan # PHPStan / Larastan ``` +The suite never touches the network — every test stubs the transport with +`Http::fake()` — but the fixture client models the JSONPlaceholder sample API so the +endpoint shapes under test stay realistic. + +### Workbench + +`workbench/` is a real, booted Laravel app that registers the same sample driver, for +poking at the package by hand: + +```bash +vendor/bin/testbench http-adapter:sample 1 # live call to the sample API, plus the mock path +vendor/bin/testbench http-adapter:circuit-demo # drive the breaker closed -> open (transport faked) +composer serve # boot the workbench app over HTTP +``` + +### A note on the Laravel 11 CI matrix + +Laravel 11 is past its security-fix window, so every 11.x release now carries an open +advisory and Composer refuses to install it by default. The package still supports and +tests 11.x; the CI job opts that ephemeral checkout out of the block with +`composer config --no-plugins audit.block-insecure false`. Your own applications should +leave that check on. + ## License MIT — see [LICENSE](LICENSE). diff --git a/config/http-adapter.php b/config/http-adapter.php index 3af4751..8383d91 100644 --- a/config/http-adapter.php +++ b/config/http-adapter.php @@ -39,15 +39,15 @@ | to the client class implementing it (which must declare a matching | #[Driver('name')] attribute) plus its own configuration. | - | 'postex' => [ - | 'client' => \App\Adapters\PostexClient::class, - | 'base_url' => env('POSTEX_API_URL'), - | 'stage_url' => 'https://stage.postex.ir', + | 'sample' => [ + | 'client' => \App\Adapters\SampleApiClient::class, + | 'base_url' => env('SAMPLE_API_URL'), + | 'stage_url' => 'https://jsonplaceholder.typicode.com', // used when base_url is empty | 'timeout' => 30, - | 'mock_enabled' => env('POSTEX_MOCK', false), + | 'mock_enabled' => env('SAMPLE_API_MOCK', false), | 'retry' => ['times' => 3, 'backoff_ms' => 200], | 'circuit_breaker' => ['enabled' => true, 'failure_threshold' => 5, 'cooldown_seconds' => 30], - | 'extra' => ['api_key' => env('POSTEX_API_KEY')], + | 'extra' => ['api_key' => env('SAMPLE_API_KEY')], | ], | */ diff --git a/src/AdapterManager.php b/src/AdapterManager.php index cad782b..bd54a19 100644 --- a/src/AdapterManager.php +++ b/src/AdapterManager.php @@ -2,21 +2,54 @@ namespace Ifds\HttpAdapter; +use Ifds\HttpAdapter\Client\AbstractApiClient; use Ifds\HttpAdapter\Exceptions\NoDefaultDriverException; +use Ifds\HttpAdapter\Support\DriverRegistrar; +use Illuminate\Contracts\Container\Container; use Illuminate\Support\Manager; class AdapterManager extends Manager { - public function getDefaultDriver() + private DriverRegistrar $registrar; + + public function __construct(Container $container, ?DriverRegistrar $registrar = null) + { + parent::__construct($container); + + $this->registrar = $registrar ?? new DriverRegistrar; + } + + /** + * This package has no sensible "default" integration — every call names its + * driver explicitly. + */ + public function getDefaultDriver(): string { throw NoDefaultDriverException::make(); } /** * @param array $parameters + * @return AbstractApiClient */ public function __call($method, $parameters) { return $this->driver($method); } + + /** + * Resolve a driver straight from the live config, so drivers registered (or + * reconfigured) after the manager was constructed still resolve correctly. + * + * @param string $driver + * @return AbstractApiClient + */ + protected function createDriver($driver) + { + if (isset($this->customCreators[$driver])) { + return $this->callCustomCreator($driver); + } + + return $this->registrar->resolve($this->container, $driver); + } } diff --git a/src/Exceptions/InvalidDriverConfigException.php b/src/Exceptions/InvalidDriverConfigException.php index abb79ab..abdce22 100644 --- a/src/Exceptions/InvalidDriverConfigException.php +++ b/src/Exceptions/InvalidDriverConfigException.php @@ -4,6 +4,11 @@ class InvalidDriverConfigException extends HttpAdapterException { + public static function unknownDriver(string $driver): self + { + return new self("Driver [{$driver}] is not registered — add it under config('http-adapter.drivers')."); + } + public static function missingClient(string $driver): self { return new self("Driver [{$driver}] is missing a 'client' entry in config('http-adapter.drivers.{$driver}')."); diff --git a/src/Facades/HttpAdapter.php b/src/Facades/HttpAdapter.php index 8bc0d89..c57e1cd 100644 --- a/src/Facades/HttpAdapter.php +++ b/src/Facades/HttpAdapter.php @@ -7,6 +7,7 @@ /** * @method static \Ifds\HttpAdapter\Client\AbstractApiClient driver(string $driver) + * @method static \Ifds\HttpAdapter\AdapterManager forgetDrivers() * * @see AdapterManager */ diff --git a/src/HttpAdapterServiceProvider.php b/src/HttpAdapterServiceProvider.php index 409b34f..114c80e 100644 --- a/src/HttpAdapterServiceProvider.php +++ b/src/HttpAdapterServiceProvider.php @@ -18,13 +18,7 @@ public function register(): void { $this->mergeConfigFrom(__DIR__.'/../config/http-adapter.php', 'http-adapter'); - $this->app->singleton(AdapterManager::class, function ($app) { - $manager = new AdapterManager($app); - - (new DriverRegistrar)->registerFromConfig($manager, $app, $app['config']->get('http-adapter.drivers', [])); - - return $manager; - }); + $this->app->singleton(AdapterManager::class, fn ($app) => new AdapterManager($app, new DriverRegistrar)); $this->app->alias(AdapterManager::class, 'http-adapter'); } diff --git a/src/Support/DriverRegistrar.php b/src/Support/DriverRegistrar.php index 9ad4822..a2ebaf1 100644 --- a/src/Support/DriverRegistrar.php +++ b/src/Support/DriverRegistrar.php @@ -2,8 +2,8 @@ namespace Ifds\HttpAdapter\Support; -use Ifds\HttpAdapter\AdapterManager; use Ifds\HttpAdapter\Attributes\Driver; +use Ifds\HttpAdapter\Client\AbstractApiClient; use Ifds\HttpAdapter\Exceptions\InvalidDriverConfigException; use Illuminate\Contracts\Container\Container; use ReflectionClass; @@ -11,25 +11,38 @@ final class DriverRegistrar { /** - * @param array> $driversConfig + * Build the client class configured under `http-adapter.drivers.{$name}`. + * + * The config is read at resolve time rather than snapshotted at boot, so a + * driver added or reconfigured later in the request (tests, feature flags, + * runtime tenancy) is picked up. */ - public function registerFromConfig(AdapterManager $manager, Container $app, array $driversConfig): void + public function resolve(Container $container, string $name): AbstractApiClient { - foreach ($driversConfig as $name => $config) { - $manager->extend($name, function ($app) use ($name, $config) { - $class = $config['client'] ?? null; + /** @var array $config */ + $config = $container->make('config')->get("http-adapter.drivers.{$name}", []); - if (! $class) { - throw InvalidDriverConfigException::missingClient($name); - } + if ($config === []) { + throw InvalidDriverConfigException::unknownDriver($name); + } - $this->assertAttributeMatches($class, $name); + $class = $config['client'] ?? null; - return $app->make($class)->configure(DriverConfig::fromArray($name, $config)); - }); + if (! is_string($class) || $class === '') { + throw InvalidDriverConfigException::missingClient($name); } + + $this->assertAttributeMatches($class, $name); + + /** @var AbstractApiClient $client */ + $client = $container->make($class); + + return $client->configure(DriverConfig::fromArray($name, $config)); } + /** + * @param class-string $class + */ private function assertAttributeMatches(string $class, string $name): void { $reflection = new ReflectionClass($class); diff --git a/tests/Fixtures/AttributelessClient.php b/tests/Fixtures/AttributelessClient.php index 205363d..7a78527 100644 --- a/tests/Fixtures/AttributelessClient.php +++ b/tests/Fixtures/AttributelessClient.php @@ -13,7 +13,7 @@ class AttributelessClient extends AbstractApiClient protected function defineEndpoints(): array { return [ - 'ping' => ['method' => 'GET', 'path' => '/ping'], + 'posts' => ['method' => 'GET', 'path' => '/posts'], ]; } } diff --git a/tests/Fixtures/FakeApiClient.php b/tests/Fixtures/FakeApiClient.php deleted file mode 100644 index 616a55e..0000000 --- a/tests/Fixtures/FakeApiClient.php +++ /dev/null @@ -1,40 +0,0 @@ - [ - 'method' => 'GET', - 'path' => '/ping', - ], - 'echo_payload' => [ - 'method' => 'POST', - 'path' => '/echo', - 'mock' => fn (array $payload, array $headers) => [ - 'echoed' => $payload, - 'mocked' => true, - ], - ], - 'show_user' => [ - 'method' => 'GET', - 'path' => '/users/{id}', - ], - 'unstable_get' => [ - 'method' => 'GET', - 'path' => '/unstable', - ], - 'unstable_post' => [ - 'method' => 'POST', - 'path' => '/unstable', - ], - ]; - } -} diff --git a/tests/Fixtures/LateSampleApiClient.php b/tests/Fixtures/LateSampleApiClient.php new file mode 100644 index 0000000..f4fd710 --- /dev/null +++ b/tests/Fixtures/LateSampleApiClient.php @@ -0,0 +1,24 @@ +> + */ + protected function defineEndpoints(): array + { + return [ + 'todos' => ['method' => 'GET', 'path' => '/todos'], + ]; + } +} diff --git a/tests/Fixtures/SampleApiClient.php b/tests/Fixtures/SampleApiClient.php new file mode 100644 index 0000000..47dae26 --- /dev/null +++ b/tests/Fixtures/SampleApiClient.php @@ -0,0 +1,44 @@ +> + */ + protected function defineEndpoints(): array + { + return [ + // Idempotent, no path params. + 'posts' => [ + 'method' => 'GET', + 'path' => '/posts', + ], + + // Idempotent, with a path parameter to interpolate. + 'show_post' => [ + 'method' => 'GET', + 'path' => '/posts/{id}', + ], + + // Non-idempotent, and carries a mock so the mock/live switch and + // the retry opt-in can both be exercised against it. + 'create_post' => [ + 'method' => 'POST', + 'path' => '/posts', + 'mock' => fn (array $payload, array $headers) => ['id' => 101] + $payload, + ], + ]; + } +} diff --git a/tests/Pest.php b/tests/Pest.php index 0eed929..541fd7e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -6,9 +6,9 @@ uses(TestCase::class)->in('Unit'); /** - * Resolve the pre-registered 'fake' driver from the container. + * Resolve the pre-registered 'sample' driver from the container. */ -function fakeDriver(): AbstractApiClient +function sampleDriver(): AbstractApiClient { - return app('http-adapter')->driver('fake'); + return app('http-adapter')->driver('sample'); } diff --git a/tests/TestCase.php b/tests/TestCase.php index beecf6e..6fd0859 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,7 +3,7 @@ namespace Ifds\HttpAdapter\Tests; use Ifds\HttpAdapter\HttpAdapterServiceProvider; -use Ifds\HttpAdapter\Tests\Fixtures\FakeApiClient; +use Ifds\HttpAdapter\Tests\Fixtures\SampleApiClient; use Monolog\Handler\NullHandler; use Orchestra\Testbench\TestCase as Orchestra; @@ -28,9 +28,9 @@ protected function defineEnvironment($app): void 'handler' => NullHandler::class, ]); - $app['config']->set('http-adapter.drivers.fake', [ - 'client' => FakeApiClient::class, - 'base_url' => 'https://fake.test', + $app['config']->set('http-adapter.drivers.sample', [ + 'client' => SampleApiClient::class, + 'base_url' => 'https://jsonplaceholder.typicode.com', 'timeout' => 5, 'mock_enabled' => false, 'retry' => [], @@ -40,14 +40,14 @@ protected function defineEnvironment($app): void } /** - * Merge extra config into the 'fake' driver definition for a single test. + * Merge extra config into the 'sample' driver definition for a single test. * * @param array $overrides */ - protected function configureFakeDriver(array $overrides): void + protected function configureSampleDriver(array $overrides): void { - $existing = config('http-adapter.drivers.fake', []); + $existing = config('http-adapter.drivers.sample', []); - config()->set('http-adapter.drivers.fake', array_replace_recursive($existing, $overrides)); + config()->set('http-adapter.drivers.sample', array_replace_recursive($existing, $overrides)); } } diff --git a/tests/Unit/CircuitBreakerTest.php b/tests/Unit/CircuitBreakerTest.php index c792e36..73fc601 100644 --- a/tests/Unit/CircuitBreakerTest.php +++ b/tests/Unit/CircuitBreakerTest.php @@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Http; beforeEach(function () { - $this->configureFakeDriver([ + $this->configureSampleDriver([ 'circuit_breaker' => ['enabled' => true, 'failure_threshold' => 3, 'cooldown_seconds' => 30], ]); }); @@ -20,7 +20,7 @@ function failOnce(): void { try { - fakeDriver()->unstableGet()->send(); + sampleDriver()->posts()->send(); } catch (RequestException) { // swallow; the breaker records the failure } @@ -35,7 +35,7 @@ function failOnce(): void Http::assertSentCount(3); - expect(fn () => fakeDriver()->unstableGet()->send()) + expect(fn () => sampleDriver()->posts()->send()) ->toThrow(CircuitOpenException::class); // The short-circuited call never reached the network. @@ -59,15 +59,15 @@ function failOnce(): void failOnce(); failOnce(); - expect(fn () => fakeDriver()->unstableGet()->send())->toThrow(CircuitOpenException::class); + expect(fn () => sampleDriver()->posts()->send())->toThrow(CircuitOpenException::class); Carbon::setTestNow(now()->addSeconds(31)); - $trial = fakeDriver()->unstableGet()->send(); + $trial = sampleDriver()->posts()->send(); expect($trial->successful())->toBeTrue(); // Closed again: a subsequent call also passes through. - $again = fakeDriver()->unstableGet()->send(); + $again = sampleDriver()->posts()->send(); expect($again->successful())->toBeTrue(); // 3 trip + 1 trial + 1 follow-up; the open-state call never reached the network. @@ -88,12 +88,12 @@ function failOnce(): void failOnce(); // ...so the breaker is open again and short-circuits. - expect(fn () => fakeDriver()->unstableGet()->send()) + expect(fn () => sampleDriver()->posts()->send()) ->toThrow(CircuitOpenException::class); }); it('never trips when the circuit breaker is disabled', function () { - $this->configureFakeDriver(['circuit_breaker' => ['enabled' => false]]); + $this->configureSampleDriver(['circuit_breaker' => ['enabled' => false]]); Http::fake(['*' => Http::response(['error' => true], 500)]); failOnce(); @@ -102,7 +102,7 @@ function failOnce(): void failOnce(); // No CircuitOpenException — every call reached the network. - expect(fn () => fakeDriver()->unstableGet()->send())->toThrow(RequestException::class); + expect(fn () => sampleDriver()->posts()->send())->toThrow(RequestException::class); Http::assertSentCount(5); }); @@ -114,7 +114,7 @@ function failOnce(): void failOnce(); // Breaker is open, but this call opts out and reaches the network (then fails normally). - expect(fn () => fakeDriver()->unstableGet()->withoutCircuitBreaker()->send()) + expect(fn () => sampleDriver()->posts()->withoutCircuitBreaker()->send()) ->toThrow(RequestException::class); Http::assertSentCount(4); diff --git a/tests/Unit/DriverRegistrarTest.php b/tests/Unit/DriverRegistrarTest.php index fa1bc00..4d71a11 100644 --- a/tests/Unit/DriverRegistrarTest.php +++ b/tests/Unit/DriverRegistrarTest.php @@ -4,25 +4,43 @@ use Ifds\HttpAdapter\Exceptions\NoDefaultDriverException; use Ifds\HttpAdapter\Support\DriverConfig; use Ifds\HttpAdapter\Tests\Fixtures\AttributelessClient; -use Ifds\HttpAdapter\Tests\Fixtures\FakeApiClient; +use Ifds\HttpAdapter\Tests\Fixtures\LateSampleApiClient; +use Ifds\HttpAdapter\Tests\Fixtures\SampleApiClient; it('resolves a correctly configured driver into its client class', function () { - expect(fakeDriver())->toBeInstanceOf(FakeApiClient::class); + expect(sampleDriver())->toBeInstanceOf(SampleApiClient::class); }); it('throws when the config key does not match the client #[Driver] attribute', function () { config()->set('http-adapter.drivers.mismatch', [ - 'client' => FakeApiClient::class, // declares #[Driver('fake')] - 'base_url' => 'https://fake.test', + 'client' => SampleApiClient::class, // declares #[Driver('sample')] + 'base_url' => 'https://jsonplaceholder.typicode.com', ]); expect(fn () => app('http-adapter')->driver('mismatch')) ->toThrow(InvalidDriverConfigException::class); }); +it('resolves a driver registered in config after the manager was already built', function () { + // Force the manager to be constructed before the driver exists in config. + expect(sampleDriver())->toBeInstanceOf(SampleApiClient::class); + + config()->set('http-adapter.drivers.late', [ + 'client' => LateSampleApiClient::class, + 'base_url' => 'https://jsonplaceholder.typicode.com', + ]); + + expect(app('http-adapter')->driver('late'))->toBeInstanceOf(LateSampleApiClient::class); +}); + +it('throws when the requested driver is not in the config at all', function () { + expect(fn () => app('http-adapter')->driver('nope')) + ->toThrow(InvalidDriverConfigException::class); +}); + it('throws when a driver has no client class configured', function () { config()->set('http-adapter.drivers.clientless', [ - 'base_url' => 'https://fake.test', + 'base_url' => 'https://jsonplaceholder.typicode.com', ]); expect(fn () => app('http-adapter')->driver('clientless')) @@ -32,7 +50,7 @@ it('throws when the client class is missing the #[Driver] attribute', function () { config()->set('http-adapter.drivers.attributeless', [ 'client' => AttributelessClient::class, - 'base_url' => 'https://fake.test', + 'base_url' => 'https://jsonplaceholder.typicode.com', ]); expect(fn () => app('http-adapter')->driver('attributeless')) diff --git a/tests/Unit/EndpointResolutionTest.php b/tests/Unit/EndpointResolutionTest.php index 3a3cec3..af8333b 100644 --- a/tests/Unit/EndpointResolutionTest.php +++ b/tests/Unit/EndpointResolutionTest.php @@ -6,29 +6,29 @@ it('resolves a snake_cased endpoint method to its configured URL and verb', function () { Http::fake(['*' => Http::response(['ok' => true])]); - fakeDriver()->ping()->send(); + sampleDriver()->posts()->send(); - Http::assertSent(fn ($request) => $request->url() === 'https://fake.test/ping' + Http::assertSent(fn ($request) => $request->url() === 'https://jsonplaceholder.typicode.com/posts' && $request->method() === 'GET'); }); it('throws when calling an endpoint that is not defined', function () { - expect(fn () => fakeDriver()->somethingUndefined()) + expect(fn () => sampleDriver()->somethingUndefined()) ->toThrow(UndefinedEndpointException::class); }); it('interpolates path parameters into the URL', function () { Http::fake(['*' => Http::response(['ok' => true])]); - fakeDriver()->showUser()->withPathParam('id', 42)->send(); + sampleDriver()->showPost()->withPathParam('id', 42)->send(); - Http::assertSent(fn ($request) => $request->url() === 'https://fake.test/users/42'); + Http::assertSent(fn ($request) => $request->url() === 'https://jsonplaceholder.typicode.com/posts/42'); }); it('appends query parameters to the URL', function () { Http::fake(['*' => Http::response(['ok' => true])]); - fakeDriver()->ping()->withQueryParam('page', 2)->withQueryParams(['sort' => 'asc'])->send(); + sampleDriver()->posts()->withQueryParam('page', 2)->withQueryParams(['sort' => 'asc'])->send(); Http::assertSent(fn ($request) => str_contains($request->url(), 'page=2') && str_contains($request->url(), 'sort=asc')); @@ -37,8 +37,8 @@ it('sends the request payload as JSON', function () { Http::fake(['*' => Http::response(['ok' => true])]); - fakeDriver()->unstablePost(['name' => 'widget'])->send(); + sampleDriver()->createPost(['title' => 'widget'])->send(); - Http::assertSent(fn ($request) => $request['name'] === 'widget' + Http::assertSent(fn ($request) => $request['title'] === 'widget' && $request->hasHeader('Content-Type', 'application/json')); }); diff --git a/tests/Unit/EventDispatchTest.php b/tests/Unit/EventDispatchTest.php index d4a2f73..0d6e011 100644 --- a/tests/Unit/EventDispatchTest.php +++ b/tests/Unit/EventDispatchTest.php @@ -13,24 +13,24 @@ Event::fake([RequestSending::class, ResponseReceived::class]); Http::fake(['*' => Http::response(['ok' => true], 200)]); - fakeDriver()->ping()->send(); + sampleDriver()->posts()->send(); - Event::assertDispatched(RequestSending::class, fn (RequestSending $e) => $e->driver === 'fake' - && $e->endpoint === 'ping' + Event::assertDispatched(RequestSending::class, fn (RequestSending $e) => $e->driver === 'sample' + && $e->endpoint === 'posts' && $e->method === 'GET' - && $e->url === 'https://fake.test/ping'); + && $e->url === 'https://jsonplaceholder.typicode.com/posts'); - Event::assertDispatched(ResponseReceived::class, fn (ResponseReceived $e) => $e->driver === 'fake' - && $e->endpoint === 'ping' + Event::assertDispatched(ResponseReceived::class, fn (ResponseReceived $e) => $e->driver === 'sample' + && $e->endpoint === 'posts' && $e->mocked === false && $e->response->status() === 200); }); it('marks ResponseReceived as mocked when the mock path is taken', function () { - $this->configureFakeDriver(['mock_enabled' => true]); + $this->configureSampleDriver(['mock_enabled' => true]); Event::fake([ResponseReceived::class]); - fakeDriver()->echoPayload(['a' => 1])->send(); + sampleDriver()->createPost(['a' => 1])->send(); Event::assertDispatched(ResponseReceived::class, fn (ResponseReceived $e) => $e->mocked === true); }); @@ -40,16 +40,16 @@ Http::fake(['*' => Http::response(['error' => true], 500)]); try { - fakeDriver()->unstableGet()->send(); + sampleDriver()->posts()->send(); } catch (RequestException) { } - Event::assertDispatched(RequestFailed::class, fn (RequestFailed $e) => $e->endpoint === 'unstable_get' + Event::assertDispatched(RequestFailed::class, fn (RequestFailed $e) => $e->endpoint === 'posts' && $e->reason->value === 'server_error'); }); it('dispatches CircuitStateChanged when the breaker trips', function () { - $this->configureFakeDriver([ + $this->configureSampleDriver([ 'circuit_breaker' => ['enabled' => true, 'failure_threshold' => 2, 'cooldown_seconds' => 30], ]); Event::fake([CircuitStateChanged::class]); @@ -57,12 +57,12 @@ foreach (range(1, 2) as $i) { try { - fakeDriver()->unstableGet()->send(); + sampleDriver()->posts()->send(); } catch (RequestException) { } } - Event::assertDispatched(CircuitStateChanged::class, fn (CircuitStateChanged $e) => $e->driver === 'fake' + Event::assertDispatched(CircuitStateChanged::class, fn (CircuitStateChanged $e) => $e->driver === 'sample' && $e->from === CircuitState::Closed && $e->to === CircuitState::Open); }); diff --git a/tests/Unit/LoggingListenerTest.php b/tests/Unit/LoggingListenerTest.php index 88099d2..2165e61 100644 --- a/tests/Unit/LoggingListenerTest.php +++ b/tests/Unit/LoggingListenerTest.php @@ -12,7 +12,7 @@ Log::shouldReceive('channel')->once()->with('my-channel')->andReturn($logger); (new LogHttpAdapterActivity)->onRequestSending( - new RequestSending('fake', 'ping', 'GET', 'https://fake.test/ping', null, []) + new RequestSending('sample', 'posts', 'GET', 'https://jsonplaceholder.typicode.com/posts', null, []) ); $logger->shouldHaveReceived('info')->once(); @@ -26,7 +26,7 @@ Log::shouldReceive('channel')->once()->with('stack')->andReturn($logger); (new LogHttpAdapterActivity)->onRequestSending( - new RequestSending('fake', 'ping', 'GET', 'https://fake.test/ping', null, []) + new RequestSending('sample', 'posts', 'GET', 'https://jsonplaceholder.typicode.com/posts', null, []) ); $logger->shouldHaveReceived('info')->once(); diff --git a/tests/Unit/MockLiveSwitchTest.php b/tests/Unit/MockLiveSwitchTest.php index 69ae67d..49106f3 100644 --- a/tests/Unit/MockLiveSwitchTest.php +++ b/tests/Unit/MockLiveSwitchTest.php @@ -3,36 +3,36 @@ use Illuminate\Support\Facades\Http; it('returns the mock response without hitting the network when mocking is enabled', function () { - $this->configureFakeDriver(['mock_enabled' => true]); + $this->configureSampleDriver(['mock_enabled' => true]); Http::fake(); - $response = fakeDriver()->echoPayload(['name' => 'widget'])->send(); + $response = sampleDriver()->createPost(['title' => 'widget'])->send(); expect($response->json())->toBe([ - 'echoed' => ['name' => 'widget'], - 'mocked' => true, + 'id' => 101, + 'title' => 'widget', ]); Http::assertNothingSent(); }); it('dispatches a live request when mocking is disabled', function () { - Http::fake(['*' => Http::response(['echoed' => 'from-server'])]); + Http::fake(['*' => Http::response(['id' => 1, 'title' => 'from-server'])]); - $response = fakeDriver()->echoPayload(['name' => 'widget'])->send(); + $response = sampleDriver()->createPost(['title' => 'widget'])->send(); - expect($response->json())->toBe(['echoed' => 'from-server']); + expect($response->json())->toBe(['id' => 1, 'title' => 'from-server']); Http::assertSentCount(1); }); it('does not mock an endpoint that has no mock defined even when mocking is enabled', function () { - $this->configureFakeDriver(['mock_enabled' => true]); - Http::fake(['*' => Http::response(['pong' => true])]); + $this->configureSampleDriver(['mock_enabled' => true]); + Http::fake(['*' => Http::response([['id' => 1]])]); - $response = fakeDriver()->ping()->send(); + $response = sampleDriver()->posts()->send(); - expect($response->json())->toBe(['pong' => true]); + expect($response->json())->toBe([['id' => 1]]); Http::assertSentCount(1); }); diff --git a/tests/Unit/RetryTest.php b/tests/Unit/RetryTest.php index 746ef5b..ce45234 100644 --- a/tests/Unit/RetryTest.php +++ b/tests/Unit/RetryTest.php @@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Http; beforeEach(function () { - $this->configureFakeDriver(['retry' => ['times' => 3, 'backoff_ms' => 0]]); + $this->configureSampleDriver(['retry' => ['times' => 3, 'backoff_ms' => 0]]); }); it('retries an idempotent GET on a 5xx and returns the eventual success', function () { @@ -16,7 +16,7 @@ ->push(['error' => true], 500) ->push(['ok' => true], 200)]); - $response = fakeDriver()->unstableGet()->send(); + $response = sampleDriver()->posts()->send(); expect($response->successful())->toBeTrue() ->and($response->json())->toBe(['ok' => true]); @@ -32,7 +32,7 @@ ->push(['error' => true], 500) ->push(['ok' => true], 200)]); - expect(fn () => fakeDriver()->unstablePost(['name' => 'widget'])->send()) + expect(fn () => sampleDriver()->createPost(['title' => 'widget'])->send()) ->toThrow(RequestException::class); Http::assertSentCount(1); @@ -46,7 +46,7 @@ ->push(['error' => true], 500) ->push(['ok' => true], 200)]); - $response = fakeDriver()->unstablePost(['name' => 'widget']) + $response = sampleDriver()->createPost(['title' => 'widget']) ->withRetry(times: 3, backoffMs: 0) ->send(); @@ -59,14 +59,14 @@ it('does not retry when no retry policy is configured', function () { // Hard-reset the policy the beforeEach installed (array_replace_recursive // can't clear a key with an empty array). - config()->set('http-adapter.drivers.fake.retry', []); + config()->set('http-adapter.drivers.sample.retry', []); Event::fake([RequestRetrying::class]); Http::fake(['*' => Http::sequence() ->push(['error' => true], 500) ->push(['ok' => true], 200)]); - expect(fn () => fakeDriver()->unstableGet()->send()) + expect(fn () => sampleDriver()->posts()->send()) ->toThrow(RequestException::class); Http::assertSentCount(1); diff --git a/workbench/app/Adapters/DemoClient.php b/workbench/app/Adapters/DemoClient.php deleted file mode 100644 index 4e52d90..0000000 --- a/workbench/app/Adapters/DemoClient.php +++ /dev/null @@ -1,27 +0,0 @@ -> - */ - protected function defineEndpoints(): array - { - return [ - 'status' => [ - 'method' => 'GET', - 'path' => '/status/{code}', - ], - ]; - } -} diff --git a/workbench/app/Adapters/SampleApiClient.php b/workbench/app/Adapters/SampleApiClient.php new file mode 100644 index 0000000..9e2229b --- /dev/null +++ b/workbench/app/Adapters/SampleApiClient.php @@ -0,0 +1,55 @@ +> + */ + protected function defineEndpoints(): array + { + return [ + 'posts' => [ + 'method' => 'GET', + 'path' => '/posts', + ], + 'show_post' => [ + 'method' => 'GET', + 'path' => '/posts/{id}', + ], + 'comments_for_post' => [ + 'method' => 'GET', + 'path' => '/posts/{id}/comments', + ], + 'create_post' => [ + 'method' => 'POST', + 'path' => '/posts', + // Served instead of a live call when 'mock_enabled' is true. + 'mock' => fn (array $payload, array $headers) => ['id' => 101] + $payload, + ], + ]; + } + + /** + * A typed convenience method over the generic builder. + * + * @return array> + */ + public function commentsFor(int $postId): array + { + return $this->commentsForPost() + ->withPathParam('id', $postId) + ->send() + ->json(); + } +} diff --git a/workbench/app/Console/Commands/CircuitDemoCommand.php b/workbench/app/Console/Commands/CircuitDemoCommand.php index 49cbadb..c8bdf66 100644 --- a/workbench/app/Console/Commands/CircuitDemoCommand.php +++ b/workbench/app/Console/Commands/CircuitDemoCommand.php @@ -15,7 +15,7 @@ class CircuitDemoCommand extends Command { protected $signature = 'http-adapter:circuit-demo'; - protected $description = 'Drive the demo driver through failures to trip the circuit breaker (exercises the CircuitState enum in a booted app).'; + protected $description = 'Drive the sample driver through failures to trip the circuit breaker (exercises the CircuitState enum in a booted app).'; public function handle(): int { @@ -32,11 +32,11 @@ public function handle(): int // Force every upstream call to fail so the breaker trips. Http::fake(['*' => Http::response(['error' => true], 500)]); - $this->info('Sending failing requests through the "demo" driver (threshold: 3)...'); + $this->info('Sending failing requests through the "sample" driver (threshold: 3)...'); foreach (range(1, 3) as $attempt) { try { - HttpAdapter::driver('demo')->status()->withPathParam('code', 500)->send(); + HttpAdapter::driver('sample')->showPost()->withPathParam('id', 1)->send(); } catch (RequestException $e) { $this->line(" attempt {$attempt}: failed ({$e->response->status()})"); } @@ -46,7 +46,7 @@ public function handle(): int $this->info('Breaker should now be OPEN — the next call must short-circuit:'); try { - HttpAdapter::driver('demo')->status()->withPathParam('code', 500)->send(); + HttpAdapter::driver('sample')->showPost()->withPathParam('id', 1)->send(); $this->error(' Expected the circuit to be open, but the call went through.'); return self::FAILURE; diff --git a/workbench/app/Console/Commands/SampleApiCommand.php b/workbench/app/Console/Commands/SampleApiCommand.php new file mode 100644 index 0000000..b968742 --- /dev/null +++ b/workbench/app/Console/Commands/SampleApiCommand.php @@ -0,0 +1,53 @@ +argument('post'); + + try { + $post = HttpAdapter::driver('sample') + ->showPost() + ->withPathParam('id', $id) + ->send() + ->json(); + } catch (ConnectionException $e) { + $this->error("Could not reach the sample API: {$e->getMessage()}"); + + return self::FAILURE; + } + + $this->info("GET /posts/{$id}"); + $this->line(' title: '.($post['title'] ?? '—')); + + $comments = HttpAdapter::driver('sample')->commentsFor($id); + $this->info('GET /posts/'.$id.'/comments'); + $this->line(' '.count($comments).' comment(s)'); + + // The same endpoint served from its 'mock' entry — no network call. + // Resolved drivers are memoised by Illuminate\Support\Manager, so the + // cached instance has to be dropped for the new config to take effect. + config()->set('http-adapter.drivers.sample.mock_enabled', true); + HttpAdapter::forgetDrivers(); + + $created = HttpAdapter::driver('sample') + ->createPost(['title' => 'from the workbench']) + ->send(); + + $this->info('POST /posts (mocked)'); + $this->line(' '.json_encode($created->json())); + + return self::SUCCESS; + } +} diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php index c022b4f..eca8a32 100644 --- a/workbench/app/Providers/WorkbenchServiceProvider.php +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -3,8 +3,9 @@ namespace Workbench\App\Providers; use Illuminate\Support\ServiceProvider; -use Workbench\App\Adapters\DemoClient; +use Workbench\App\Adapters\SampleApiClient; use Workbench\App\Console\Commands\CircuitDemoCommand; +use Workbench\App\Console\Commands\SampleApiCommand; class WorkbenchServiceProvider extends ServiceProvider { @@ -18,15 +19,15 @@ public function register(): void // which has no cache table). config()->set('http-adapter.circuit_breaker.store', 'array'); - // Register a demo driver so the package has something to resolve in the - // booted workbench app. Circuit breaker is enabled with a low threshold - // so the demo command can trip it quickly. - config()->set('http-adapter.drivers.demo', [ - 'client' => DemoClient::class, - 'base_url' => 'https://httpbin.org', - 'timeout' => 5, - 'mock_enabled' => false, - 'retry' => [], + // Register the sample driver so the package has something to resolve in + // the booted workbench app. The circuit breaker is enabled with a low + // threshold so the demo command can trip it quickly. + config()->set('http-adapter.drivers.sample', [ + 'client' => SampleApiClient::class, + 'base_url' => env('SAMPLE_API_URL', 'https://jsonplaceholder.typicode.com'), + 'timeout' => 10, + 'mock_enabled' => env('SAMPLE_API_MOCK', false), + 'retry' => ['times' => 2, 'backoff_ms' => 200], 'circuit_breaker' => ['enabled' => true, 'failure_threshold' => 3, 'cooldown_seconds' => 30], 'extra' => [], ]); @@ -39,6 +40,7 @@ public function boot(): void { if ($this->app->runningInConsole()) { $this->commands([ + SampleApiCommand::class, CircuitDemoCommand::class, ]); }