Skip to content

Commit e56baa7

Browse files
committed
feat(http): add event dispatching and error handling to HttpRepository
Add RequestExecutedEvent dispatching for all HTTP requests with method, URI, options and response. Enhance error handling to extract response data from ClientException and include exception context in response headers.
1 parent 1b88f77 commit e56baa7

3 files changed

Lines changed: 289 additions & 9 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Serendipity\Domain\Event;
6+
7+
use Constructo\Contract\Message;
8+
9+
class RequestExecutedEvent
10+
{
11+
public function __construct(
12+
public readonly string $method,
13+
public readonly string $uri,
14+
public readonly array $options,
15+
public readonly ?Message $message = null,
16+
) {
17+
}
18+
}

src/Infrastructure/Repository/HttpRepository.php

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,40 @@
66

77
use Constructo\Contract\Message;
88
use GuzzleHttp\Client;
9+
use GuzzleHttp\Exception\ClientException;
910
use GuzzleHttp\Exception\GuzzleException;
1011
use Hyperf\Guzzle\ClientFactory;
12+
use Psr\EventDispatcher\EventDispatcherInterface;
1113
use Psr\Http\Message\ResponseInterface;
14+
use Serendipity\Domain\Contract\Support\ThrownFactory;
15+
use Serendipity\Domain\Event\RequestExecutedEvent;
1216
use Serendipity\Domain\Exception\RepositoryException;
17+
use Serendipity\Hyperf\Support\HyperfThrownFactory;
1318
use Serendipity\Infrastructure\Http\Received;
1419

20+
use function Constructo\Json\encode;
21+
use function Hyperf\Support\make;
22+
1523
abstract class HttpRepository
1624
{
1725
private readonly Client $client;
1826

19-
public function __construct(ClientFactory $clientFactory)
20-
{
21-
$this->client = $clientFactory->create($this->options());
27+
private readonly array $options;
28+
29+
private readonly EventDispatcherInterface $dispatcher;
30+
31+
private readonly ThrownFactory $thrownFactory;
32+
33+
public function __construct(
34+
ClientFactory $clientFactory,
35+
?EventDispatcherInterface $dispatcher = null,
36+
?ThrownFactory $thrownFactory = null,
37+
) {
38+
$this->options = $this->options();
39+
$this->client = $clientFactory->create($this->options);
40+
41+
$this->dispatcher = $dispatcher ?? make(EventDispatcherInterface::class);
42+
$this->thrownFactory = $thrownFactory ?? make(HyperfThrownFactory::class);
2243
}
2344

2445
abstract protected function options(): array;
@@ -28,27 +49,63 @@ abstract protected function options(): array;
2849
*/
2950
protected function request(string $method = 'POST', string $uri = '', array $options = []): Message
3051
{
52+
$message = null;
3153
/*
3254
* @see https://docs.guzzlephp.org/en/latest/quickstart.html#exceptions
3355
*/
3456
try {
3557
$response = $this->client->request($method, $uri, $options);
36-
return $this->format($response);
58+
$message = $this->format($response);
3759
} catch (GuzzleException $exception) {
60+
$message = $this->extract($exception);
3861
throw new RepositoryException(static::class, $exception);
62+
} finally {
63+
$this->dispatch($options, $method, $uri, $message);
3964
}
65+
return $message;
4066
}
4167

42-
private function format(ResponseInterface $response): Message
68+
private function extractHeaders(?ResponseInterface $response): array
4369
{
44-
$headers = array_map(
70+
return array_map(
4571
fn (array $item) => count($item) === 1
4672
? $item[0]
4773
: $item,
48-
$response->getHeaders()
74+
$response?->getHeaders() ?? []
4975
);
50-
$content = $response->getBody()
51-
->getContents();
76+
}
77+
78+
private function extractBody(?ResponseInterface $response): ?string
79+
{
80+
$body = $response?->getBody();
81+
return $body?->getContents();
82+
}
83+
84+
private function format(ResponseInterface $response): Message
85+
{
86+
$headers = $this->extractHeaders($response);
87+
$content = $this->extractBody($response);
5288
return new Received($headers, $content);
5389
}
90+
91+
private function extract(GuzzleException $exception): Message
92+
{
93+
$headers = [];
94+
$content = null;
95+
if ($exception instanceof ClientException) {
96+
$response = $exception->getResponse();
97+
$headers = $this->extractHeaders($response);
98+
$content = $this->extractBody($response);
99+
}
100+
$thrown = $this->thrownFactory->make($exception);
101+
$headers['X-Exception'] = encode($thrown->context());
102+
return new Received($headers, $content);
103+
}
104+
105+
private function dispatch(array $options, string $method, string $uri, ?Message $message): void
106+
{
107+
$options = array_merge($this->options, $options);
108+
$event = new RequestExecutedEvent($method, $uri, $options, $message);
109+
$this->dispatcher->dispatch($event);
110+
}
54111
}

tests/Infrastructure/Repository/HttpRepositoryTest.php

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@
66

77
use GuzzleHttp\Client;
88
use GuzzleHttp\Exception\BadResponseException;
9+
use GuzzleHttp\Exception\ClientException;
10+
use GuzzleHttp\Exception\ConnectException;
911
use Hyperf\Guzzle\ClientFactory;
1012
use PHPUnit\Framework\TestCase;
13+
use Psr\EventDispatcher\EventDispatcherInterface;
1114
use Psr\Http\Message\RequestInterface;
1215
use Psr\Http\Message\ResponseInterface;
1316
use Psr\Http\Message\StreamInterface;
17+
use Serendipity\Domain\Contract\Support\ThrownFactory;
18+
use Serendipity\Domain\Event\RequestExecutedEvent;
19+
use Serendipity\Domain\Exception\Parser\Thrown;
1420
use Serendipity\Domain\Exception\RepositoryException;
21+
use Serendipity\Domain\Exception\ThrowableType;
1522

1623
class HttpRepositoryTest extends TestCase
1724
{
@@ -76,4 +83,202 @@ public function testShouldRaiseGeneralException(): void
7683
$repository = new HttpRepositoryTestMock($clientFactory);
7784
$repository->exposeRequest();
7885
}
86+
87+
public function testShouldExtractHeadersWithMultipleValues(): void
88+
{
89+
$stream = $this->createMock(StreamInterface::class);
90+
$stream->expects($this->once())
91+
->method('getContents')
92+
->willReturn('{"data": "test"}');
93+
94+
$response = $this->createMock(ResponseInterface::class);
95+
$response->expects($this->once())
96+
->method('getHeaders')
97+
->willReturn([
98+
'Content-Type' => ['application/json'],
99+
'Set-Cookie' => ['cookie1=value1', 'cookie2=value2'],
100+
]);
101+
$response->expects($this->once())
102+
->method('getBody')
103+
->willReturn($stream);
104+
105+
$client = $this->createMock(Client::class);
106+
$client->expects($this->once())
107+
->method('request')
108+
->with('GET', '/test', [])
109+
->willReturn($response);
110+
111+
$clientFactory = $this->createMock(ClientFactory::class);
112+
$clientFactory->expects($this->once())
113+
->method('create')
114+
->willReturn($client);
115+
116+
$repository = new HttpRepositoryTestMock($clientFactory);
117+
$message = $repository->exposeRequest('GET', '/test');
118+
119+
$this->assertEquals('{"data": "test"}', $message->content());
120+
$this->assertEquals('application/json', $message->properties()->get('Content-Type'));
121+
$this->assertEquals(['cookie1=value1', 'cookie2=value2'], $message->properties()->get('Set-Cookie'));
122+
}
123+
124+
public function testShouldHandleClientExceptionWithResponse(): void
125+
{
126+
$stream = $this->createMock(StreamInterface::class);
127+
$stream->method('getContents')
128+
->willReturn('{"error": "Bad Request"}');
129+
130+
$response = $this->createMock(ResponseInterface::class);
131+
$response->method('getHeaders')
132+
->willReturn(['Content-Type' => ['application/json']]);
133+
$response->method('getBody')
134+
->willReturn($stream);
135+
136+
$exception = new ClientException(
137+
'Bad Request',
138+
$this->createMock(RequestInterface::class),
139+
$response
140+
);
141+
142+
$client = $this->createMock(Client::class);
143+
$client->expects($this->once())
144+
->method('request')
145+
->with('POST', '', [])
146+
->willThrowException($exception);
147+
148+
$clientFactory = $this->createMock(ClientFactory::class);
149+
$clientFactory->expects($this->once())
150+
->method('create')
151+
->willReturn($client);
152+
153+
$thrownFactory = $this->createMock(ThrownFactory::class);
154+
$thrownFactory->expects($this->once())
155+
->method('make')
156+
->with($exception)
157+
->willReturn(Thrown::createFrom($exception));
158+
159+
$dispatcher = $this->createMock(EventDispatcherInterface::class);
160+
$dispatcher->expects($this->once())
161+
->method('dispatch');
162+
163+
try {
164+
$repository = new HttpRepositoryTestMock($clientFactory, $dispatcher, $thrownFactory);
165+
$repository->exposeRequest();
166+
$this->fail('Expected RepositoryException to be thrown');
167+
} catch (RepositoryException $e) {
168+
$this->assertInstanceOf(RepositoryException::class, $e);
169+
}
170+
}
171+
172+
public function testShouldHandleNonClientException(): void
173+
{
174+
$this->expectException(RepositoryException::class);
175+
176+
$exception = new ConnectException(
177+
'Connection refused',
178+
$this->createMock(RequestInterface::class)
179+
);
180+
181+
$client = $this->createMock(Client::class);
182+
$client->expects($this->once())
183+
->method('request')
184+
->with('POST', '', [])
185+
->willThrowException($exception);
186+
187+
$clientFactory = $this->createMock(ClientFactory::class);
188+
$clientFactory->expects($this->once())
189+
->method('create')
190+
->willReturn($client);
191+
192+
$thrownFactory = $this->createMock(ThrownFactory::class);
193+
$thrownFactory->expects($this->once())
194+
->method('make')
195+
->with($exception)
196+
->willReturn(Thrown::createFrom($exception));
197+
198+
$repository = new HttpRepositoryTestMock($clientFactory, null, $thrownFactory);
199+
$repository->exposeRequest();
200+
}
201+
202+
public function testShouldDispatchEventOnSuccess(): void
203+
{
204+
$stream = $this->createMock(StreamInterface::class);
205+
$stream->expects($this->once())
206+
->method('getContents')
207+
->willReturn('{"message": "Success"}');
208+
209+
$response = $this->createMock(ResponseInterface::class);
210+
$response->expects($this->once())
211+
->method('getHeaders')
212+
->willReturn([]);
213+
$response->expects($this->once())
214+
->method('getBody')
215+
->willReturn($stream);
216+
217+
$client = $this->createMock(Client::class);
218+
$client->expects($this->once())
219+
->method('request')
220+
->with('POST', '/api', ['json' => ['key' => 'value']])
221+
->willReturn($response);
222+
223+
$clientFactory = $this->createMock(ClientFactory::class);
224+
$clientFactory->expects($this->once())
225+
->method('create')
226+
->willReturn($client);
227+
228+
$dispatcher = $this->createMock(EventDispatcherInterface::class);
229+
$dispatcher->expects($this->once())
230+
->method('dispatch')
231+
->with($this->callback(function ($event) {
232+
return $event instanceof RequestExecutedEvent
233+
&& $event->method === 'POST'
234+
&& $event->uri === '/api'
235+
&& isset($event->options['json'])
236+
&& $event->message !== null;
237+
}));
238+
239+
$repository = new HttpRepositoryTestMock($clientFactory, $dispatcher);
240+
$repository->exposeRequest('POST', '/api', ['json' => ['key' => 'value']]);
241+
}
242+
243+
public function testShouldDispatchEventOnFailure(): void
244+
{
245+
$exception = new ConnectException(
246+
'Connection refused',
247+
$this->createMock(RequestInterface::class)
248+
);
249+
250+
$client = $this->createMock(Client::class);
251+
$client->expects($this->once())
252+
->method('request')
253+
->with('POST', '/api', [])
254+
->willThrowException($exception);
255+
256+
$clientFactory = $this->createMock(ClientFactory::class);
257+
$clientFactory->expects($this->once())
258+
->method('create')
259+
->willReturn($client);
260+
261+
$dispatcher = $this->createMock(EventDispatcherInterface::class);
262+
$dispatcher->expects($this->once())
263+
->method('dispatch')
264+
->with($this->callback(function ($event) {
265+
return $event instanceof RequestExecutedEvent
266+
&& $event->method === 'POST'
267+
&& $event->uri === '/api'
268+
&& $event->message !== null;
269+
}));
270+
271+
$thrownFactory = $this->createMock(ThrownFactory::class);
272+
$thrownFactory->expects($this->once())
273+
->method('make')
274+
->with($exception)
275+
->willReturn(Thrown::createFrom($exception));
276+
277+
try {
278+
$repository = new HttpRepositoryTestMock($clientFactory, $dispatcher, $thrownFactory);
279+
$repository->exposeRequest('POST', '/api');
280+
} catch (RepositoryException $exception) {
281+
// Expected exception
282+
}
283+
}
79284
}

0 commit comments

Comments
 (0)