From 7baefe972de9f3e1336701fd9a431018a2fea469 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 21:32:28 +0000 Subject: [PATCH] feat: Expose the Seam middleware for a caller built client A client passed with the client option is used exactly as given, which means it does not carry the SDK's error mapping or retries: an API error raises Guzzle's exception rather than HttpApiError, and a retryable failure is not retried. Nothing said so, and the README's own from_client example is subject to it. Leave that behavior alone and make it opt in instead. Guzzle fixes the handler stack when a client is constructed, so the middleware cannot be added afterwards; ClientFactory::add_middleware puts it on a stack the caller builds the client with. ClientFactory::create now uses the same method, so there is one definition of the order the middleware goes on in. Applying it twice would stack two sets of retries, so it is documented as once per stack rather than guarded, since the caller owns the stack. Also correct the claim that $seam->client is the Guzzle client. It is a SerializingClient implementing ClientInterface, so Guzzle specific calls on it fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HH3wdHh4Y6Wjyc5uHwk5iG --- README.md | 41 +++++++++++- codegen/layouts/seam-client.hbs | 2 +- src/Http/ClientFactory.php | 27 ++++++-- src/Seam.php | 2 +- src/SeamWithoutWorkspace.php | 2 +- tests/ClientTest.php | 109 ++++++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2145cf1f..bb82b09c 100644 --- a/README.md +++ b/README.md @@ -390,10 +390,11 @@ $seam = new Seam\Seam(retries: 5); $seam = new Seam\Seam(retries: 0); ``` -#### Using the Guzzle client +#### Using the underlying client -`$seam->client` is the [Guzzle] client, already carrying the endpoint and -authorization, so it can be used to reach an endpoint the SDK does not expose. +`$seam->client` already carries the endpoint, authorization, error mapping, +and retries, so it can be used to reach an endpoint the SDK does not expose. +It wraps the [Guzzle] client and implements Guzzle's `ClientInterface`. [Guzzle]: https://docs.guzzlephp.org/ @@ -420,6 +421,40 @@ $client = new GuzzleHttp\Client([ $seam = Seam\Seam::from_client($client); ``` +The client is used exactly as given. It does not gain the SDK's error mapping +or retries, so an API error raises Guzzle's exception rather than +`Seam\HttpApiError`. To opt in, add the middleware yourself. + +#### Adding the Seam middleware to your own client + +`Seam\Http\ClientFactory::add_middleware` puts the error mapping and retry +middleware on a handler stack. Build the client with that stack, and with +`http_errors` disabled so the error middleware raises instead of Guzzle. + +```php +$handler = GuzzleHttp\HandlerStack::create(); + +Seam\Http\ClientFactory::add_middleware($handler); + +$client = new GuzzleHttp\Client([ + "base_uri" => "https://connect.getseam.com", + "headers" => ["authorization" => "Bearer " . $api_key], + "handler" => $handler, + "http_errors" => false, +]); + +$seam = Seam\Seam::from_client($client); +``` + +Pass `retries` to change how many times a failed request is retried, or `0` +to disable them: + +```php +Seam\Http\ClientFactory::add_middleware($handler, retries: 0); +``` + +Add it once per stack: applying it twice stacks two sets of retries. + #### Serializing URL search params The Seam API parses URL search params as complex types. diff --git a/codegen/layouts/seam-client.hbs b/codegen/layouts/seam-client.hbs index fa1faede..4e26a88f 100644 --- a/codegen/layouts/seam-client.hbs +++ b/codegen/layouts/seam-client.hbs @@ -28,7 +28,7 @@ class Seam {{/each}} /** - * The Guzzle client this instance makes its requests with. + * The client this instance makes its requests with. * * Query params given as a map and NullValue::NULL sentinels in JSON * bodies are serialized with the Seam standard before the request goes diff --git a/src/Http/ClientFactory.php b/src/Http/ClientFactory.php index 9ba1451f..77bffdb2 100644 --- a/src/Http/ClientFactory.php +++ b/src/Http/ClientFactory.php @@ -51,12 +51,7 @@ public static function create( $handler = HandlerStack::create($handler); } - // Unshifted so it sits outside every other middleware and only sees - // a response none of them could act on: a redirect is followed - // rather than raised, and a retried request is judged by the - // response it finally settled on. - $handler->unshift(ErrorMiddleware::create(), "seam_error"); - RetryMiddleware::add($handler, $retries); + self::add_middleware($handler, $retries); $headers = array_merge( $auth_headers, @@ -83,6 +78,26 @@ public static function create( ); } + /** + * Adds the Seam error mapping and retry middleware to a handler stack. + * + * Build the client with the stack, and with `http_errors` disabled so + * the error middleware raises instead of Guzzle. + * + * @param int|null $retries Defaults to self::DEFAULT_RETRIES. + */ + public static function add_middleware( + HandlerStack $handler, + ?int $retries = null, + ): void { + // Unshifted so it sits outside every other middleware and only sees + // a response none of them could act on: a redirect is followed + // rather than raised, and a retried request is judged by the + // response it finally settled on. + $handler->unshift(ErrorMiddleware::create(), "seam_error"); + RetryMiddleware::add($handler, $retries ?? self::DEFAULT_RETRIES); + } + /** * @return array */ diff --git a/src/Seam.php b/src/Seam.php index 4ad10234..3f423eea 100644 --- a/src/Seam.php +++ b/src/Seam.php @@ -62,7 +62,7 @@ class Seam public WorkspacesClient $workspaces; /** - * The Guzzle client this instance makes its requests with. + * The client this instance makes its requests with. * * Query params given as a map and NullValue::NULL sentinels in JSON * bodies are serialized with the Seam standard before the request goes diff --git a/src/SeamWithoutWorkspace.php b/src/SeamWithoutWorkspace.php index 69e3cc7b..603ba22e 100644 --- a/src/SeamWithoutWorkspace.php +++ b/src/SeamWithoutWorkspace.php @@ -19,7 +19,7 @@ class SeamWithoutWorkspace { /** - * The Guzzle client this instance makes its requests with. + * The client this instance makes its requests with. */ public ClientInterface $client; diff --git a/tests/ClientTest.php b/tests/ClientTest.php index b565c9c1..0f6c5feb 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -65,6 +65,115 @@ public function testFromClientNeedsNoCredentials(): void ); } + private function foreign_client(array $options = []): \GuzzleHttp\Client + { + return new \GuzzleHttp\Client( + array_merge( + [ + "base_uri" => $this->endpoint, + "headers" => [ + "authorization" => + "Bearer " . $this->seed["seam_apikey1_token"], + ], + ], + $options, + ), + ); + } + + public function testAnInjectedClientIsUsedAsGiven(): void + { + $seam = Seam::from_client($this->foreign_client()); + + $device = $seam->devices->get($this->seed["august_device_1"]); + $this->assertSame($this->seed["august_device_1"], $device->device_id); + + $this->expectException(\GuzzleHttp\Exception\ClientException::class); + + $seam->devices->get("nonexistent-device-id"); + } + + public function testAddMiddlewareGivesAnInjectedClientSeamErrors(): void + { + $handler = \GuzzleHttp\HandlerStack::create(); + ClientFactory::add_middleware($handler); + + $seam = Seam::from_client( + $this->foreign_client([ + "handler" => $handler, + "http_errors" => false, + ]), + ); + + $this->expectException(HttpApiError::class); + + $seam->devices->get("nonexistent-device-id"); + } + + public function testAddMiddlewareGivesAnInjectedClientRetries(): void + { + $recorder = RecordingClient::repeating( + RecordingClient::json(503, [ + "error" => [ + "type" => "unavailable", + "message" => "Service Unavailable", + ], + ]), + times: 5, + ); + + $handler = \GuzzleHttp\HandlerStack::create( + $recorder->guzzle_options()["handler"], + ); + ClientFactory::add_middleware($handler); + + $seam = Seam::from_client( + $this->foreign_client([ + "handler" => $handler, + "http_errors" => false, + ]), + ); + + try { + $seam->devices->get("d1"); + $this->fail("Expected an HttpApiError"); + } catch (HttpApiError) { + $this->assertSame(3, $recorder->attempt_count()); + } + } + + public function testAddMiddlewareHonoursARetryCount(): void + { + $recorder = RecordingClient::repeating( + RecordingClient::json(503, [ + "error" => [ + "type" => "unavailable", + "message" => "Service Unavailable", + ], + ]), + times: 5, + ); + + $handler = \GuzzleHttp\HandlerStack::create( + $recorder->guzzle_options()["handler"], + ); + ClientFactory::add_middleware($handler, retries: 0); + + $seam = Seam::from_client( + $this->foreign_client([ + "handler" => $handler, + "http_errors" => false, + ]), + ); + + try { + $seam->devices->get("d1"); + $this->fail("Expected an HttpApiError"); + } catch (HttpApiError) { + $this->assertSame(1, $recorder->attempt_count()); + } + } + public function testClientOptionReusesAnotherInstancesClient(): void { $seam = new Seam(client: $this->seam()->client);