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 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, ]); }