Skip to content

Commit 94fad39

Browse files
committed
Phase 5: PSR-18 HTTP client support
- Add psr/http-client + psr/http-factory to require (interface packages only) - Remove constructor from HttpClientInterface — it blocked DI for adapters - Add Psr18Adapter implementing HttpClientInterface; factories are optional when the PSR-18 client also implements them (e.g. Symfony Psr18Client) - Add CoyoteCert::httpClient() builder method; wired through to Api for both issue() and ARI renewal checks
1 parent a4669a9 commit 94fad39

4 files changed

Lines changed: 139 additions & 3 deletions

File tree

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"ext-json": "*",
99
"ext-mbstring": "*",
1010
"ext-openssl": "*",
11+
"psr/http-client": "^1.0",
12+
"psr/http-factory": "^1.0",
1113
"psr/log": "^3.0",
1214
"spatie/dns": "^2.7"
1315
},

src/CoyoteCert.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
use CoyoteCert\Enums\KeyType;
99
use CoyoteCert\DTO\RenewalWindow;
1010
use CoyoteCert\Exceptions\LetsEncryptClientException;
11+
use CoyoteCert\Http\Psr18Adapter;
1112
use CoyoteCert\Interfaces\ChallengeHandlerInterface;
13+
use CoyoteCert\Interfaces\HttpClientInterface;
1214
use CoyoteCert\Provider\AcmeProviderInterface;
1315
use CoyoteCert\Storage\StoredCertificate;
1416
use CoyoteCert\Storage\StorageInterface;
@@ -37,6 +39,7 @@ class CoyoteCert
3739
{
3840
private ?StorageInterface $storage = null;
3941
private ?LoggerInterface $logger = null;
42+
private ?HttpClientInterface $httpClient = null;
4043
private string $email = '';
4144
private string $profile = '';
4245
private array $domains = [];
@@ -88,6 +91,22 @@ public function logger(LoggerInterface $logger): self
8891
return $this;
8992
}
9093

94+
/**
95+
* Use a PSR-18 HTTP client instead of the built-in curl client.
96+
*
97+
* $requestFactory and $streamFactory are optional when the PSR-18 client
98+
* also implements those interfaces (e.g. Symfony's Psr18Client).
99+
*/
100+
public function httpClient(
101+
\Psr\Http\Client\ClientInterface $client,
102+
?\Psr\Http\Message\RequestFactoryInterface $requestFactory = null,
103+
?\Psr\Http\Message\StreamFactoryInterface $streamFactory = null,
104+
): self {
105+
$this->httpClient = new Psr18Adapter($client, $requestFactory, $streamFactory);
106+
107+
return $this;
108+
}
109+
91110
/** @param string|string[] $domains */
92111
public function domains(array|string $domains): self
93112
{
@@ -173,6 +192,7 @@ public function issue(): StoredCertificate
173192
provider: $this->provider,
174193
storage: $this->storage,
175194
logger: $this->logger,
195+
httpClient: $this->httpClient,
176196
accountKeyType: $this->accountKeyType,
177197
);
178198

@@ -297,7 +317,7 @@ private function ariWindow(StoredCertificate $cert): ?RenewalWindow
297317
}
298318

299319
try {
300-
return (new Api(provider: $this->provider, logger: $this->logger))
320+
return (new Api(provider: $this->provider, logger: $this->logger, httpClient: $this->httpClient))
301321
->renewalInfo()
302322
->get($cert->certificate, $m[1]);
303323
} catch (\Throwable) {

src/Http/Psr18Adapter.php

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
namespace CoyoteCert\Http;
4+
5+
use Psr\Http\Client\ClientInterface;
6+
use Psr\Http\Message\RequestFactoryInterface;
7+
use Psr\Http\Message\StreamFactoryInterface;
8+
use CoyoteCert\Interfaces\HttpClientInterface;
9+
10+
/**
11+
* Adapts any PSR-18 HTTP client to CoyoteCert's internal HttpClientInterface.
12+
*
13+
* Both $requestFactory and $streamFactory are optional when the PSR-18 client
14+
* also implements those interfaces (e.g. Symfony's Psr18Client, nyholm/psr7).
15+
*
16+
* Usage:
17+
*
18+
* // Symfony — client implements all three interfaces
19+
* CoyoteCert::with(new LetsEncrypt())
20+
* ->httpClient(new \Symfony\Component\HttpClient\Psr18Client())
21+
* ->issue();
22+
*
23+
* // Guzzle — separate factory required
24+
* CoyoteCert::with(new LetsEncrypt())
25+
* ->httpClient(new \GuzzleHttp\Client(), new \GuzzleHttp\Psr7\HttpFactory())
26+
* ->issue();
27+
*/
28+
class Psr18Adapter implements HttpClientInterface
29+
{
30+
private RequestFactoryInterface $requestFactory;
31+
private StreamFactoryInterface $streamFactory;
32+
33+
public function __construct(
34+
private readonly ClientInterface $client,
35+
?RequestFactoryInterface $requestFactory = null,
36+
?StreamFactoryInterface $streamFactory = null,
37+
) {
38+
$this->requestFactory = $requestFactory
39+
?? ($client instanceof RequestFactoryInterface ? $client
40+
: throw new \InvalidArgumentException(
41+
'Provide a RequestFactoryInterface or use a client that implements it (e.g. Symfony Psr18Client).'
42+
));
43+
44+
$this->streamFactory = $streamFactory
45+
?? ($client instanceof StreamFactoryInterface ? $client
46+
: throw new \InvalidArgumentException(
47+
'Provide a StreamFactoryInterface or use a client that implements it (e.g. Symfony Psr18Client).'
48+
));
49+
}
50+
51+
public function head(string $url): Response
52+
{
53+
$request = $this->requestFactory->createRequest('HEAD', $url)
54+
->withHeader('User-Agent', 'blendbyte/coyote-cert')
55+
->withHeader('Accept', 'application/json');
56+
57+
return $this->send($request, $url);
58+
}
59+
60+
public function get(string $url, array $headers = [], array $arguments = [], int $maxRedirects = 0): Response
61+
{
62+
if (!empty($arguments)) {
63+
$url .= '?' . http_build_query($arguments);
64+
}
65+
66+
$request = $this->requestFactory->createRequest('GET', $url)
67+
->withHeader('User-Agent', 'blendbyte/coyote-cert')
68+
->withHeader('Accept', 'application/json');
69+
70+
foreach ($headers as $header) {
71+
[$name, $value] = explode(':', $header, 2);
72+
$request = $request->withAddedHeader(trim($name), trim($value));
73+
}
74+
75+
return $this->send($request, $url);
76+
}
77+
78+
public function post(string $url, array $payload = [], array $headers = [], int $maxRedirects = 0): Response
79+
{
80+
$body = json_encode($payload, JSON_THROW_ON_ERROR);
81+
$request = $this->requestFactory->createRequest('POST', $url)
82+
->withHeader('User-Agent', 'blendbyte/coyote-cert')
83+
->withHeader('Accept', 'application/json')
84+
->withHeader('Content-Type', 'application/jose+json')
85+
->withBody($this->streamFactory->createStream($body));
86+
87+
foreach ($headers as $header) {
88+
[$name, $value] = explode(':', $header, 2);
89+
$request = $request->withAddedHeader(trim($name), trim($value));
90+
}
91+
92+
return $this->send($request, $url);
93+
}
94+
95+
private function send(\Psr\Http\Message\RequestInterface $request, string $url): Response
96+
{
97+
$psrResponse = $this->client->sendRequest($request);
98+
99+
$headers = [];
100+
foreach ($psrResponse->getHeaders() as $name => $values) {
101+
$headers[strtolower($name)] = implode(', ', $values);
102+
}
103+
104+
$rawBody = (string) $psrResponse->getBody();
105+
$body = json_validate($rawBody)
106+
? json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR)
107+
: $rawBody;
108+
109+
return new Response(
110+
headers: $headers,
111+
requestedUrl: $url,
112+
statusCode: $psrResponse->getStatusCode(),
113+
body: $body,
114+
);
115+
}
116+
}

src/Interfaces/HttpClientInterface.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66

77
interface HttpClientInterface
88
{
9-
public function __construct(int $timeout = 10);
10-
119
public function head(string $url): Response;
1210

1311
public function get(string $url, array $headers = [], array $arguments = [], int $maxRedirects = 0): Response;

0 commit comments

Comments
 (0)