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);