Compare commits

...
Author SHA1 Message Date
Maurice PreußandClaude Opus 5.5 57ce0b4a79 Throw ApiException for requests with the user's token too
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 <noreply@anthropic.com>
2026-11-04 18:28:50 +01:00
envoyr a4345e72ec Merge pull request #14 from anikeen-com/docs/remove-live-test-hint
Document payments end to end, add subscription status helpers
2026-11-04 17:56:25 +01:00
8 changed files with 196 additions and 33 deletions

No files matched your search

+5 -5
View File
@@ -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.
+22 -8
View File
@@ -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
+22 -8
View File
@@ -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
+25
View File
@@ -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;
+6 -6
View File
@@ -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
{
}
}
+8 -2
View File
@@ -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);
}
}
+11 -4
View File
@@ -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) {
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\CollectionException;
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Anikeen\Id\Exceptions\ResourceException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request;
use Illuminate\Database\Eloquent\Model;
class UserApiExceptionsTest extends TestCase
{
private function user(): Model
{
$user = new class extends Model {
use Billable;
};
$user->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();
}
}