Compare commits

...
4 Commits
Author SHA1 Message Date
envoyr 0ed6934da3 Merge pull request #16 from anikeen-com/fix/dependabot-alerts
Require patched Laravel and php-jwt versions
2026-11-04 22:09:39 +01:00
Maurice Preuß df7e506a5e Require patched Laravel and php-jwt versions
Raise the minimum versions so installs can no longer resolve releases with
known advisories (Dependabot alerts):

- laravel/framework / illuminate/*: ^12.69|^13.0 (TokenGuard auth bypass,
  CRLF injection in the email rule, signed URL path confusion, debug page XSS).
  Laravel 11 is end of life and gets no fixes, so support for it is dropped.
- firebase/php-jwt: ^7.0 (weak encryption in 6.x).
- orchestra/testbench: ^10.0|^11.0 to match.

Tests pass with both the lowest and the highest allowed versions.
2026-11-04 22:05:30 +01:00
envoyr 61385df92f Merge pull request #15 from anikeen-com/feature/user-api-exceptions
Throw ApiException for requests with the user's token too
2026-11-04 18:44:38 +01:00
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
9 changed files with 204 additions and 41 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. 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; - `src/Id/Concerns/` – one trait per API area. Traits on `AnikeenId` act as the app/client;
traits on `Billable` act for one user. traits on `Billable` act for one user.
- `src/Id/Resources/` – `BaseResource` (single object, throws `ResourceException`, or - `src/Id/Resources/` – `BaseResource` (single object) and `BaseCollection` (lists, built via
`ConfirmationRequiredException` for `confirmation_required`) and `BaseCollection` (lists, `::builder()`). Failed requests throw `ApiException::fromResult()` (`CollectionException` for
lazily built via `::builder()`). 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 - `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. (`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 They must not send any request until a method is called.
until a method is called.
- `src/Id/Auth/`, `Providers/AnikeenIdServiceProvider.php`, `Http/Middleware/` – the `anikeen` - `src/Id/Auth/`, `Providers/AnikeenIdServiceProvider.php`, `Http/Middleware/` – the `anikeen`
guard driver and user provider, JWT validation (`Helpers/JwtParser.php` with the bundled guard driver and user provider, JWT validation (`Helpers/JwtParser.php` with the bundled
`oauth-public*.key`), scope middleware. `oauth-public*.key`), scope middleware.
+24 -10
View File
@@ -4,7 +4,7 @@
[![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
[![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
PHP Anikeen ID API Client for Laravel 11+ PHP Anikeen ID API Client for Laravel 12+
Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders, Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders,
subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID. subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID.
@@ -71,7 +71,7 @@ Add to `config/services.php` file:
### Event Listener ### Event Listener
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method: Since Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
```php ```php
public function boot(): void public function boot(): void
@@ -417,23 +417,37 @@ object through the API when the current state matters. The admin panel can send
### Errors ### Errors
Calls with the user's token throw `Anikeen\Id\Exceptions\ResourceException` when Anikeen ID Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
rejects them; `getCode()` is the HTTP status (e.g. `422` for invalid data, `404` for an unknown well as with the app's (`app*()`). It carries:
ID) and `getMessage()` the reason. App calls (`app*()`) throw its subclass `ApiException`, which
adds a stable `error` code. Catching `ResourceException` covers both: - `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 ```php
use Anikeen\Id\Exceptions\ResourceException; use Anikeen\Id\Exceptions\ApiException;
try { try {
$order = $user->orders()->create($attributes); $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); 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 ### Testing
Develop against staging (`ANIKEEN_ID_MODE=staging`) with an OAuth client registered there. 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 Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw
`ResourceException` instead (see [Errors](#errors)). `ApiException` instead (see [Errors](#errors)).
## Examples ## Examples
+24 -10
View File
@@ -4,7 +4,7 @@
[![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
[![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id)
PHP Anikeen ID API Client for Laravel 11+ PHP Anikeen ID API Client for Laravel 12+
Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders, Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders,
subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID. subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID.
@@ -71,7 +71,7 @@ Add to `config/services.php` file:
### Event Listener ### Event Listener
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method: Since Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
```php ```php
public function boot(): void public function boot(): void
@@ -417,23 +417,37 @@ object through the API when the current state matters. The admin panel can send
### Errors ### Errors
Calls with the user's token throw `Anikeen\Id\Exceptions\ResourceException` when Anikeen ID Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
rejects them; `getCode()` is the HTTP status (e.g. `422` for invalid data, `404` for an unknown well as with the app's (`app*()`). It carries:
ID) and `getMessage()` the reason. App calls (`app*()`) throw its subclass `ApiException`, which
adds a stable `error` code. Catching `ResourceException` covers both: - `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 ```php
use Anikeen\Id\Exceptions\ResourceException; use Anikeen\Id\Exceptions\ApiException;
try { try {
$order = $user->orders()->create($attributes); $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); 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 ### Testing
Develop against staging (`ANIKEEN_ID_MODE=staging`) with an OAuth client registered there. 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 Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw
`ResourceException` instead (see [Errors](#errors)). `ApiException` instead (see [Errors](#errors)).
## Examples ## Examples
+5 -5
View File
@@ -15,15 +15,15 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"ext-json": "*", "ext-json": "*",
"illuminate/support": "^11.0|^12.0|^13.0", "illuminate/support": "^12.69|^13.0",
"illuminate/console": "^11.0|^12.0|^13.0", "illuminate/console": "^12.69|^13.0",
"guzzlehttp/guzzle": "^7.8", "guzzlehttp/guzzle": "^7.8",
"socialiteproviders/manager": "^4.6", "socialiteproviders/manager": "^4.6",
"firebase/php-jwt": "^6.10|^7.0" "firebase/php-jwt": "^7.0"
}, },
"require-dev": { "require-dev": {
"laravel/framework": "^11.0|^12.0|^13.0", "laravel/framework": "^12.69|^13.0",
"orchestra/testbench": "^9.0|^10.0|^11.0", "orchestra/testbench": "^10.0|^11.0",
"phpunit/phpunit": "^11.0|^12.0" "phpunit/phpunit": "^11.0|^12.0"
}, },
"autoload": { "autoload": {
+25
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id\Exceptions; namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result;
use Throwable; use Throwable;
/** /**
@@ -19,6 +20,30 @@ class ApiException extends ResourceException
parent::__construct($message, $status, $previous); 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 public function isNotFound(): bool
{ {
return $this->status === 404; return $this->status === 404;
+5 -5
View File
@@ -2,10 +2,10 @@
namespace Anikeen\Id\Exceptions; namespace Anikeen\Id\Exceptions;
use Anikeen\Id\Result; /**
use Exception; * A list request failed. Like ApiException it carries the HTTP status (also as getCode()) and
* Anikeen ID's error code.
class CollectionException extends Exception */
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\Helpers\Paginator;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Closure; use Closure;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Response;
use JsonSerializable; use JsonSerializable;
use Throwable; use Throwable;
@@ -34,10 +35,15 @@ abstract class BaseCollection implements JsonSerializable
*/ */
protected function __construct(callable $callable) 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()) { 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; namespace Anikeen\Id\Resources;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ConfirmationRequiredException; use Anikeen\Id\Exceptions\ConfirmationRequiredException;
use Anikeen\Id\Exceptions\ResourceException;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use JsonSerializable; use JsonSerializable;
#[\AllowDynamicProperties] #[\AllowDynamicProperties]
@@ -13,11 +14,17 @@ abstract class BaseResource implements JsonSerializable
public Result $result; 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) 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->success()) {
if (($this->result->data->error ?? null) === 'confirmation_required') { 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) { 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();
}
}