Skip to content

Commit a8532e4

Browse files
cb-alishclaude
andauthored
feat: add PSR-18/PSR-17 HTTP client injection support (#127)
* feat: add PSR-18/PSR-17 HTTP client injection support Allow injecting a PSR-18 ClientInterface directly into ChargebeeClient without implementing the Chargebee-specific HttpClientFactory interface. PSR-17 request/stream factories can optionally be provided; when omitted, php-http/discovery auto-discovers any installed implementation. New: src/HttpClient/PsrClientAdapter.php wraps a PSR-18 client and builds PSR-7 requests using injected or discovered PSR-17 factories. The existing HttpClientFactory injection path and the default GuzzleFactory are unchanged — fully backward compatible. New dependencies (require): - php-http/discovery ^1.0 - psr/http-factory ^1.0 Note: guzzlehttp/guzzle remains in require for v4.x compatibility. Removing it is deferred to the next major version. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Version bump --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 91b0624 commit a8532e4

7 files changed

Lines changed: 339 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
### v4.18.0 (2025-03-03)
2+
* * *
3+
### PSR-18 / PSR-17 HTTP client injection (backward compatible):
4+
- **Direct PSR-18 injection:** You can now inject a `Psr\Http\Client\ClientInterface` (PSR-18) directly into `ChargebeeClient` without implementing the Chargebee-specific `HttpClientFactory` interface.
5+
- **Optional PSR-17 factories:** When using a PSR-18 client, you may optionally pass `RequestFactoryInterface` and `StreamFactoryInterface` (PSR-17). When omitted, [php-http/discovery](https://github.com/php-http/discovery) auto-discovers any installed PSR-17 implementation.
6+
- **New adapter:** `Chargebee\HttpClient\PsrClientAdapter` wraps a PSR-18 client and builds PSR-7 requests using the injected or discovered PSR-17 request/stream factories.
7+
- **Backward compatibility:** The existing `HttpClientFactory` injection path and the default `GuzzleFactory` are unchanged.
8+
9+
### New dependencies (require):
10+
- `php-http/discovery` ^1.0
11+
- `psr/http-factory` ^1.0
12+
13+
114
### v4.17.0 (2026-03-02)
215
* * *
316
### New Resources:

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
4.17.0
1+
4.18.0

composer.json

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
"require": {
1414
"php": ">=8.1.0",
1515
"guzzlehttp/guzzle": ">=7",
16-
"psr/http-client": "^1.0"
16+
"php-http/discovery": "^1.0",
17+
"psr/http-client": "^1.0",
18+
"psr/http-factory": "^1.0"
19+
},
20+
"suggest": {
21+
"psr/http-client-implementation": "Any PSR-18 HTTP client (e.g. symfony/http-client) — in a future major version Guzzle will become optional.",
22+
"psr/http-factory-implementation": "Any PSR-17 factory (e.g. nyholm/psr7) — required if you inject a PSR-18 client without PSR-17 factories and Guzzle is not installed."
1723
},
1824
"autoload": {
1925
"psr-4": {
@@ -31,5 +37,10 @@
3137
"require-dev": {
3238
"phpstan/phpstan": "^2.1",
3339
"phpunit/phpunit": "^12.3"
40+
},
41+
"config": {
42+
"allow-plugins": {
43+
"php-http/discovery": true
44+
}
3445
}
35-
}
46+
}

src/ChargebeeClient.php

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,10 @@
133133

134134
use Chargebee\HttpClient\GuzzleFactory;
135135
use Chargebee\HttpClient\HttpClientFactory;
136+
use Chargebee\HttpClient\PsrClientAdapter;
137+
use Psr\Http\Client\ClientInterface;
138+
use Psr\Http\Message\RequestFactoryInterface;
139+
use Psr\Http\Message\StreamFactoryInterface;
136140

137141
class ChargebeeClient {
138142
private HttpClientFactory $httpClientFactory;
@@ -149,10 +153,18 @@ class ChargebeeClient {
149153
* retryConfig?: RetryConfig,
150154
* enableDebugLogs?: bool
151155
* } $options
152-
* @param HttpClientFactory|null $httpClient
156+
* @param HttpClientFactory|ClientInterface|null $httpClient Pass an HttpClientFactory for full control,
157+
* or a PSR-18 ClientInterface for a simpler injection path. When omitted, GuzzleFactory is used.
158+
* @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (used only when $httpClient is a ClientInterface)
159+
* @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory (used only when $httpClient is a ClientInterface)
153160
* @throws \Exception
154161
*/
155-
public function __construct($options, ?HttpClientFactory $httpClient=null)
162+
public function __construct(
163+
$options,
164+
HttpClientFactory|ClientInterface|null $httpClient = null,
165+
?RequestFactoryInterface $requestFactory = null,
166+
?StreamFactoryInterface $streamFactory = null,
167+
)
156168
{
157169
if (!is_array($options)) {
158170
throw new \Exception('$options must be of type array!');
@@ -183,7 +195,13 @@ public function __construct($options, ?HttpClientFactory $httpClient=null)
183195
$env->setEnableDebugLogs($options['enableDebugLogs']);
184196
}
185197
$this->env = $env;
186-
$this->httpClientFactory = $httpClient ?? new GuzzleFactory($env->requestTimeoutInSecs, $env->connectTimeoutInSecs);
198+
if ($httpClient instanceof ClientInterface) {
199+
$this->httpClientFactory = new PsrClientAdapter($httpClient, $requestFactory, $streamFactory);
200+
} elseif ($httpClient instanceof HttpClientFactory) {
201+
$this->httpClientFactory = $httpClient;
202+
} else {
203+
$this->httpClientFactory = new GuzzleFactory($env->requestTimeoutInSecs, $env->connectTimeoutInSecs);
204+
}
187205
}
188206

189207

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace Chargebee\HttpClient;
4+
5+
use Chargebee\ValueObjects\Transporters\ChargebeePayload;
6+
use Http\Discovery\Psr17FactoryDiscovery;
7+
use Psr\Http\Client\ClientInterface;
8+
use Psr\Http\Message\RequestFactoryInterface;
9+
use Psr\Http\Message\RequestInterface;
10+
use Psr\Http\Message\StreamFactoryInterface;
11+
12+
class PsrClientAdapter implements HttpClientFactory
13+
{
14+
public function __construct(
15+
private readonly ClientInterface $client,
16+
private readonly ?RequestFactoryInterface $requestFactory = null,
17+
private readonly ?StreamFactoryInterface $streamFactory = null,
18+
) {}
19+
20+
public function create(): ClientInterface
21+
{
22+
return $this->client;
23+
}
24+
25+
public function createRequest(ChargebeePayload $payload): RequestInterface
26+
{
27+
$httpMethod = $payload->getHttpMethod();
28+
$params = $payload->getSerializedParameters();
29+
$headers = $payload->getHeaders();
30+
31+
if (!in_array($httpMethod, ['get', 'post'], true)) {
32+
throw new \Exception("Invalid http method $httpMethod");
33+
}
34+
35+
$url = self::utf8($payload->getUrl());
36+
37+
$requestFactory = $this->requestFactory ?? Psr17FactoryDiscovery::findRequestFactory();
38+
$streamFactory = $this->streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
39+
40+
if ($httpMethod === 'get' && !empty($params)) {
41+
$url .= '?' . $params;
42+
}
43+
$request = $requestFactory->createRequest(strtoupper($httpMethod), $url);
44+
foreach ($headers as $name => $value) {
45+
$request = $request->withHeader($name, $value);
46+
}
47+
if ($httpMethod === 'post' && !empty($params)) {
48+
$request = $request->withBody($streamFactory->createStream($params));
49+
}
50+
return $request;
51+
}
52+
53+
private static function utf8(string $value): string
54+
{
55+
return mb_convert_encoding($value, 'UTF-8');
56+
}
57+
}

src/Version.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
final class Version
66
{
7-
const VERSION = '4.17.0';
7+
const VERSION = '4.18.0';
88
}
99

1010
?>
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
<?php
2+
3+
namespace Tests\Network;
4+
5+
use Chargebee\ChargebeeClient;
6+
use Chargebee\Environment;
7+
use Chargebee\HttpClient\PsrClientAdapter;
8+
use Chargebee\ValueObjects\Transporters\ChargebeePayload;
9+
use GuzzleHttp\Psr7\Response;
10+
use PHPUnit\Framework\Attributes\CoversClass;
11+
use PHPUnit\Framework\Attributes\TestDox;
12+
use PHPUnit\Framework\TestCase;
13+
use Psr\Http\Client\ClientInterface;
14+
use Psr\Http\Message\RequestFactoryInterface;
15+
use Psr\Http\Message\RequestInterface;
16+
use Psr\Http\Message\ResponseInterface;
17+
use Psr\Http\Message\StreamFactoryInterface;
18+
use Psr\Http\Message\StreamInterface;
19+
use Psr\Http\Message\UriInterface;
20+
21+
#[CoversClass(PsrClientAdapter::class)]
22+
final class PsrClientAdapterTest extends TestCase
23+
{
24+
private function makePayload(
25+
string $method,
26+
string $url,
27+
?string $params = null,
28+
array $headers = []
29+
): ChargebeePayload {
30+
$env = new Environment('test-site', 'test-api-key');
31+
return new ChargebeePayload($url, $method, $params, $headers, $env);
32+
}
33+
34+
private function makeStubClient(?ResponseInterface $response = null): ClientInterface
35+
{
36+
$response ??= new Response(200, [], '{}');
37+
38+
return new class($response) implements ClientInterface {
39+
private ResponseInterface $response;
40+
public array $sentRequests = [];
41+
42+
public function __construct(ResponseInterface $response)
43+
{
44+
$this->response = $response;
45+
}
46+
47+
public function sendRequest(RequestInterface $request): ResponseInterface
48+
{
49+
$this->sentRequests[] = $request;
50+
return $this->response;
51+
}
52+
};
53+
}
54+
55+
#[TestDox('PsrClientAdapter::create() returns the injected PSR-18 client')]
56+
public function testCreateReturnsInjectedClient(): void
57+
{
58+
$client = $this->makeStubClient();
59+
$adapter = new PsrClientAdapter($client);
60+
61+
$this->assertSame($client, $adapter->create());
62+
}
63+
64+
#[TestDox('GET request with no PSR-17 factories discovers them automatically')]
65+
public function testGetRequestWithDiscoveredFactories(): void
66+
{
67+
$client = $this->makeStubClient();
68+
$adapter = new PsrClientAdapter($client);
69+
70+
$payload = $this->makePayload('get', 'https://example.com/api/v2/customers', 'limit=10', [
71+
'Authorization' => 'Basic dGVzdA==',
72+
]);
73+
74+
$request = $adapter->createRequest($payload);
75+
76+
$this->assertSame('GET', strtoupper($request->getMethod()));
77+
$this->assertStringContainsString('limit=10', (string) $request->getUri());
78+
$this->assertSame('Basic dGVzdA==', $request->getHeaderLine('Authorization'));
79+
}
80+
81+
#[TestDox('POST request with no PSR-17 factories discovers them automatically')]
82+
public function testPostRequestWithDiscoveredFactories(): void
83+
{
84+
$client = $this->makeStubClient();
85+
$adapter = new PsrClientAdapter($client);
86+
87+
$payload = $this->makePayload('post', 'https://example.com/api/v2/customers', 'first_name=John', [
88+
'Content-Type' => 'application/x-www-form-urlencoded',
89+
]);
90+
91+
$request = $adapter->createRequest($payload);
92+
93+
$this->assertSame('POST', strtoupper($request->getMethod()));
94+
$this->assertSame('first_name=John', (string) $request->getBody());
95+
}
96+
97+
#[TestDox('GET request with PSR-17 factories uses them for request building')]
98+
public function testGetRequestWithPsr17Factories(): void
99+
{
100+
$client = $this->makeStubClient();
101+
[$requestFactory, $streamFactory] = $this->makePsr17Factories();
102+
103+
$adapter = new PsrClientAdapter($client, $requestFactory, $streamFactory);
104+
105+
$payload = $this->makePayload('get', 'https://example.com/api/v2/customers', 'limit=5', [
106+
'Authorization' => 'Basic dGVzdA==',
107+
]);
108+
109+
$request = $adapter->createRequest($payload);
110+
111+
$this->assertSame('GET', strtoupper($request->getMethod()));
112+
$this->assertStringContainsString('limit=5', (string) $request->getUri());
113+
$this->assertSame('Basic dGVzdA==', $request->getHeaderLine('Authorization'));
114+
}
115+
116+
#[TestDox('POST request with PSR-17 factories uses them for request building')]
117+
public function testPostRequestWithPsr17Factories(): void
118+
{
119+
$client = $this->makeStubClient();
120+
[$requestFactory, $streamFactory] = $this->makePsr17Factories();
121+
122+
$adapter = new PsrClientAdapter($client, $requestFactory, $streamFactory);
123+
124+
$payload = $this->makePayload('post', 'https://example.com/api/v2/customers', 'first_name=Jane', [
125+
'Content-Type' => 'application/x-www-form-urlencoded',
126+
]);
127+
128+
$request = $adapter->createRequest($payload);
129+
130+
$this->assertSame('POST', strtoupper($request->getMethod()));
131+
$this->assertSame('first_name=Jane', (string) $request->getBody());
132+
}
133+
134+
#[TestDox('Invalid HTTP method throws an exception')]
135+
public function testInvalidHttpMethodThrows(): void
136+
{
137+
$client = $this->makeStubClient();
138+
$adapter = new PsrClientAdapter($client);
139+
140+
$payload = $this->makePayload('delete', 'https://example.com/api/v2/customers/123');
141+
142+
$this->expectException(\Exception::class);
143+
$this->expectExceptionMessageMatches('/Invalid http method delete/i');
144+
145+
$adapter->createRequest($payload);
146+
}
147+
148+
#[TestDox('Existing HttpClientFactory injection still works (regression)')]
149+
public function testExistingHttpClientFactoryInjectionStillWorks(): void
150+
{
151+
$mockFactory = new MockGuzzleFactory(
152+
1.0,
153+
3.0,
154+
[],
155+
200,
156+
'{"list":[],"next_offset":null}'
157+
);
158+
159+
$chargebeeClient = new ChargebeeClient([
160+
'site' => 'test-site',
161+
'apiKey' => 'test-api-key',
162+
], $mockFactory);
163+
164+
// Verify no exception is thrown during construction
165+
$this->assertInstanceOf(ChargebeeClient::class, $chargebeeClient);
166+
}
167+
168+
#[TestDox('PSR-18 ClientInterface can be injected directly into ChargebeeClient')]
169+
public function testPsrClientInterfaceInjectionIntoChargebeeClient(): void
170+
{
171+
$stubClient = $this->makeStubClient(
172+
new Response(200, [], '{"list":[],"next_offset":null}')
173+
);
174+
175+
$chargebeeClient = new ChargebeeClient([
176+
'site' => 'test-site',
177+
'apiKey' => 'test-api-key',
178+
], $stubClient);
179+
180+
$this->assertInstanceOf(ChargebeeClient::class, $chargebeeClient);
181+
}
182+
183+
#[TestDox('PSR-18 + PSR-17 factories can be injected into ChargebeeClient')]
184+
public function testPsrClientWithFactoriesInjectionIntoChargebeeClient(): void
185+
{
186+
$stubClient = $this->makeStubClient(
187+
new Response(200, [], '{"list":[],"next_offset":null}')
188+
);
189+
[$requestFactory, $streamFactory] = $this->makePsr17Factories();
190+
191+
$chargebeeClient = new ChargebeeClient([
192+
'site' => 'test-site',
193+
'apiKey' => 'test-api-key',
194+
], $stubClient, $requestFactory, $streamFactory);
195+
196+
$this->assertInstanceOf(ChargebeeClient::class, $chargebeeClient);
197+
}
198+
199+
/**
200+
* Returns minimal anonymous-class implementations of PSR-17 factories
201+
* backed by Guzzle PSR-7 objects, so no extra test dependency is needed.
202+
*
203+
* @return array{RequestFactoryInterface, StreamFactoryInterface}
204+
*/
205+
private function makePsr17Factories(): array
206+
{
207+
$requestFactory = new class implements RequestFactoryInterface {
208+
public function createRequest(string $method, $uri): RequestInterface
209+
{
210+
return new \GuzzleHttp\Psr7\Request($method, $uri);
211+
}
212+
};
213+
214+
$streamFactory = new class implements StreamFactoryInterface {
215+
public function createStream(string $content = ''): StreamInterface
216+
{
217+
return \GuzzleHttp\Psr7\Utils::streamFor($content);
218+
}
219+
220+
public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
221+
{
222+
return \GuzzleHttp\Psr7\Utils::streamFor(fopen($filename, $mode));
223+
}
224+
225+
public function createStreamFromResource($resource): StreamInterface
226+
{
227+
return \GuzzleHttp\Psr7\Utils::streamFor($resource);
228+
}
229+
};
230+
231+
return [$requestFactory, $streamFactory];
232+
}
233+
}

0 commit comments

Comments
 (0)