Skip to content

Commit dba30a4

Browse files
committed
feat(http): add GuzzleHttp middleware to dispatch Laravel HTTP events
1 parent d60f0a0 commit dba30a4

File tree

3 files changed

+210
-1
lines changed

3 files changed

+210
-1
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
namespace OpenAI\Laravel\Http\Adapters;
4+
5+
use GuzzleHttp\Client;
6+
use GuzzleHttp\Exception\RequestException;
7+
use GuzzleHttp\HandlerStack;
8+
use GuzzleHttp\Middleware;
9+
use GuzzleHttp\Promise\PromiseInterface;
10+
use Illuminate\Http\Client\ConnectionException;
11+
use Illuminate\Http\Client\Events\ConnectionFailed;
12+
use Illuminate\Http\Client\Events\RequestSending;
13+
use Illuminate\Http\Client\Events\ResponseReceived;
14+
use Illuminate\Http\Client\Request;
15+
use Illuminate\Http\Client\Response;
16+
use Illuminate\Support\Facades\Event;
17+
use Psr\Http\Message\RequestInterface;
18+
use Psr\Http\Message\ResponseInterface;
19+
20+
/**
21+
* @internal
22+
*
23+
* This class hooks into GuzzleHttp's middleware and fires
24+
* Laravel's HTTP client events.
25+
*/
26+
class GuzzleHttpAdapter
27+
{
28+
/**
29+
* Create a new GuzzleHttp client instance that dispatches Laravel HTTP events.
30+
*
31+
* @param array<string, mixed> $options
32+
*/
33+
public function client(array $options = [], ?callable $handler = null): Client
34+
{
35+
$stack = HandlerStack::create($handler);
36+
37+
// Push middleware that hooks into Guzzle’s request lifecycle
38+
$stack->push(Middleware::tap(
39+
// Before sending the request → dispatch "sending" event
40+
function (RequestInterface $request) {
41+
event(new RequestSending(new Request($request)));
42+
43+
return $request;
44+
},
45+
46+
// After sending → handle promise (success or failure)
47+
function (RequestInterface $request, array $_opt, PromiseInterface $promise) {
48+
return $promise
49+
->then(function (ResponseInterface $response) use ($request) {
50+
// Dispatch "response received" event
51+
Event::dispatch(new ResponseReceived(
52+
new Request($request),
53+
new Response($response)
54+
));
55+
56+
return $response;
57+
})
58+
->otherwise(function ($reason) use ($request) {
59+
// Normalize the failure into a Laravel ConnectionException
60+
$exception = $reason instanceof RequestException
61+
? new ConnectionException($reason->getMessage(), $reason->getCode(), $reason)
62+
: new ConnectionException(
63+
new RequestException('Connection failed', $request)
64+
);
65+
66+
// Dispatch "connection failed" event
67+
Event::dispatch(new ConnectionFailed(new Request($request), $exception));
68+
69+
// Re-throw the original rejection
70+
return \GuzzleHttp\Promise\Create::rejectionFor($reason);
71+
});
72+
}
73+
));
74+
75+
return new Client(array_merge($options, ['handler' => $stack]));
76+
}
77+
78+
/**
79+
* Create a GuzzleHttp with optional options and handler stack.
80+
*
81+
* @param array<string, mixed> $options
82+
*/
83+
public static function make(array $options = [], ?callable $stack = null): Client
84+
{
85+
return (new self)->client($options, $stack);
86+
}
87+
88+
/**
89+
* Create GuzzleHttp with specify the timeout (in seconds) for the client.
90+
*/
91+
public static function timeout(mixed $seconds): Client
92+
{
93+
return static::make(['timeout' => $seconds]);
94+
}
95+
}

src/ServiceProvider.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use OpenAI\Contracts\ClientContract;
1212
use OpenAI\Laravel\Commands\InstallCommand;
1313
use OpenAI\Laravel\Exceptions\ApiKeyIsMissing;
14+
use OpenAI\Laravel\Http\Adapters\GuzzleHttpAdapter;
1415

1516
/**
1617
* @internal
@@ -36,7 +37,7 @@ public function register(): void
3637
->withApiKey($apiKey)
3738
->withOrganization($organization)
3839
->withHttpHeader('OpenAI-Beta', 'assistants=v2')
39-
->withHttpClient(new \GuzzleHttp\Client(['timeout' => config('openai.request_timeout', 30)]));
40+
->withHttpClient(GuzzleHttpAdapter::timeout(config('openai.request_timeout', 30)));
4041

4142
if (is_string($project)) {
4243
$client->withProject($project);
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
use GuzzleHttp\Exception\RequestException;
4+
use GuzzleHttp\Handler\MockHandler;
5+
use GuzzleHttp\HandlerStack;
6+
use GuzzleHttp\Middleware;
7+
use GuzzleHttp\Psr7\Request as GuzzleRequest;
8+
use GuzzleHttp\Psr7\Response as GuzzleResponse;
9+
use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
10+
use Illuminate\Events\Dispatcher;
11+
use OpenAI\Laravel\Http\Adapters\GuzzleHttpAdapter;
12+
13+
beforeEach(function () {
14+
app()->instance(DispatcherContract::class, new Dispatcher(app()));
15+
app()->alias(DispatcherContract::class, 'events'); // Important
16+
});
17+
18+
it('create GuzzleHttp client instance', function () {
19+
expect((new GuzzleHttpAdapter)->make())->toBeInstanceOf(GuzzleHttp\Client::class);
20+
expect((new GuzzleHttpAdapter)->client())->toBeInstanceOf(GuzzleHttp\Client::class);
21+
expect((new GuzzleHttpAdapter)->timeout(3))->toBeInstanceOf(GuzzleHttp\Client::class);
22+
});
23+
24+
it('create GuzzleHttp client instance with options timeout and handler', function () {
25+
$timeout = 0;
26+
$handler = HandlerStack::create(new MockHandler([new GuzzleResponse(200, [], 'OK')]));
27+
$handler->push(Middleware::tap(
28+
after: function ($req, array $options) use (&$timeout) {
29+
$timeout = $options['timeout'];
30+
expect($options['handler'])->toBeInstanceOf(HandlerStack::class);
31+
}
32+
));
33+
$client = GuzzleHttpAdapter::make(['timeout' => 3], $handler);
34+
$response = $client->request('GET', 'https://example.com');
35+
36+
expect($client)->toBeInstanceOf(\GuzzleHttp\Client::class);
37+
expect($response)->toBeInstanceOf(\Psr\Http\Message\ResponseInterface::class);
38+
expect($response->getBody()->getContents())->toBe('OK');
39+
expect($response->getStatusCode())->toBe(200);
40+
expect($timeout)->toBe(3);
41+
});
42+
43+
it('runs middleware before and after callbacks to callable events', function () {
44+
$calledBefore = false;
45+
$calledAfter = false;
46+
47+
$mock = new MockHandler([new GuzzleResponse(200, [], 'OK')]);
48+
$stack = HandlerStack::create($mock);
49+
50+
$stack->push(Middleware::tap(
51+
before: function () use (&$calledBefore) {
52+
$calledBefore = true;
53+
},
54+
after: function ($req, array $options, $promise) use (&$calledAfter) {
55+
$calledAfter = true;
56+
}
57+
));
58+
59+
$client = (new GuzzleHttpAdapter)->client([], $stack);
60+
61+
$response = $client->request('GET', 'https://example.com');
62+
63+
// Force the response to complete
64+
expect($response->getBody()->getContents())->toBe('OK');
65+
expect($calledBefore)->toBeTrue();
66+
expect($calledAfter)->toBeTrue();
67+
expect($response->getStatusCode())->toBe(200);
68+
});
69+
70+
it('handles rejected promise without Laravel events', function () {
71+
72+
$mock = new MockHandler([new RequestException('Error', new GuzzleRequest('GET', 'test'))]);
73+
$handler = HandlerStack::create($mock);
74+
$client = (new GuzzleHttpAdapter)->client([], $handler);
75+
76+
$promise = $client->getAsync('https://example.com');
77+
78+
$rejection = null;
79+
$promise->then(null, function ($reason) use (&$rejection) {
80+
$rejection = $reason;
81+
})->wait();
82+
83+
expect($rejection)->toBeInstanceOf(RequestException::class);
84+
});
85+
86+
it('handles rejected promise and normalizes ConnectionException', function () {
87+
$calledAfter = false;
88+
$reason = null;
89+
90+
// Mock a rejected promise
91+
$request = new GuzzleRequest('GET', 'test');
92+
$mock = new MockHandler([new RequestException('Connection failed', $request)]);
93+
$stack = HandlerStack::create($mock);
94+
95+
// Push tap middleware to observe "after"
96+
$stack->push(Middleware::tap(
97+
after: function ($req, array $o, $promise) use (&$calledAfter, &$reason) {
98+
$promise->otherwise(function ($responseReason) use (&$calledAfter, &$reason) {
99+
$reason = $responseReason;
100+
$calledAfter = true;
101+
});
102+
}
103+
));
104+
105+
try {
106+
GuzzleHttpAdapter::make([], $stack)->request('GET', 'https://example.com');
107+
} catch (\GuzzleHttp\Exception\RequestException $e) {
108+
// expected
109+
}
110+
111+
expect($calledAfter)->toBeTrue(); // ensure the "after" handler ran
112+
expect($reason)->toBeInstanceOf(RequestException::class);
113+
});

0 commit comments

Comments
 (0)