Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 0 additions & 49 deletions .github/workflows/php.yml

This file was deleted.

87 changes: 87 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
111 changes: 84 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -55,75 +59,96 @@ 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();
}
}
```

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
`#[Driver]` attribute name — a mismatch fails fast at resolution time.

```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

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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -215,7 +249,7 @@ casing (`isSuccess` vs `IsSuccess`, `data` vs `Data`):
```php
use Ifds\HttpAdapter\Support\ExtractsTolerantFields;

class QuoteResult
class PostResult
{
use ExtractsTolerantFields;

Expand All @@ -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).
12 changes: 6 additions & 6 deletions config/http-adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')],
| ],
|
*/
Expand Down
Loading
Loading