From 57ce0b4a798eee7dd18688d435edca9da98bc86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Wed, 4 Nov 2026 18:28:50 +0100 Subject: [PATCH] Throw ApiException for requests with the user's token too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requests with the user's token threw a bare ResourceException with a message like "… for resource [Anikeen\Id\Resources\Order]" and no error code, while app requests threw ApiException. Both throw ApiException now: status (also getCode()), Anikeen ID's error code and message, isNotFound(), isConflict(), isTemporary(). Failed lists throw CollectionException, now a subclass of ApiException. A request without a response (connection refused, timeout) throws ApiException with status 0 instead of a Guzzle exception. ApiException extends ResourceException, so existing catch blocks keep working. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 10 +-- README.md | 30 +++++-- README.stub | 30 +++++-- src/Id/Exceptions/ApiException.php | 25 ++++++ src/Id/Exceptions/CollectionException.php | 12 +-- src/Id/Resources/BaseCollection.php | 10 ++- src/Id/Resources/BaseResource.php | 15 +++- tests/Id/UserApiExceptionsTest.php | 97 +++++++++++++++++++++++ 8 files changed, 196 insertions(+), 33 deletions(-) create mode 100644 tests/Id/UserApiExceptionsTest.php diff --git a/CLAUDE.md b/CLAUDE.md index 4ad4d18..b644945 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,13 +30,13 @@ CI (`.github/workflows/tests.yml`) runs PHPUnit on PHP 8.3 and 8.4. attributes and break `save()`). Add a new cache property there when a concern caches. - `src/Id/Concerns/` – one trait per API area. Traits on `AnikeenId` act as the app/client; traits on `Billable` act for one user. -- `src/Id/Resources/` – `BaseResource` (single object, throws `ResourceException`, or - `ConfirmationRequiredException` for `confirmation_required`) and `BaseCollection` (lists, - lazily built via `::builder()`). +- `src/Id/Resources/` – `BaseResource` (single object) and `BaseCollection` (lists, built via + `::builder()`). Failed requests throw `ApiException::fromResult()` (`CollectionException` for + lists), no response `::unreachable()`, `confirmation_required` a `ConfirmationRequiredException`. + All extend `ResourceException`. - `src/Id/Resources/App*` – "app" variants that use the app's own client-credentials token (`AnikeenId::asApp()`, `Support/AppRequest`) instead of the user's token, for trusted apps. - They throw `Exceptions/ApiException` with a stable `error` code and must not send any request - until a method is called. + They must not send any request until a method is called. - `src/Id/Auth/`, `Providers/AnikeenIdServiceProvider.php`, `Http/Middleware/` – the `anikeen` guard driver and user provider, JWT validation (`Helpers/JwtParser.php` with the bundled `oauth-public*.key`), scope middleware. diff --git a/README.md b/README.md index a5157c1..5dfa60d 100644 --- a/README.md +++ b/README.md @@ -417,23 +417,37 @@ object through the API when the current state matters. The admin panel can send ### Errors -Calls with the user's token throw `Anikeen\Id\Exceptions\ResourceException` when Anikeen ID -rejects them; `getCode()` is the HTTP status (e.g. `422` for invalid data, `404` for an unknown -ID) and `getMessage()` the reason. App calls (`app*()`) throw its subclass `ApiException`, which -adds a stable `error` code. Catching `ResourceException` covers both: +Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as +well as with the app's (`app*()`). It carries: + +- `status`: the HTTP status (also `getCode()`), `0` when Anikeen ID could not be reached, +- `error`: a stable error code when Anikeen ID sent one (e.g. `billing_address_missing`, + `already_cancelled`), otherwise `null`, +- `getMessage()`: Anikeen ID's reason, +- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry + later). ```php -use Anikeen\Id\Exceptions\ResourceException; +use Anikeen\Id\Exceptions\ApiException; try { $order = $user->orders()->create($attributes); -} catch (ResourceException $e) { +} catch (ApiException $e) { + if ($e->isTemporary()) { + return back()->with('error', 'Anikeen ID is not reachable right now, please try again.'); + } + report($e); - return back()->with('error', 'The order could not be created.'); + return back()->with('error', $e->getMessage()); } ``` +Failed lists (`$user->invoices()`, …) throw `CollectionException`, a subclass of `ApiException`. +`ConfirmationRequiredException` (a third-party app called `checkout()`, see above) stays its own +type. All of them extend `ResourceException`, so existing `catch (ResourceException $e)` blocks +keep working. + ### Testing Develop against staging (`ANIKEEN_ID_MODE=staging`) with an OAuth client registered there. @@ -700,7 +714,7 @@ $user = $result->shift(); // the object, or the first item of a list ``` Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw -`ResourceException` instead (see [Errors](#errors)). +`ApiException` instead (see [Errors](#errors)). ## Examples diff --git a/README.stub b/README.stub index b135453..c87d48b 100644 --- a/README.stub +++ b/README.stub @@ -417,23 +417,37 @@ object through the API when the current state matters. The admin panel can send ### Errors -Calls with the user's token throw `Anikeen\Id\Exceptions\ResourceException` when Anikeen ID -rejects them; `getCode()` is the HTTP status (e.g. `422` for invalid data, `404` for an unknown -ID) and `getMessage()` the reason. App calls (`app*()`) throw its subclass `ApiException`, which -adds a stable `error` code. Catching `ResourceException` covers both: +Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as +well as with the app's (`app*()`). It carries: + +- `status`: the HTTP status (also `getCode()`), `0` when Anikeen ID could not be reached, +- `error`: a stable error code when Anikeen ID sent one (e.g. `billing_address_missing`, + `already_cancelled`), otherwise `null`, +- `getMessage()`: Anikeen ID's reason, +- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry + later). ```php -use Anikeen\Id\Exceptions\ResourceException; +use Anikeen\Id\Exceptions\ApiException; try { $order = $user->orders()->create($attributes); -} catch (ResourceException $e) { +} catch (ApiException $e) { + if ($e->isTemporary()) { + return back()->with('error', 'Anikeen ID is not reachable right now, please try again.'); + } + report($e); - return back()->with('error', 'The order could not be created.'); + return back()->with('error', $e->getMessage()); } ``` +Failed lists (`$user->invoices()`, …) throw `CollectionException`, a subclass of `ApiException`. +`ConfirmationRequiredException` (a third-party app called `checkout()`, see above) stays its own +type. All of them extend `ResourceException`, so existing `catch (ResourceException $e)` blocks +keep working. + ### Testing Develop against staging (`ANIKEEN_ID_MODE=staging`) with an OAuth client registered there. @@ -700,7 +714,7 @@ $user = $result->shift(); // the object, or the first item of a list ``` Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw -`ResourceException` instead (see [Errors](#errors)). +`ApiException` instead (see [Errors](#errors)). ## Examples diff --git a/src/Id/Exceptions/ApiException.php b/src/Id/Exceptions/ApiException.php index 35a236d..5654568 100644 --- a/src/Id/Exceptions/ApiException.php +++ b/src/Id/Exceptions/ApiException.php @@ -2,6 +2,7 @@ namespace Anikeen\Id\Exceptions; +use Anikeen\Id\Result; use Throwable; /** @@ -19,6 +20,30 @@ class ApiException extends ResourceException parent::__construct($message, $status, $previous); } + /** + * The exception for a failed request: Anikeen ID's message and error code, the HTTP status + * (0 when there was no response). + */ + public static function fromResult(Result $result): static + { + $body = is_object($result->data) ? $result->data : null; + + return new static( + rtrim((string) ($body->message ?? $result->error()), '.'), + $result->response()?->getStatusCode() ?? 0, + $body->error ?? null, + $result->exception, + ); + } + + /** + * The exception for a request that got no response (network error, timeout). + */ + public static function unreachable(Throwable $previous): static + { + return new static(sprintf('Anikeen ID is not reachable: %s', $previous->getMessage()), 0, null, $previous); + } + public function isNotFound(): bool { return $this->status === 404; diff --git a/src/Id/Exceptions/CollectionException.php b/src/Id/Exceptions/CollectionException.php index 637070c..1058c6d 100644 --- a/src/Id/Exceptions/CollectionException.php +++ b/src/Id/Exceptions/CollectionException.php @@ -2,10 +2,10 @@ namespace Anikeen\Id\Exceptions; -use Anikeen\Id\Result; -use Exception; - -class CollectionException extends Exception +/** + * A list request failed. Like ApiException it carries the HTTP status (also as getCode()) and + * Anikeen ID's error code. + */ +class CollectionException extends ApiException { - -} \ No newline at end of file +} diff --git a/src/Id/Resources/BaseCollection.php b/src/Id/Resources/BaseCollection.php index 9d50f83..24cbfc3 100644 --- a/src/Id/Resources/BaseCollection.php +++ b/src/Id/Resources/BaseCollection.php @@ -7,6 +7,7 @@ use Anikeen\Id\Exceptions\CollectionException; use Anikeen\Id\Helpers\Paginator; use Anikeen\Id\Result; use Closure; +use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Psr7\Response; use JsonSerializable; use Throwable; @@ -34,10 +35,15 @@ abstract class BaseCollection implements JsonSerializable */ protected function __construct(callable $callable) { - $this->result = $callable(); + try { + $this->result = $callable(); + } catch (GuzzleException $e) { + // no response at all (connection refused, timeout) + throw CollectionException::unreachable($e); + } if (!$this->result->success()) { - throw new CollectionException(sprintf('%s for collection [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode()); + throw CollectionException::fromResult($this->result); } } diff --git a/src/Id/Resources/BaseResource.php b/src/Id/Resources/BaseResource.php index 0ecafc2..d5316e8 100644 --- a/src/Id/Resources/BaseResource.php +++ b/src/Id/Resources/BaseResource.php @@ -2,9 +2,10 @@ namespace Anikeen\Id\Resources; +use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ConfirmationRequiredException; -use Anikeen\Id\Exceptions\ResourceException; use Anikeen\Id\Result; +use GuzzleHttp\Exception\GuzzleException; use JsonSerializable; #[\AllowDynamicProperties] @@ -13,11 +14,17 @@ abstract class BaseResource implements JsonSerializable public Result $result; /** - * @throws ResourceException + * @throws ApiException the request failed (status, error code; a ResourceException) + * @throws ConfirmationRequiredException the user has to confirm it on Anikeen ID */ public function __construct(callable $callable) { - $this->result = $callable(); + try { + $this->result = $callable(); + } catch (GuzzleException $e) { + // no response at all (connection refused, timeout) + throw ApiException::unreachable($e); + } if (!$this->result->success()) { if (($this->result->data->error ?? null) === 'confirmation_required') { @@ -28,7 +35,7 @@ abstract class BaseResource implements JsonSerializable ); } - throw new ResourceException(sprintf('%s for resource [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode()); + throw ApiException::fromResult($this->result); } foreach ($this->result->data as $key => $value) { diff --git a/tests/Id/UserApiExceptionsTest.php b/tests/Id/UserApiExceptionsTest.php new file mode 100644 index 0000000..872c65f --- /dev/null +++ b/tests/Id/UserApiExceptionsTest.php @@ -0,0 +1,97 @@ +anikeen_id_access_token = 'user-token'; + + return $user; + } + + public function test_a_refused_request_carries_status_and_error_code(): void + { + $this->respond(200, ['data' => []]); // orders() loads the list + $this->respond(422, ['message' => 'The billing address is missing.', 'error' => 'billing_address_missing']); + + try { + $this->user()->orders()->create(['items' => []]); + $this->fail('Expected an ApiException.'); + } catch (ApiException $e) { + $this->assertInstanceOf(ResourceException::class, $e, 'existing catch (ResourceException) blocks keep working'); + $this->assertSame(422, $e->status); + $this->assertSame(422, $e->getCode()); + $this->assertSame('billing_address_missing', $e->error); + $this->assertSame('The billing address is missing', $e->getMessage()); + $this->assertFalse($e->isTemporary()); + } + } + + public function test_an_unknown_id_is_not_found(): void + { + $this->respond(200, ['data' => []]); + $this->respond(404, ['message' => 'Not found.']); + + try { + $this->user()->orders()->find('01JMISSING'); + $this->fail('Expected an ApiException.'); + } catch (ApiException $e) { + $this->assertTrue($e->isNotFound()); + $this->assertNull($e->error); + } + } + + public function test_a_failed_list_is_an_api_exception_too(): void + { + $this->respond(503, ['message' => 'Maintenance.']); + + try { + $this->user()->invoices(); + $this->fail('Expected a CollectionException.'); + } catch (CollectionException $e) { + $this->assertInstanceOf(ApiException::class, $e); + $this->assertSame(503, $e->status); + $this->assertTrue($e->isTemporary()); + } + } + + public function test_no_response_is_temporary_with_status_zero(): void + { + $this->respond(200, ['data' => []]); + $this->http->append(new ConnectException('Connection refused', new Request('GET', 'v1/orders/01J'))); + + try { + $this->user()->orders()->find('01J'); + $this->fail('Expected an ApiException.'); + } catch (ApiException $e) { + $this->assertSame(0, $e->status); + $this->assertTrue($e->isTemporary()); + $this->assertInstanceOf(ConnectException::class, $e->getPrevious()); + } + } + + public function test_confirmation_required_stays_its_own_exception(): void + { + $this->respond(200, ['data' => []]); + $this->respond(200, ['data' => ['id' => '01JORDER', 'status' => 'draft']]); + $this->respond(403, ['message' => 'Confirm it on Anikeen ID.', 'error' => 'confirmation_required', 'confirmation_url' => 'https://id.test/confirm/01JORDER']); + + $this->expectException(ConfirmationRequiredException::class); + + $this->user()->orders()->find('01JORDER')->checkout(); + } +}