Compare 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
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
9 changed files with 202 additions and 39 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.
+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)
[![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,
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
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
public function boot(): void
@@ -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
+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)
[![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,
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
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
public function boot(): void
@@ -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
+5 -5
View File
@@ -15,15 +15,15 @@
"require": {
"php": "^8.2",
"ext-json": "*",
"illuminate/support": "^11.0|^12.0|^13.0",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/support": "^12.69|^13.0",
"illuminate/console": "^12.69|^13.0",
"guzzlehttp/guzzle": "^7.8",
"socialiteproviders/manager": "^4.6",
"firebase/php-jwt": "^6.10|^7.0"
"firebase/php-jwt": "^7.0"
},
"require-dev": {
"laravel/framework": "^11.0|^12.0|^13.0",
"orchestra/testbench": "^9.0|^10.0|^11.0",
"laravel/framework": "^12.69|^13.0",
"orchestra/testbench": "^10.0|^11.0",
"phpunit/phpunit": "^11.0|^12.0"
},
"autoload": {
+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;
+5 -5
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
{
}
+7 -1
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)
{
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);
}
}
+10 -3
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)
{
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();
}
}