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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.1.1] - 2026-10-23

### Fixed

- `$chip->publicKey->get()` no longer throws `TypeError: Chip\Http\GuzzleClient::request(): Return value must be of type stdClass|array, string returned`. `GET /public_key/` answers with a JSON-encoded PEM string, so the HTTP client now returns decoded string bodies as-is and `PublicKeyResource::get()` returns the bare PEM, making it directly usable with `ChipApi::verify()`.
- The HTTP client no longer discards non-JSON response bodies: plain-text bodies (e.g. a bare PEM) are returned as-is instead of being silently turned into an empty `stdClass`. Empty bodies still return `stdClass`.
- `ClientInterface::request()` return type widened to `\stdClass|array<string, mixed>|string`; `BillingResource::create()` follows suit. Implementations that declare the narrower `\stdClass|array` remain valid (return type covariance).
- Error responses no longer throw a `TypeError` when the failure body is not a JSON object: a `detail`/`message` that is a list or object is JSON-encoded into the exception message, and a JSON scalar or plain-text body is reported as an empty `getResponseBody()`. The failure stays a `ChipApiException`, so `catch (ChipApiException $e)` keeps working instead of dying on a type error.
- Paginated `iterate()` loops only follow a real URL: `next: false` used to be a `TypeError` (`Argument #2 ($endpoint) must be of type string, false given`) and `next: ""` looped forever. Affects `webhooks`, `company_statements`, `billing_templates`, `billing_templates/{id}/clients` and `clients`.

## [2.1.0] - 2026-09-01

### Fixed
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,10 @@ $statement = $chip->statements->cancel($statementId);
### Public Key

```php
// PEM public key for verifying callback signatures
$publicKey = $chip->publicKey->get();

$isValid = ChipApi::verify($callbackBody, $signatureHeader, $publicKey);
```

### Billing
Expand Down
7 changes: 5 additions & 2 deletions lib/Http/ClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
interface ClientInterface
{
/**
* `GET /public_key/` answers with a JSON-encoded PEM string rather than an
* object, so a decoded string body is returned as-is.
*
* @param array<string, mixed> $options
* @return \stdClass|array<string, mixed>
* @return \stdClass|array<string, mixed>|string
* @throws \Chip\Exception\ChipApiException
*/
public function request(string $method, string $endpoint, array $options = []): \stdClass|array;
public function request(string $method, string $endpoint, array $options = []): \stdClass|array|string;
}
65 changes: 54 additions & 11 deletions lib/Http/GuzzleClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Chip\Exception\ServerException;
use Chip\Exception\ValidationException;
use GuzzleHttp\Exception\ClientException as GuzzleClientException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\ServerException as GuzzleServerException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
Expand Down Expand Up @@ -40,9 +41,9 @@ public function __construct(

/**
* @param array<string, mixed> $options
* @return \stdClass|array<string, mixed>
* @return \stdClass|array<string, mixed>|string
*/
public function request(string $method, string $endpoint, array $options = []): \stdClass|array
public function request(string $method, string $endpoint, array $options = []): \stdClass|array|string
{
$headers = [];
if ($this->apiKey) {
Expand All @@ -61,10 +62,7 @@ public function request(string $method, string $endpoint, array $options = []):
try {
$response = $this->client->request($method, $endpoint, $mergedOptions);
} catch (GuzzleClientException $e) {
$response = $e->getResponse();
$statusCode = $response->getStatusCode();
$body = json_decode((string) $response->getBody(), true) ?? [];
$message = $body['detail'] ?? $body['message'] ?? $e->getMessage();
[$statusCode, $body, $message] = $this->describeError($e);

$this->logger->error('CHIP API client error', [
'status' => $statusCode,
Expand All @@ -78,10 +76,7 @@ public function request(string $method, string $endpoint, array $options = []):
default => new ClientException($message, $statusCode, $body, $e),
};
} catch (GuzzleServerException $e) {
$response = $e->getResponse();
$statusCode = $response->getStatusCode();
$body = json_decode((string) $response->getBody(), true) ?? [];
$message = $body['detail'] ?? $body['message'] ?? $e->getMessage();
[$statusCode, $body, $message] = $this->describeError($e);

$this->logger->error('CHIP API server error', [
'status' => $statusCode,
Expand All @@ -92,7 +87,55 @@ public function request(string $method, string $endpoint, array $options = []):
}

$body = (string) $response->getBody()->getContents();
$decoded = json_decode($body, true);

return json_decode($body, true) ?? new \stdClass();
if (is_array($decoded) || is_string($decoded)) {
return $decoded;
}

// Empty or non-array JSON body (e.g. `204 No Content`), or plain text
// such as a bare PEM: return the raw body instead of discarding it so
// plain-text endpoints stay usable, and keep `{}` for empty bodies.
$raw = trim($body);

return '' === $raw ? new \stdClass() : $raw;
}

/**
* Normalise a failed response into `[status, body, message]`.
*
* A failure is not guaranteed to carry a JSON object: a proxy in front of the
* gateway can answer with a JSON scalar or plain text, and `detail`/`message`
* can themselves be a list or an object. Coercing here keeps the failure a
* `ChipApiException` instead of letting a typed constructor argument turn it
* into a `TypeError` the caller cannot catch as an API error.
*
* @return array{0: int, 1: array<string, mixed>, 2: string}
*/
private function describeError(RequestException $e): array
{
$response = $e->getResponse();

if (null === $response) {
return [0, [], $e->getMessage()];
}

$statusCode = $response->getStatusCode();
$decoded = json_decode((string) $response->getBody(), true);
$body = is_array($decoded) ? $decoded : [];

$detail = $body['detail'] ?? $body['message'] ?? null;
$message = $e->getMessage();

if (is_string($detail) && '' !== $detail) {
$message = $detail;
} elseif (is_array($detail) && [] !== $detail) {
$encoded = json_encode($detail, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE);
if (is_string($encoded)) {
$message = $encoded;
}
}

return [$statusCode, $body, $message];
}
}
4 changes: 2 additions & 2 deletions lib/Http/RetryClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ public function __construct(

/**
* @param array<string, mixed> $options
* @return \stdClass|array<string, mixed>
* @return \stdClass|array<string, mixed>|string
*/
public function request(string $method, string $endpoint, array $options = []): \stdClass|array
public function request(string $method, string $endpoint, array $options = []): \stdClass|array|string
{
$attempt = 0;

Expand Down
14 changes: 10 additions & 4 deletions lib/Resource/BillingResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ public function __construct(private readonly ClientInterface $client)
}

/**
* @return array<string, mixed>|\stdClass
* @return array<string, mixed>|\stdClass|string
*/
public function create(BillingTemplate $billing): array|\stdClass
public function create(BillingTemplate $billing): array|\stdClass|string
{
return $this->client->request('POST', 'billing/', [
'json' => $billing,
Expand Down Expand Up @@ -59,7 +59,10 @@ public function iterateTemplates(): \Generator
yield $template;
}

$url = $list->next;
// `next` is string|null per the API, but `false`/"" show up too:
// a non-string is a TypeError and an empty string loops forever.
$next = $list->next;
$url = is_string($next) && '' !== $next ? $next : null;
}
}

Expand Down Expand Up @@ -124,7 +127,10 @@ public function iterateClients(string $billingId): \Generator
yield $client;
}

$url = $list->next;
// `next` is string|null per the API, but `false`/"" show up too:
// a non-string is a TypeError and an empty string loops forever.
$next = $list->next;
$url = is_string($next) && '' !== $next ? $next : null;
}
}

Expand Down
5 changes: 4 additions & 1 deletion lib/Resource/ClientsResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ public function iterate(): \Generator
yield $client;
}

$url = $list->next;
// `next` is string|null per the API, but `false`/"" show up too:
// a non-string is a TypeError and an empty string loops forever.
$next = $list->next;
$url = is_string($next) && '' !== $next ? $next : null;
}
}

Expand Down
14 changes: 13 additions & 1 deletion lib/Resource/PublicKeyResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,23 @@ public function __construct(private readonly ClientInterface $client)
{
}

/**
* Returns the PEM public key used to verify callback payload signatures.
*
* `GET /public_key/` answers with a JSON-encoded PEM string, so the decoded
* body is the key itself. An object body (`{"public_key": "..."}`) is still
* accepted for compatibility with older gateway responses.
*/
public function get(): string
{
$response = $this->client->request('GET', 'public_key/');

if (is_string($response)) {
return trim($response);
}

$arr = (array) $response;

return $arr['public_key'] ?? '';
return (string) ($arr['public_key'] ?? '');
}
}
5 changes: 4 additions & 1 deletion lib/Resource/StatementsResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ public function iterate(): \Generator
yield $statement;
}

$url = $list->next;
// `next` is string|null per the API, but `false`/"" show up too:
// a non-string is a TypeError and an empty string loops forever.
$next = $list->next;
$url = is_string($next) && '' !== $next ? $next : null;
}
}

Expand Down
5 changes: 4 additions & 1 deletion lib/Resource/WebhooksResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ public function iterate(): \Generator
yield $webhook;
}

$url = $list->next;
// `next` is string|null per the API, but `false`/"" show up too:
// a non-string is a TypeError and an empty string loops forever.
$next = $list->next;
$url = is_string($next) && '' !== $next ? $next : null;
}
}

Expand Down
Loading
Loading