Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion codegen/layouts/seam-client.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 21 additions & 6 deletions src/Http/ClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, string>
*/
Expand Down
2 changes: 1 addition & 1 deletion src/Seam.php

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/SeamWithoutWorkspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
109 changes: 109 additions & 0 deletions tests/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading