Compare commits

...
24 changed files with 1324 additions and 28 deletions

No files matched your search

+21
View File
@@ -1,5 +1,26 @@
# Changelog # Changelog
## Unreleased
Preferences; they need an Anikeen ID release that supports them.
### Added
- Preferences of any user with the app's token (trusted apps, registered by Anikeen per
namespace): `preferencesByUserId()`, `preferenceByUserId()`, `setPreferenceByUserId()`,
`deletePreferenceByUserId()` and `findUserIdByPreference()` (the user holding a value of a unique
key, `null` when nobody does).
- The user's shared preferences with their token: `globalPreferences()`, `globalPreference()`,
`setGlobalPreference()` and `deleteGlobalPreference()` on `Billable`, cached per instance.
- `Resources\Preference` and `Resources\Preferences`; `Enums\SharedPreference` and
`Enums\Appearance`.
- Conditional writes: `ifVersion` (`If-Match`) and `createOnly` (`If-None-Match: *`);
`PreconditionFailedException` (a subclass of `ApiException`) for 412 with `currentVersion()`.
- Scopes `Scope::PREFERENCES`, `PREFERENCES_READ` and `PREFERENCES_CLIENT`.
- Webhook event `order.refunded` (`EventType::ORDER_REFUNDED`).
- An optional trailing `$headers` argument on `AnikeenId::request()`, `get()`, `post()`, `put()`,
`delete()` and `AppRequest::send()`; calls without it send the same request as before.
## 3.3.0 (2026-11-06) ## 3.3.0 (2026-11-06)
App billing features; they need an Anikeen ID release that supports them. App billing features; they need an Anikeen ID release that supports them.
+188 -5
View File
@@ -16,11 +16,12 @@ subscriptions and physical goods, with invoices, VAT and payment handled by Anik
3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id) 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Payments](#payments) 4. [Payments](#payments)
5. [Trusted apps](#trusted-apps) 5. [Trusted apps](#trusted-apps)
6. [API authentication](#api-authentication) 6. [Preferences](#preferences)
7. [General](#general) 7. [API authentication](#api-authentication)
8. [Examples](#examples) 8. [General](#general)
9. [Documentation](#documentation) 9. [Examples](#examples)
10. [Development](#development) 10. [Documentation](#documentation)
11. [Development](#development)
## Installation ## Installation
@@ -181,6 +182,14 @@ A full reseller scope includes reading. `user` and `*` do not open the reseller
token has to carry the reseller scope itself. For the product APIs there is only `products:read`; token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`. changing products needs `user`.
Anikeen ID's [preferences](#preferences) use these scopes:
| Scope | Constant | Grants |
|-------|----------|--------|
| `preferences:read` | `Scope::PREFERENCES_READ` | read the user's shared preferences (`global`) |
| `preferences` | `Scope::PREFERENCES` | read and change them |
| `preferences:client` | `Scope::PREFERENCES_CLIENT` | app tokens: the preferences of any user, in the namespaces Anikeen registered for the app |
### Change the default access token / refresh token field name ### Change the default access token / refresh token field name
If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method: If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
@@ -549,6 +558,11 @@ if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) {
`order.*` events list the order's `items` and `invoice` like `order.*` events list the order's `items` and `invoice` like
[app orders](#app-orders-trusted-apps-no-user-token). [app orders](#app-orders-trusted-apps-no-user-token).
`order.refunded` (`EventType::ORDER_REFUNDED`) is sent when money was given back for an order (a
refund or a partial cancellation, with a credit note); the order carries its `refunds` and
`refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the
order of the two events.
### Errors ### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -918,6 +932,156 @@ $result->data->invitation_sent; // only for an account created by this ca
other than an unknown address. other than an unknown address.
## Preferences
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they
follow the user across devices and apps:
- **`global`** is shared by all apps. Only registered keys and values are accepted: today
`SharedPreference::APPEARANCE` with `Appearance::SYSTEM`, `LIGHT` or `DARK`.
- **App namespaces** (e.g. `acme`) hold one app's own data: onboarding state, a default workspace,
the user's customer ID at a payment provider. Use another namespace on staging (e.g.
`acme-staging`), so test data never mixes with production.
**Registration.** Access never comes from scopes alone: any client can request any scope. Ask
Anikeen to register your OAuth client(s) (the app has to be [trusted](#trusted-apps)) for
- the namespaces your app owns (read, write and the lookup),
- read access to another app's namespace,
- `global` with the app's token (users always read and write their own `global` with their token),
- keys whose values are unique (see the lookup below).
Without it, app requests fail with 403 `client_not_trusted` or 404 `namespace_not_found`.
**Scopes.** User tokens need `Scope::PREFERENCES` (`preferences`, read and write) or
`Scope::PREFERENCES_READ` (`preferences:read`); app tokens `Scope::PREFERENCES_CLIENT`
(`preferences:client`).
**Limits.** A value is at most 2048 bytes of JSON and 4 levels deep; at most 50 keys per user and
namespace (updating a key always works); keys match `^[a-z][a-z0-9_]{0,63}$`. Per minute: 60
requests per user and app with user tokens, 600 per app with the app token and 120 lookups per
app (429 `rate_limited` with `Retry-After`).
**Not a secrets store.** Values are stored as plain JSON and shown to Anikeen's support and to the
user on a request for their data. Store IDs and settings, never passwords, API keys or tokens: keys
that look like credentials (`secret`, `token`, `password`, `api_key`, …) are rejected with 422
`forbidden_key`.
### The user's shared preferences (user token)
```php
use Anikeen\Id\Enums\Appearance;
use Anikeen\Id\Enums\SharedPreference;
$user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM); // the default when not set
$user->globalPreferences()->values(); // ['appearance' => 'dark']
$preference = $user->setGlobalPreference(SharedPreference::APPEARANCE, Appearance::DARK);
$preference->version; // e.g. 1042
$user->deleteGlobalPreference(SharedPreference::APPEARANCE);
```
User tokens reach `global` only; an app namespace answers 404 `namespace_not_found`, whatever the
token's scopes. The app's own token (client credentials) has no user and gets 401 on these routes.
### Preferences of any user (trusted apps)
With the app's own token, by the user's Anikeen ID:
```php
$anikeenId = app(AnikeenId::class);
$preferences = $anikeenId->preferencesByUserId($userId, 'acme'); // sorted by key, no pagination
$preferences->get('onboarding', []); // the value or a default; also has(), find(), values()
$preference = $anikeenId->preferenceByUserId($userId, 'acme', 'onboarding'); // 404 preference_not_found if not set
$preference->value; // JSON objects as stdClass, so {} stays {}
$preference->version;
$preference->updated_by; // {type: "user"|"app", client_id}
$anikeenId->setPreferenceByUserId($userId, 'acme', 'onboarding', ['step' => 3]);
$anikeenId->deletePreferenceByUserId($userId, 'acme', 'onboarding');
```
A value is any JSON value; pass `(object) []` for an empty object (`[]` is an empty list).
`setPreferenceByUserId()` returns the stored preference; `wasCreated()` tells whether the write
created the key. A deleted user's preferences are gone: every call answers 404 `user_not_found`.
### Conditional writes and retries
Writes are *last write wins* by default. Every write gives the preference a new `version`.
Versions are opaque: they only go up (also when a key is deleted and set again) and increase per
user, not by one per key. Pass the version you read to write only if nobody changed it in between
(`If-Match`), or `createOnly` to write only if the key isn't set yet (`If-None-Match: *`):
```php
use Anikeen\Id\Exceptions\PreconditionFailedException;
$preference = $anikeenId->preferenceByUserId($userId, 'acme', 'layout');
try {
$anikeenId->setPreferenceByUserId($userId, 'acme', 'layout', $merged, ifVersion: $preference->version);
} catch (PreconditionFailedException $e) {
$e->error; // "preference_version_mismatch" (or "preference_exists" for createOnly)
$e->currentVersion(); // the version stored now, null when the key isn't set: reload, merge, retry
}
$anikeenId->setPreferenceByUserId($userId, 'acme', 'welcome_sent_at', now()->toIso8601String(), createOnly: true);
```
`deletePreferenceByUserId()`, `setGlobalPreference()` and `deleteGlobalPreference()` take
`ifVersion` too. Writing the value that is already stored is a no-op that succeeds (no new version),
even when the precondition would fail, so retrying a write after a timeout is safe.
### Unique keys and the lookup
Anikeen can declare keys of your namespace unique, e.g. `customer_id`. Their values are strings of
1 to 255 characters without surrounding whitespace, held by at most one user: setting another
user's value fails with 409 `preference_value_taken`. Your app can then find the user by value, e.g.
in a payment provider's webhook:
```php
$userId = $anikeenId->findUserIdByPreference('acme', 'customer_id', 'cus_123'); // null if nobody holds it
```
The value is sent in the body, never in the URL. `findUserIdByPreference()` returns `null` only for
404 `preference_not_found` (also when the user was deleted); everything else throws, e.g. 422
`key_not_unique` for a key that isn't declared unique.
### Error codes
Failures throw `ApiException` (412 `PreconditionFailedException`) with these `error` codes:
| Status | `error` |
|--------|---------|
| 401 | `unauthenticated` (no or an invalid token; an app token on the user routes) |
| 403 | `missing_scope`, `app_token_required` (a user token on the app routes), `client_not_trusted`, `namespace_read_only`, `lookup_not_allowed` |
| 404 | `namespace_not_found` (unknown or not accessible), `user_not_found` (also deleted users), `preference_not_found`, `not_found` |
| 405 | `method_not_allowed` |
| 409 | `preference_value_taken` |
| 412 | `preference_version_mismatch`, `preference_exists` |
| 422 | `invalid_key`, `forbidden_key`, `unknown_shared_key`, `value_required`, `value_too_large`, `value_too_deep`, `invalid_shared_value`, `invalid_unique_value`, `key_not_unique`, `preference_limit_exceeded` |
| 429 | `rate_limited` |
A `null` value is rejected with 422 `value_required`: delete the key instead.
### Caching
Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference()` cache the list
on the model instance (one request per instance); `setGlobalPreference()` and
`deleteGlobalPreference()` clear it. For a copy that outlives the request, keep a read cache: fill
it at sign-in, write to Anikeen ID first and update the copy from the response, and let it expire
after about an hour, since other apps and the user can change shared preferences too:
```php
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
fn () => $user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM));
$user->setGlobalPreference(SharedPreference::APPEARANCE, $request->appearance);
Cache::put("appearance:{$user->id}", $request->appearance, now()->addHour());
```
## API authentication ## API authentication
Use this when your own API accepts access tokens issued by Anikeen ID. Use this when your own API accepts access tokens issued by Anikeen ID.
@@ -1084,6 +1248,16 @@ AnikeenId::withToken('abcdef123456')->getAuthedUser();
public function retrievingToken(string $grantType, array $attributes): Result public function retrievingToken(string $grantType, array $attributes): Result
``` ```
### ManagesPreferences
```php
public function preferencesByUserId(string $userId, string $namespace): Preferences
public function preferenceByUserId(string $userId, string $namespace, string $key): Preference
public function setPreferenceByUserId(string $userId, string $namespace, string $key, mixed $value, ?int $ifVersion = null, bool $createOnly = false): Preference
public function deletePreferenceByUserId(string $userId, string $namespace, string $key, ?int $ifVersion = null): void
public function findUserIdByPreference(string $namespace, string $key, string $value): ?string
```
### ManagesPricing ### ManagesPricing
```php ```php
@@ -1241,6 +1415,15 @@ public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable public function getBillable(): Billable
``` ```
### ManagesUserPreferences
```php
public function globalPreferences(): Preferences
public function globalPreference(string $key, mixed $default = null): mixed
public function setGlobalPreference(string $key, mixed $value, ?int $ifVersion = null): Preference
public function deleteGlobalPreference(string $key, ?int $ifVersion = null): void
```
### ManagesUserSshKeys ### ManagesUserSshKeys
```php ```php
+169 -5
View File
@@ -16,11 +16,12 @@ subscriptions and physical goods, with invoices, VAT and payment handled by Anik
3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id) 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Payments](#payments) 4. [Payments](#payments)
5. [Trusted apps](#trusted-apps) 5. [Trusted apps](#trusted-apps)
6. [API authentication](#api-authentication) 6. [Preferences](#preferences)
7. [General](#general) 7. [API authentication](#api-authentication)
8. [Examples](#examples) 8. [General](#general)
9. [Documentation](#documentation) 9. [Examples](#examples)
10. [Development](#development) 10. [Documentation](#documentation)
11. [Development](#development)
## Installation ## Installation
@@ -181,6 +182,14 @@ A full reseller scope includes reading. `user` and `*` do not open the reseller
token has to carry the reseller scope itself. For the product APIs there is only `products:read`; token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`. changing products needs `user`.
Anikeen ID's [preferences](#preferences) use these scopes:
| Scope | Constant | Grants |
|-------|----------|--------|
| `preferences:read` | `Scope::PREFERENCES_READ` | read the user's shared preferences (`global`) |
| `preferences` | `Scope::PREFERENCES` | read and change them |
| `preferences:client` | `Scope::PREFERENCES_CLIENT` | app tokens: the preferences of any user, in the namespaces Anikeen registered for the app |
### Change the default access token / refresh token field name ### Change the default access token / refresh token field name
If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method: If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
@@ -549,6 +558,11 @@ if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) {
`order.*` events list the order's `items` and `invoice` like `order.*` events list the order's `items` and `invoice` like
[app orders](#app-orders-trusted-apps-no-user-token). [app orders](#app-orders-trusted-apps-no-user-token).
`order.refunded` (`EventType::ORDER_REFUNDED`) is sent when money was given back for an order (a
refund or a partial cancellation, with a credit note); the order carries its `refunds` and
`refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the
order of the two events.
### Errors ### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -918,6 +932,156 @@ $result->data->invitation_sent; // only for an account created by this ca
other than an unknown address. other than an unknown address.
## Preferences
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they
follow the user across devices and apps:
- **`global`** is shared by all apps. Only registered keys and values are accepted: today
`SharedPreference::APPEARANCE` with `Appearance::SYSTEM`, `LIGHT` or `DARK`.
- **App namespaces** (e.g. `acme`) hold one app's own data: onboarding state, a default workspace,
the user's customer ID at a payment provider. Use another namespace on staging (e.g.
`acme-staging`), so test data never mixes with production.
**Registration.** Access never comes from scopes alone: any client can request any scope. Ask
Anikeen to register your OAuth client(s) (the app has to be [trusted](#trusted-apps)) for
- the namespaces your app owns (read, write and the lookup),
- read access to another app's namespace,
- `global` with the app's token (users always read and write their own `global` with their token),
- keys whose values are unique (see the lookup below).
Without it, app requests fail with 403 `client_not_trusted` or 404 `namespace_not_found`.
**Scopes.** User tokens need `Scope::PREFERENCES` (`preferences`, read and write) or
`Scope::PREFERENCES_READ` (`preferences:read`); app tokens `Scope::PREFERENCES_CLIENT`
(`preferences:client`).
**Limits.** A value is at most 2048 bytes of JSON and 4 levels deep; at most 50 keys per user and
namespace (updating a key always works); keys match `^[a-z][a-z0-9_]{0,63}$`. Per minute: 60
requests per user and app with user tokens, 600 per app with the app token and 120 lookups per
app (429 `rate_limited` with `Retry-After`).
**Not a secrets store.** Values are stored as plain JSON and shown to Anikeen's support and to the
user on a request for their data. Store IDs and settings, never passwords, API keys or tokens: keys
that look like credentials (`secret`, `token`, `password`, `api_key`, …) are rejected with 422
`forbidden_key`.
### The user's shared preferences (user token)
```php
use Anikeen\Id\Enums\Appearance;
use Anikeen\Id\Enums\SharedPreference;
$user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM); // the default when not set
$user->globalPreferences()->values(); // ['appearance' => 'dark']
$preference = $user->setGlobalPreference(SharedPreference::APPEARANCE, Appearance::DARK);
$preference->version; // e.g. 1042
$user->deleteGlobalPreference(SharedPreference::APPEARANCE);
```
User tokens reach `global` only; an app namespace answers 404 `namespace_not_found`, whatever the
token's scopes. The app's own token (client credentials) has no user and gets 401 on these routes.
### Preferences of any user (trusted apps)
With the app's own token, by the user's Anikeen ID:
```php
$anikeenId = app(AnikeenId::class);
$preferences = $anikeenId->preferencesByUserId($userId, 'acme'); // sorted by key, no pagination
$preferences->get('onboarding', []); // the value or a default; also has(), find(), values()
$preference = $anikeenId->preferenceByUserId($userId, 'acme', 'onboarding'); // 404 preference_not_found if not set
$preference->value; // JSON objects as stdClass, so {} stays {}
$preference->version;
$preference->updated_by; // {type: "user"|"app", client_id}
$anikeenId->setPreferenceByUserId($userId, 'acme', 'onboarding', ['step' => 3]);
$anikeenId->deletePreferenceByUserId($userId, 'acme', 'onboarding');
```
A value is any JSON value; pass `(object) []` for an empty object (`[]` is an empty list).
`setPreferenceByUserId()` returns the stored preference; `wasCreated()` tells whether the write
created the key. A deleted user's preferences are gone: every call answers 404 `user_not_found`.
### Conditional writes and retries
Writes are *last write wins* by default. Every write gives the preference a new `version`.
Versions are opaque: they only go up (also when a key is deleted and set again) and increase per
user, not by one per key. Pass the version you read to write only if nobody changed it in between
(`If-Match`), or `createOnly` to write only if the key isn't set yet (`If-None-Match: *`):
```php
use Anikeen\Id\Exceptions\PreconditionFailedException;
$preference = $anikeenId->preferenceByUserId($userId, 'acme', 'layout');
try {
$anikeenId->setPreferenceByUserId($userId, 'acme', 'layout', $merged, ifVersion: $preference->version);
} catch (PreconditionFailedException $e) {
$e->error; // "preference_version_mismatch" (or "preference_exists" for createOnly)
$e->currentVersion(); // the version stored now, null when the key isn't set: reload, merge, retry
}
$anikeenId->setPreferenceByUserId($userId, 'acme', 'welcome_sent_at', now()->toIso8601String(), createOnly: true);
```
`deletePreferenceByUserId()`, `setGlobalPreference()` and `deleteGlobalPreference()` take
`ifVersion` too. Writing the value that is already stored is a no-op that succeeds (no new version),
even when the precondition would fail, so retrying a write after a timeout is safe.
### Unique keys and the lookup
Anikeen can declare keys of your namespace unique, e.g. `customer_id`. Their values are strings of
1 to 255 characters without surrounding whitespace, held by at most one user: setting another
user's value fails with 409 `preference_value_taken`. Your app can then find the user by value, e.g.
in a payment provider's webhook:
```php
$userId = $anikeenId->findUserIdByPreference('acme', 'customer_id', 'cus_123'); // null if nobody holds it
```
The value is sent in the body, never in the URL. `findUserIdByPreference()` returns `null` only for
404 `preference_not_found` (also when the user was deleted); everything else throws, e.g. 422
`key_not_unique` for a key that isn't declared unique.
### Error codes
Failures throw `ApiException` (412 `PreconditionFailedException`) with these `error` codes:
| Status | `error` |
|--------|---------|
| 401 | `unauthenticated` (no or an invalid token; an app token on the user routes) |
| 403 | `missing_scope`, `app_token_required` (a user token on the app routes), `client_not_trusted`, `namespace_read_only`, `lookup_not_allowed` |
| 404 | `namespace_not_found` (unknown or not accessible), `user_not_found` (also deleted users), `preference_not_found`, `not_found` |
| 405 | `method_not_allowed` |
| 409 | `preference_value_taken` |
| 412 | `preference_version_mismatch`, `preference_exists` |
| 422 | `invalid_key`, `forbidden_key`, `unknown_shared_key`, `value_required`, `value_too_large`, `value_too_deep`, `invalid_shared_value`, `invalid_unique_value`, `key_not_unique`, `preference_limit_exceeded` |
| 429 | `rate_limited` |
A `null` value is rejected with 422 `value_required`: delete the key instead.
### Caching
Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference()` cache the list
on the model instance (one request per instance); `setGlobalPreference()` and
`deleteGlobalPreference()` clear it. For a copy that outlives the request, keep a read cache: fill
it at sign-in, write to Anikeen ID first and update the copy from the response, and let it expire
after about an hour, since other apps and the user can change shared preferences too:
```php
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
fn () => $user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM));
$user->setGlobalPreference(SharedPreference::APPEARANCE, $request->appearance);
Cache::put("appearance:{$user->id}", $request->appearance, now()->addHour());
```
## API authentication ## API authentication
Use this when your own API accepts access tokens issued by Anikeen ID. Use this when your own API accepts access tokens issued by Anikeen ID.
+14 -10
View File
@@ -2,6 +2,7 @@
namespace Anikeen\Id; namespace Anikeen\Id;
use Anikeen\Id\Concerns\ManagesPreferences;
use Anikeen\Id\Concerns\ManagesPricing; use Anikeen\Id\Concerns\ManagesPricing;
use Anikeen\Id\Concerns\ManagesProducts; use Anikeen\Id\Concerns\ManagesProducts;
use Anikeen\Id\Concerns\ManagesShippingLabels; use Anikeen\Id\Concerns\ManagesShippingLabels;
@@ -23,6 +24,7 @@ use Illuminate\Contracts\Auth\Authenticatable;
class AnikeenId class AnikeenId
{ {
use OauthTrait; use OauthTrait;
use ManagesPreferences;
use ManagesPricing; use ManagesPricing;
use ManagesProducts; use ManagesProducts;
use ManagesShippingLabels; use ManagesShippingLabels;
@@ -369,10 +371,12 @@ class AnikeenId
/** /**
* Build query & execute. * Build query & execute.
* *
* @param array<string, string> $headers Extra request headers, e.g. `If-Match` for conditional
* writes; they are sent in addition to (and may not replace) Client-ID, Accept and Authorization.
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null, bool $useClientSecret = false): Result public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null, bool $useClientSecret = false, array $headers = []): Result
{ {
if ($paginator !== null) { if ($paginator !== null) {
$parameters[$paginator->action] = $paginator->cursor(); $parameters[$paginator->action] = $paginator->cursor();
@@ -380,7 +384,7 @@ class AnikeenId
try { try {
$response = $this->client->request($method, $path, [ $response = $this->client->request($method, $path, [
'headers' => $this->buildHeaders((bool)$payload, $useClientSecret), 'headers' => $this->buildHeaders((bool)$payload, $useClientSecret) + $headers,
'query' => Query::build($parameters), 'query' => Query::build($parameters),
'json' => $payload ?: null, 'json' => $payload ?: null,
]); ]);
@@ -419,35 +423,35 @@ class AnikeenId
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function get(string $path, array $parameters = [], ?Paginator $paginator = null): Result public function get(string $path, array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result
{ {
return $this->request('GET', $path, null, $parameters, $paginator); return $this->request('GET', $path, null, $parameters, $paginator, false, $headers);
} }
/** /**
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result
{ {
return $this->request('POST', $path, $payload, $parameters, $paginator); return $this->request('POST', $path, $payload, $parameters, $paginator, false, $headers);
} }
/** /**
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result
{ {
return $this->request('PUT', $path, $payload, $parameters, $paginator); return $this->request('PUT', $path, $payload, $parameters, $paginator, false, $headers);
} }
/** /**
* @throws GuzzleException * @throws GuzzleException
* @throws RequestRequiresClientIdException * @throws RequestRequiresClientIdException
*/ */
public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result
{ {
return $this->request('DELETE', $path, $payload, $parameters, $paginator); return $this->request('DELETE', $path, $payload, $parameters, $paginator, false, $headers);
} }
} }
+4
View File
@@ -9,6 +9,7 @@ use Anikeen\Id\Concerns\ManagesInvoices;
use Anikeen\Id\Concerns\ManagesOrders; use Anikeen\Id\Concerns\ManagesOrders;
use Anikeen\Id\Concerns\ManagesPaymentMethods; use Anikeen\Id\Concerns\ManagesPaymentMethods;
use Anikeen\Id\Concerns\ManagesProfile; use Anikeen\Id\Concerns\ManagesProfile;
use Anikeen\Id\Concerns\ManagesUserPreferences;
use Anikeen\Id\Concerns\ManagesSubscriptions; use Anikeen\Id\Concerns\ManagesSubscriptions;
use Anikeen\Id\Concerns\ManagesTaxation; use Anikeen\Id\Concerns\ManagesTaxation;
use Anikeen\Id\Concerns\ManagesTransactions; use Anikeen\Id\Concerns\ManagesTransactions;
@@ -27,6 +28,7 @@ trait Billable
use ManagesSubscriptions; use ManagesSubscriptions;
use ManagesTaxation; use ManagesTaxation;
use ManagesTransactions; use ManagesTransactions;
use ManagesUserPreferences;
use ManagesUserSshKeys; use ManagesUserSshKeys;
/* /*
@@ -40,6 +42,8 @@ trait Billable
protected mixed $invoicesCache = null; protected mixed $invoicesCache = null;
protected mixed $ordersCache = null; protected mixed $ordersCache = null;
protected mixed $paymentMethodsCache = null; protected mixed $paymentMethodsCache = null;
/** @var array<string, \Anikeen\Id\Resources\Preferences> by namespace */
protected array $preferencesCache = [];
protected mixed $subscriptionsCache = null; protected mixed $subscriptionsCache = null;
protected mixed $transactionsCache = null; protected mixed $transactionsCache = null;
protected mixed $userDataCache = null; protected mixed $userDataCache = null;
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Resources\Preference;
use Anikeen\Id\Resources\Preferences;
use Anikeen\Id\Support\AppRequest;
use Anikeen\Id\Support\PreferenceRequest;
/**
* Preferences of any user, with the app's own token (client credentials, scope
* `preferences:client`). Anikeen has to register the app for each namespace it uses: its own
* namespaces (read, write, lookup), read access to another app's namespace, or access to `global`.
* Without it every call fails with 403 "client_not_trusted" or 404 "namespace_not_found".
*/
trait ManagesPreferences
{
/**
* All preferences of a user in a namespace, sorted by key.
*
* @throws ApiException 404 "namespace_not_found", "user_not_found" (also deleted users), …
*/
public function preferencesByUserId(string $userId, string $namespace): Preferences
{
return Preferences::builder(fn () => AppRequest::send('GET', $this->userPreferencePath($userId, $namespace)));
}
/**
* One preference of a user.
*
* @throws ApiException 404 "preference_not_found" when the key isn't set, "user_not_found", …
*/
public function preferenceByUserId(string $userId, string $namespace, string $key): Preference
{
return new Preference(fn () => AppRequest::send('GET', $this->userPreferencePath($userId, $namespace, $key)));
}
/**
* Set a preference of a user. The value is any JSON value (pass `(object) []` for `{}`), at
* most 2048 bytes and 4 levels deep. Writing the value already stored changes nothing and
* succeeds, also when a precondition would fail, so a retried write is safe.
*
* @param int|null $ifVersion only if the stored version is this one (`If-Match`)
* @param bool $createOnly only if the key isn't set yet (`If-None-Match: *`); not with $ifVersion
* @throws PreconditionFailedException 412 "preference_version_mismatch" / "preference_exists",
* with currentVersion()
* @throws ApiException 403 "namespace_read_only", 409 "preference_value_taken" (a unique key),
* 422 "invalid_key", "forbidden_key", "value_too_large", "preference_limit_exceeded", …
*/
public function setPreferenceByUserId(string $userId, string $namespace, string $key, mixed $value, ?int $ifVersion = null, bool $createOnly = false): Preference
{
$headers = PreferenceRequest::conditions($ifVersion, $createOnly);
return new Preference(fn () => AppRequest::send('PUT', $this->userPreferencePath($userId, $namespace, $key), ['value' => $value], [], $headers));
}
/**
* Delete a preference of a user.
*
* @param int|null $ifVersion only if the stored version is this one (`If-Match`)
* @throws PreconditionFailedException 412 "preference_version_mismatch", with currentVersion()
* @throws ApiException 404 "preference_not_found" when the key isn't set, …
*/
public function deletePreferenceByUserId(string $userId, string $namespace, string $key, ?int $ifVersion = null): void
{
AppRequest::send('DELETE', $this->userPreferencePath($userId, $namespace, $key), null, [], PreferenceRequest::conditions($ifVersion));
}
/**
* The ID of the user holding a value of a unique key in one of the app's own namespaces, e.g.
* the user of a payment provider's customer ID. The value is sent in the body, never in the URL.
*
* @return string|null null when no (not deleted) user holds the value
* @throws ApiException 422 "key_not_unique", "invalid_unique_value", 403 "lookup_not_allowed", …
*/
public function findUserIdByPreference(string $namespace, string $key, string $value): ?string
{
try {
$result = AppRequest::send('POST', PreferenceRequest::path('v1/client/preferences', $namespace, 'lookup'), [
'key' => $key,
'value' => $value,
]);
} catch (ApiException $e) {
// only Anikeen ID's own answer means "nobody holds it", not e.g. an unknown namespace
if ($e->isNotFound() && $e->error === 'preference_not_found') {
return null;
}
throw $e;
}
return $result->data->user_id;
}
private function userPreferencePath(string $userId, string $namespace, ?string $key = null): string
{
$segments = [$userId, 'preferences', $namespace];
if ($key !== null) {
$segments[] = $key;
}
return PreferenceRequest::path('v1/client/users', ...$segments);
}
}
@@ -0,0 +1,96 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Enums\SharedPreference;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Resources\Preference;
use Anikeen\Id\Resources\Preferences;
use Anikeen\Id\Support\PreferenceRequest;
use GuzzleHttp\Exception\GuzzleException;
/**
* The user's shared preferences (`global`, e.g. SharedPreference::APPEARANCE), with the user's
* token (scope `preferences` or `preferences:read`). User tokens reach `global` only; an app's
* own namespace needs the app token (AnikeenId::preferencesByUserId()).
*/
trait ManagesUserPreferences
{
/**
* The user's shared preferences, sorted by key. Cached on this instance; set and delete clear it.
*
* @throws ApiException
*/
public function globalPreferences(): Preferences
{
$namespace = SharedPreference::NAMESPACE;
if (!isset($this->preferencesCache[$namespace])) {
$anikeenId = $this->anikeenId();
$this->preferencesCache[$namespace] = Preferences::builder(
fn () => $anikeenId->get(PreferenceRequest::path('v1/preferences', $namespace))
);
}
return $this->preferencesCache[$namespace];
}
/**
* The value of a shared preference, or $default when the user hasn't set it.
*
* @throws ApiException
*/
public function globalPreference(string $key, mixed $default = null): mixed
{
return $this->globalPreferences()->get($key, $default);
}
/**
* Set a shared preference of the user, e.g. setGlobalPreference(SharedPreference::APPEARANCE,
* Appearance::DARK). Only registered keys and values are accepted (422 "unknown_shared_key",
* "invalid_shared_value").
*
* @param int|null $ifVersion only if the stored version is this one (`If-Match`)
* @throws PreconditionFailedException 412 "preference_version_mismatch", with currentVersion()
* @throws ApiException
*/
public function setGlobalPreference(string $key, mixed $value, ?int $ifVersion = null): Preference
{
$namespace = SharedPreference::NAMESPACE;
$headers = PreferenceRequest::conditions($ifVersion);
$anikeenId = $this->anikeenId();
try {
return new Preference(fn () => $anikeenId->put(PreferenceRequest::path('v1/preferences', $namespace, $key), ['value' => $value], [], null, $headers));
} finally {
unset($this->preferencesCache[$namespace]);
}
}
/**
* Delete a shared preference of the user.
*
* @param int|null $ifVersion only if the stored version is this one (`If-Match`)
* @throws PreconditionFailedException 412 "preference_version_mismatch", with currentVersion()
* @throws ApiException 404 "preference_not_found" when the key isn't set, …
*/
public function deleteGlobalPreference(string $key, ?int $ifVersion = null): void
{
$namespace = SharedPreference::NAMESPACE;
$headers = PreferenceRequest::conditions($ifVersion);
try {
$result = $this->anikeenId()->delete(PreferenceRequest::path('v1/preferences', $namespace, $key), [], [], null, $headers);
} catch (GuzzleException $e) {
throw ApiException::unreachable($e);
} finally {
unset($this->preferencesCache[$namespace]);
}
if (!$result->success()) {
throw ApiException::fromResult($result);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Anikeen\Id\Enums;
/**
* The values of the shared preference SharedPreference::APPEARANCE.
*/
class Appearance
{
/** Follow the operating system. */
const SYSTEM = 'system';
const LIGHT = 'light';
const DARK = 'dark';
/**
* @return list<string>
*/
public static function all(): array
{
return [self::SYSTEM, self::LIGHT, self::DARK];
}
}
+6
View File
@@ -33,6 +33,12 @@ class Scope
const TRANSACTIONS_READ = 'transactions:read'; const TRANSACTIONS_READ = 'transactions:read';
const TRANSACTIONS_CLIENT = 'transactions:client'; const TRANSACTIONS_CLIENT = 'transactions:client';
// The user's own shared preferences (`global`). PREFERENCES_CLIENT labels an app token for
// the preferences of any user; access to a namespace is granted by Anikeen, not by the scope.
const PREFERENCES = 'preferences';
const PREFERENCES_READ = 'preferences:read';
const PREFERENCES_CLIENT = 'preferences:client';
const SSH_KEYS = 'ssh-keys'; const SSH_KEYS = 'ssh-keys';
const SSH_KEYS_READ = 'ssh-keys:read'; const SSH_KEYS_READ = 'ssh-keys:read';
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Anikeen\Id\Enums;
/**
* The keys of the `global` preferences namespace, shared by all apps. Anikeen ID accepts only
* these keys there, each with its own values.
*/
class SharedPreference
{
/** The namespace of the shared preferences. */
const NAMESPACE = 'global';
/** Light or dark mode: one of the Appearance constants. */
const APPEARANCE = 'appearance';
}
+7 -1
View File
@@ -28,10 +28,16 @@ class ApiException extends ResourceException
public static function fromResult(Result $result): static public static function fromResult(Result $result): static
{ {
$body = is_object($result->data) ? $result->data : null; $body = is_object($result->data) ? $result->data : null;
$status = $result->response()?->getStatusCode() ?? 0;
// a failed conditional write; subclasses (CollectionException) keep their own type
if ($status === 412 && static::class === self::class) {
return PreconditionFailedException::fromResult($result);
}
return new static( return new static(
rtrim((string) ($body->message ?? $result->error()), '.'), rtrim((string) ($body->message ?? $result->error()), '.'),
$result->response()?->getStatusCode() ?? 0, $status,
$body->error ?? null, $body->error ?? null,
$result->exception, $result->exception,
); );
@@ -0,0 +1,21 @@
<?php
namespace Anikeen\Id\Exceptions;
/**
* A conditional write failed (412): the stored version wasn't the one sent with `If-Match`
* ("preference_version_mismatch"), or the key was already set and the write was create-only
* ("preference_exists"). Reload, merge and retry with currentVersion().
*/
class PreconditionFailedException extends ApiException
{
/**
* The version stored now, null when the key isn't set (or Anikeen ID didn't send one).
*/
public function currentVersion(): ?int
{
$version = $this->body()->current_version ?? null;
return is_numeric($version) ? (int) $version : null;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Resources;
/**
* A preference of a user: a JSON value under a namespace and a key. `version` is opaque: it only
* goes up (per user, not consecutive per key) and is what ifVersion (`If-Match`) compares.
* JSON objects come back as stdClass, so `{}` stays an object.
*
* @property string $namespace
* @property string $key
* @property mixed $value
* @property int $version
* @property string $updated_at
* @property object{type: string, client_id: ?string} $updated_by `type` "user" (written with a user token) or "app"
*/
class Preference extends BaseResource
{
/**
* The write that returned this preference created the key (201). False for reads, updates and
* writes of the value already stored.
*/
public function wasCreated(): bool
{
return $this->result->response()?->getStatusCode() === 201;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace Anikeen\Id\Resources;
/**
* The preferences of a user in one namespace, sorted by key (at most 50, no pagination).
*/
class Preferences extends BaseCollection
{
/**
* The preference of a key; null when it isn't set. Reads the list, no request.
*/
public function find(string $id): ?Preference
{
$data = $this->item($id);
if ($data === null) {
return null;
}
$result = clone $this->result;
$result->data = $data;
return new Preference(fn () => $result);
}
/**
* The value of a key, or $default when it isn't set.
*/
public function get(string $key, mixed $default = null): mixed
{
$data = $this->item($key);
return $data !== null ? $data->value : $default;
}
public function has(string $key): bool
{
return $this->item($key) !== null;
}
/**
* The values by key.
*
* @return array<string, mixed>
*/
public function values(): array
{
$values = [];
foreach ((array) $this->result->data as $data) {
$values[$data->key] = $data->value;
}
return $values;
}
private function item(string $key): ?object
{
foreach ((array) $this->result->data as $data) {
if (($data->key ?? null) === $key) {
return $data;
}
}
return null;
}
}
+11 -7
View File
@@ -5,36 +5,40 @@ namespace Anikeen\Id\Support;
use Anikeen\Id\AnikeenId; use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\AppTokenRepository; use Anikeen\Id\Contracts\AppTokenRepository;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use Throwable; use Throwable;
/** /**
* Sends a request with the app's own token (client credentials). A rejected token is renewed * Sends a request with the app's own token (client credentials). A rejected token is renewed
* once; every failure ends in an ApiException with status and error code. * once; every failure ends in an ApiException with status and error code (a 412 in a
* PreconditionFailedException).
*/ */
final class AppRequest final class AppRequest
{ {
/** /**
* @param array<string, string> $headers Extra request headers, e.g. `If-Match`.
* @throws ApiException * @throws ApiException
*/ */
public static function send(string $method, string $path, ?array $payload = null, array $query = []): Result public static function send(string $method, string $path, ?array $payload = null, array $query = [], array $headers = []): Result
{ {
$result = self::attempt($method, $path, $payload, $query); $result = self::attempt($method, $path, $payload, $query, $headers);
if ($result->status === 401) { if ($result->status === 401) {
$tokens = app(AppTokenRepository::class); $tokens = app(AppTokenRepository::class);
if (method_exists($tokens, 'forgetAccessToken')) { if (method_exists($tokens, 'forgetAccessToken')) {
$tokens->forgetAccessToken(); $tokens->forgetAccessToken();
$result = self::attempt($method, $path, $payload, $query); $result = self::attempt($method, $path, $payload, $query, $headers);
} }
} }
if (!$result->success()) { if (!$result->success()) {
$body = is_object($result->data) ? $result->data : null; $body = is_object($result->data) ? $result->data : null;
$exception = $result->status === 412 ? PreconditionFailedException::class : ApiException::class;
throw new ApiException( throw new $exception(
sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()), sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()),
$result->response()?->getStatusCode() ?? 0, $result->response()?->getStatusCode() ?? 0,
$body->error ?? null, $body->error ?? null,
@@ -48,10 +52,10 @@ final class AppRequest
/** /**
* @throws ApiException * @throws ApiException
*/ */
private static function attempt(string $method, string $path, ?array $payload, array $query): Result private static function attempt(string $method, string $path, ?array $payload, array $query, array $headers = []): Result
{ {
try { try {
return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query); return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query, null, false, $headers);
} catch (ApiException $e) { } catch (ApiException $e) {
throw $e; throw $e;
} catch (GuzzleException|Throwable $e) { } catch (GuzzleException|Throwable $e) {
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Anikeen\Id\Support;
use InvalidArgumentException;
/**
* @internal paths and preconditions of the preferences API
*/
final class PreferenceRequest
{
/**
* The path with every segment URL-encoded, e.g. path('v1/preferences', 'global', 'appearance').
*/
public static function path(string $base, string ...$segments): string
{
return $base . '/' . implode('/', array_map('rawurlencode', $segments));
}
/**
* `If-Match: "<version>"` (only if the stored version is this one) or `If-None-Match: *`
* (only if the key isn't set yet).
*
* @return array<string, string>
*/
public static function conditions(?int $ifVersion, bool $createOnly = false): array
{
if ($ifVersion !== null && $createOnly) {
throw new InvalidArgumentException('A write is either conditional on a version or create-only, not both.');
}
return match (true) {
$ifVersion !== null => ['If-Match' => sprintf('"%d"', $ifVersion)],
$createOnly => ['If-None-Match' => '*'],
default => [],
};
}
private function __construct()
{
}
}
+6
View File
@@ -13,6 +13,12 @@ final class EventType
public const ORDER_PAYMENT_FAILED = 'order.payment_failed'; public const ORDER_PAYMENT_FAILED = 'order.payment_failed';
public const ORDER_CANCELLED = 'order.cancelled'; public const ORDER_CANCELLED = 'order.cancelled';
public const ORDER_SHIPPED = 'order.shipped'; public const ORDER_SHIPPED = 'order.shipped';
/**
* Money was given back for an order (a refund or a partial cancellation, with a credit note);
* see the order's `refunds` and `refunded_total`. A full refund also cancels the order,
* so order.cancelled is sent too; don't rely on the order of the two events.
*/
public const ORDER_REFUNDED = 'order.refunded';
public const SUBSCRIPTION_CREATED = 'subscription.created'; public const SUBSCRIPTION_CREATED = 'subscription.created';
public const SUBSCRIPTION_ACTIVATED = 'subscription.activated'; public const SUBSCRIPTION_ACTIVATED = 'subscription.activated';
+2
View File
@@ -19,6 +19,8 @@ class BillableModelTest extends TestCase
foreach (['addresses', 'countries', 'defaultPaymentMethod', 'invoices', 'orders', 'paymentMethods', 'subscriptions', 'transactions', 'userData'] as $name) { foreach (['addresses', 'countries', 'defaultPaymentMethod', 'invoices', 'orders', 'paymentMethods', 'subscriptions', 'transactions', 'userData'] as $name) {
$this->{$name.'Cache'} = 'cached'; $this->{$name.'Cache'} = 'cached';
} }
$this->preferencesCache['global'] = 'cached';
} }
}; };
$user->email = 'ada@example.com'; $user->email = 'ada@example.com';
+129
View File
@@ -283,4 +283,133 @@ class EndpointsTest extends TestCase
$this->assertSame(['items' => [], 'billing_address' => ['country_iso' => 'DE']], $this->sentJson(0)); $this->assertSame(['items' => [], 'billing_address' => ['country_iso' => 'DE']], $this->sentJson(0));
} }
private function preference(string $namespace, string $key, mixed $value, int $version = 7): array
{
return ['namespace' => $namespace, 'key' => $key, 'value' => $value, 'version' => $version, 'updated_at' => '2026-11-06T10:00:00.000000Z', 'updated_by' => ['type' => 'app', 'client_id' => 'app-client']];
}
private function conditions(int $index): array
{
$request = $this->sent[$index]['request'];
return [$request->getHeaderLine('If-Match'), $request->getHeaderLine('If-None-Match')];
}
public function test_global_preferences_of_the_user(): void
{
$this->respond(200, ['data' => [$this->preference('global', 'appearance', 'dark')]]);
$preferences = $this->owner()->globalPreferences();
$this->assertSame('dark', $preferences->get('appearance'));
$this->assertSame('GET /api/v1/preferences/global Bearer user-token', $this->sentCall(0));
}
public function test_global_preference_with_a_default(): void
{
$this->respond(200, ['data' => [$this->preference('global', 'appearance', 'light')]]);
$owner = $this->owner();
$this->assertSame('light', $owner->globalPreference('appearance'));
$this->assertSame('system', $owner->globalPreference('unknown', 'system'));
$this->assertCount(1, $this->sent);
$this->assertSame('GET /api/v1/preferences/global Bearer user-token', $this->sentCall(0));
}
public function test_set_global_preference(): void
{
$this->respond(201, ['data' => $this->preference('global', 'appearance', 'dark', 8)]);
$this->respond(200, ['data' => $this->preference('global', 'appearance', 'light', 9)]);
$created = $this->owner()->setGlobalPreference('appearance', 'dark');
$updated = $this->owner()->setGlobalPreference('appearance', 'light', ifVersion: 8);
$this->assertTrue($created->wasCreated());
$this->assertFalse($updated->wasCreated());
$this->assertSame(9, $updated->version);
$this->assertSame('PUT /api/v1/preferences/global/appearance Bearer user-token', $this->sentCall(0));
$this->assertSame(['value' => 'dark'], $this->sentJson(0));
$this->assertSame(['', ''], $this->conditions(0));
$this->assertSame(['"8"', ''], $this->conditions(1));
}
public function test_delete_global_preference(): void
{
$this->respond(204);
$this->respond(204);
$this->owner()->deleteGlobalPreference('appearance');
$this->owner()->deleteGlobalPreference('appearance', ifVersion: 9);
$this->assertSame('DELETE /api/v1/preferences/global/appearance Bearer user-token', $this->sentCall(0));
$this->assertSame('', (string) $this->sent[0]['request']->getBody());
$this->assertSame(['', ''], $this->conditions(0));
$this->assertSame(['"9"', ''], $this->conditions(1));
}
public function test_preferences_by_user_id(): void
{
$this->respond(200, ['data' => [$this->preference('acme', 'plan', 'pro'), $this->preference('acme', 'theme', ['accent' => 'blue'])]]);
$preferences = app(AnikeenId::class)->preferencesByUserId('01JUSER', 'acme');
$this->assertEquals(['plan' => 'pro', 'theme' => (object) ['accent' => 'blue']], $preferences->values());
$this->assertSame('blue', $preferences->find('theme')->value->accent);
$this->assertNull($preferences->find('missing'));
$this->assertSame('GET /api/v1/client/users/01JUSER/preferences/acme Bearer app-token', $this->sentCall(0));
}
public function test_preference_by_user_id(): void
{
$this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]);
$preference = app(AnikeenId::class)->preferenceByUserId('01JUSER', 'acme', 'plan');
$this->assertSame('pro', $preference->value);
$this->assertSame(7, $preference->version);
$this->assertSame('GET /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0));
}
public function test_set_preference_by_user_id(): void
{
$this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]);
$this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]);
$this->respond(201, ['data' => $this->preference('acme', 'plan', 'pro')]);
$anikeenId = app(AnikeenId::class);
$anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro');
$anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7);
$anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', createOnly: true);
$this->assertSame('PUT /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0));
$this->assertSame(['value' => 'pro'], $this->sentJson(0));
$this->assertSame(['', ''], $this->conditions(0));
$this->assertSame(['"7"', ''], $this->conditions(1));
$this->assertSame(['', '*'], $this->conditions(2));
}
public function test_delete_preference_by_user_id(): void
{
$this->respond(204);
$this->respond(204);
app(AnikeenId::class)->deletePreferenceByUserId('01JUSER', 'acme', 'plan');
app(AnikeenId::class)->deletePreferenceByUserId('01JUSER', 'acme', 'plan', ifVersion: 7);
$this->assertSame('DELETE /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0));
$this->assertSame(['', ''], $this->conditions(0));
$this->assertSame(['"7"', ''], $this->conditions(1));
}
public function test_find_user_id_by_preference(): void
{
$this->respond(200, ['data' => ['user_id' => '01JUSER', 'namespace' => 'acme', 'key' => 'customer_id', 'version' => 7, 'updated_at' => '2026-11-06T10:00:00.000000Z']]);
$this->assertSame('01JUSER', app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123'));
$this->assertSame('POST /api/v1/client/preferences/acme/lookup Bearer app-token', $this->sentCall(0));
$this->assertSame(['key' => 'customer_id', 'value' => 'cus_123'], $this->sentJson(0));
$this->assertSame('', $this->sent[0]['request']->getUri()->getQuery());
}
} }
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Enums\Appearance;
use Anikeen\Id\Enums\SharedPreference;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class PreferencesTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
$owner->anikeen_id_access_token = 'user-token';
return $owner;
}
private function preference(string $key, mixed $value, int $version = 7): array
{
return ['data' => ['namespace' => 'global', 'key' => $key, 'value' => $value, 'version' => $version, 'updated_at' => '2026-11-06T10:00:00.000000Z', 'updated_by' => ['type' => 'user', 'client_id' => 'app-client']]];
}
private function path(int $index): string
{
return $this->sent[$index]['request']->getUri()->getPath();
}
public function test_the_lookup_returns_null_when_nobody_holds_the_value(): void
{
$this->respond(404, ['message' => 'The preference is not set.', 'error' => 'preference_not_found']);
$this->assertNull(app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123'));
}
public function test_the_lookup_throws_on_other_not_found_answers(): void
{
$this->respond(404, ['message' => 'The namespace does not exist.', 'error' => 'namespace_not_found']);
try {
app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123');
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertSame(404, $e->status);
$this->assertSame('namespace_not_found', $e->error);
}
}
public function test_the_lookup_throws_on_a_404_without_an_error_code(): void
{
$this->respond(404, ['message' => 'Not Found']);
$this->expectException(ApiException::class);
app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123');
}
public function test_the_lookup_throws_on_validation_errors(): void
{
$this->respond(422, ['message' => 'The key is not unique.', 'error' => 'key_not_unique']);
try {
app(AnikeenId::class)->findUserIdByPreference('acme', 'plan', 'pro');
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertSame('key_not_unique', $e->error);
}
}
public function test_a_failed_app_write_carries_the_current_version(): void
{
$this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 12]);
try {
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7);
$this->fail('no exception');
} catch (PreconditionFailedException $e) {
$this->assertSame(412, $e->status);
$this->assertSame('preference_version_mismatch', $e->error);
$this->assertSame(12, $e->currentVersion());
}
}
public function test_a_create_only_write_of_a_set_key_fails(): void
{
$this->respond(412, ['message' => 'The preference is already set.', 'error' => 'preference_exists', 'current_version' => 3]);
try {
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', createOnly: true);
$this->fail('no exception');
} catch (PreconditionFailedException $e) {
$this->assertSame('preference_exists', $e->error);
$this->assertSame(3, $e->currentVersion());
}
}
public function test_a_failed_user_write_carries_the_current_version(): void
{
$this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => null]);
$this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 5]);
foreach ([
fn () => $this->owner()->setGlobalPreference('appearance', 'dark', ifVersion: 4),
fn () => $this->owner()->deleteGlobalPreference('appearance', ifVersion: 4),
] as $i => $call) {
try {
$call();
$this->fail('no exception');
} catch (PreconditionFailedException $e) {
$this->assertSame([null, 5][$i], $e->currentVersion());
}
}
}
public function test_a_write_is_either_conditional_or_create_only(): void
{
$this->expectException(InvalidArgumentException::class);
try {
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7, createOnly: true);
} finally {
$this->assertCount(0, $this->sent);
}
}
public function test_writes_clear_the_cached_preferences(): void
{
$owner = $this->owner();
$this->respond(200, ['data' => [$this->preference('appearance', 'light')['data']]]);
$this->respond(200, $this->preference('appearance', 'dark', 8));
$this->respond(200, ['data' => [$this->preference('appearance', 'dark', 8)['data']]]);
$this->respond(204);
$this->respond(200, ['data' => []]);
$this->assertSame('light', $owner->globalPreference(SharedPreference::APPEARANCE));
$this->assertSame('light', $owner->globalPreference(SharedPreference::APPEARANCE)); // cached
$owner->setGlobalPreference(SharedPreference::APPEARANCE, Appearance::DARK);
$this->assertSame('dark', $owner->globalPreference(SharedPreference::APPEARANCE));
$owner->deleteGlobalPreference(SharedPreference::APPEARANCE);
$this->assertSame(Appearance::SYSTEM, $owner->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM));
$this->assertCount(5, $this->sent);
$this->assertSame(['GET', 'PUT', 'GET', 'DELETE', 'GET'], array_map(fn ($sent) => $sent['request']->getMethod(), $this->sent));
}
public function test_a_failed_write_clears_the_cached_preferences_too(): void
{
$owner = $this->owner();
$this->respond(200, ['data' => [$this->preference('appearance', 'light')['data']]]);
$this->respond(412, ['message' => 'x', 'error' => 'preference_version_mismatch', 'current_version' => 9]);
$this->respond(200, ['data' => [$this->preference('appearance', 'dark', 9)['data']]]);
$owner->globalPreferences();
try {
$owner->setGlobalPreference('appearance', 'dark', ifVersion: 7);
} catch (PreconditionFailedException) {
}
$this->assertSame('dark', $owner->globalPreference('appearance'));
$this->assertCount(3, $this->sent);
}
public function test_path_segments_are_encoded(): void
{
$this->respond(200, ['data' => []]);
$this->respond(204);
$this->respond(200, ['data' => ['user_id' => '01JUSER']]);
$anikeenId = app(AnikeenId::class);
$anikeenId->preferencesByUserId('a/b', 'x y');
$anikeenId->deletePreferenceByUserId('01JUSER', 'acme', '../key?');
$anikeenId->findUserIdByPreference('ac/me', 'k', 'v');
$this->assertSame('/api/v1/client/users/a%2Fb/preferences/x%20y', $this->path(0));
$this->assertSame('/api/v1/client/users/01JUSER/preferences/acme/..%2Fkey%3F', $this->path(1));
$this->assertSame('/api/v1/client/preferences/ac%2Fme/lookup', $this->path(2));
}
public function test_values_round_trip_as_json(): void
{
$this->respond(201, $this->preference('settings', (object) []));
$preference = app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'settings', (object) []);
$this->assertSame('{"value":{}}', (string) $this->sent[0]['request']->getBody());
$this->assertEquals((object) [], $preference->value);
}
public function test_a_deleted_user_is_not_found(): void
{
$this->respond(404, ['message' => 'The user does not exist.', 'error' => 'user_not_found']);
try {
app(AnikeenId::class)->preferenceByUserId('01JGONE', 'acme', 'plan');
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertTrue($e->isNotFound());
$this->assertSame('user_not_found', $e->error);
}
}
public function test_an_unreachable_service_is_temporary(): void
{
$this->http->append(new \GuzzleHttp\Exception\ConnectException('down', new \GuzzleHttp\Psr7\Request('DELETE', 'x')));
try {
$this->owner()->deleteGlobalPreference('appearance');
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertTrue($e->isTemporary());
}
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\CollectionException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Resources\SshKeys;
use Anikeen\Id\Support\AppRequest;
class RequestHeadersTest extends TestCase
{
public function test_requests_without_extra_headers_send_the_same_headers_as_before(): void
{
$this->respond(200, ['data' => []]);
AppRequest::send('GET', 'v1/client/subscriptions', null, ['page' => 1]);
$request = $this->sent[0]['request'];
$this->assertSame(['Bearer app-token'], $request->getHeader('Authorization'));
$this->assertSame(['app-client'], $request->getHeader('Client-ID'));
$this->assertFalse($request->hasHeader('If-Match'));
$this->assertFalse($request->hasHeader('If-None-Match'));
$this->assertSame('page=1', $request->getUri()->getQuery());
}
public function test_app_requests_send_extra_headers_also_on_the_retry_with_a_fresh_token(): void
{
$this->respond(401, ['message' => 'Unauthenticated.']);
$this->respond(200, ['data' => []]);
AppRequest::send('PUT', 'v1/x', ['value' => 1], [], ['If-Match' => '"7"']);
$this->assertSame('"7"', $this->sent[0]['request']->getHeaderLine('If-Match'));
$this->assertSame('"7"', $this->sent[1]['request']->getHeaderLine('If-Match'));
$this->assertSame('Bearer fresh-app-token', $this->sent[1]['request']->getHeaderLine('Authorization'));
}
public function test_extra_headers_do_not_replace_the_client_and_token_headers(): void
{
$this->respond(200, ['data' => []]);
app(AnikeenId::class)->withToken('user-token')
->put('v1/x', ['value' => 1], [], null, ['If-None-Match' => '*', 'Authorization' => 'Bearer other']);
$request = $this->sent[0]['request'];
$this->assertSame('*', $request->getHeaderLine('If-None-Match'));
$this->assertSame('Bearer user-token', $request->getHeaderLine('Authorization'));
}
public function test_get_and_delete_helpers_take_headers(): void
{
$this->respond(200, ['data' => []]);
$this->respond(204);
$anikeenId = app(AnikeenId::class)->withToken('user-token');
$anikeenId->get('v1/x', [], null, ['If-None-Match' => '"3"']);
$anikeenId->delete('v1/x', [], [], null, ['If-Match' => '"3"']);
$this->assertSame('"3"', $this->sent[0]['request']->getHeaderLine('If-None-Match'));
$this->assertSame('"3"', $this->sent[1]['request']->getHeaderLine('If-Match'));
}
public function test_a_412_of_an_app_request_is_a_precondition_failed_exception(): void
{
$this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 12]);
try {
AppRequest::send('PUT', 'v1/x', ['value' => 1], [], ['If-Match' => '"7"']);
$this->fail('no exception');
} catch (PreconditionFailedException $e) {
$this->assertSame(412, $e->status);
$this->assertSame('preference_version_mismatch', $e->error);
$this->assertSame(12, $e->currentVersion());
}
}
public function test_a_412_of_a_user_request_is_a_precondition_failed_exception(): void
{
$this->respond(412, ['message' => 'The preference is already set.', 'error' => 'preference_exists', 'current_version' => null]);
$result = app(AnikeenId::class)->withToken('user-token')->put('v1/x', ['value' => 1]);
$e = ApiException::fromResult($result);
$this->assertInstanceOf(PreconditionFailedException::class, $e);
$this->assertSame('preference_exists', $e->error);
$this->assertNull($e->currentVersion());
}
public function test_list_errors_keep_their_collection_exception_type(): void
{
$this->respond(412, ['message' => 'x', 'error' => 'x']);
$this->expectException(CollectionException::class);
$anikeenId = app(AnikeenId::class)->withToken('user-token');
SshKeys::builder(fn () => $anikeenId->get('v1/ssh-keys'));
}
public function test_other_errors_stay_plain_api_exceptions(): void
{
$this->respond(409, ['message' => 'Taken.', 'error' => 'preference_value_taken']);
try {
AppRequest::send('PUT', 'v1/x', ['value' => 1]);
$this->fail('no exception');
} catch (ApiException $e) {
$this->assertSame(ApiException::class, $e::class);
$this->assertTrue($e->isConflict());
}
}
}
+7
View File
@@ -25,6 +25,13 @@ class ScopeTest extends TestCase
$this->assertSame($expected, $constant); $this->assertSame($expected, $constant);
} }
public function test_the_preference_scopes_match_anikeen_id(): void
{
$this->assertSame('preferences', Scope::PREFERENCES);
$this->assertSame('preferences:read', Scope::PREFERENCES_READ);
$this->assertSame('preferences:client', Scope::PREFERENCES_CLIENT);
}
public function test_there_is_no_full_products_scope_the_console_would_not_accept(): void public function test_there_is_no_full_products_scope_the_console_would_not_accept(): void
{ {
// the Console's product APIs accept `user`, `user:read` and `products:read` only // the Console's product APIs accept `user`, `user:read` and `products:read` only
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Enums\Appearance;
use Anikeen\Id\Enums\SharedPreference;
class SharedPreferenceTest extends TestCase
{
public function test_the_shared_keys_and_values_match_anikeen_id(): void
{
$this->assertSame('global', SharedPreference::NAMESPACE);
$this->assertSame('appearance', SharedPreference::APPEARANCE);
$this->assertSame(['system', 'light', 'dark'], Appearance::all());
}
}
+6
View File
@@ -70,6 +70,12 @@ class WebhookTest extends TestCase
$this->assertContains(EventType::SUBSCRIPTION_RENEWED, EventType::all()); $this->assertContains(EventType::SUBSCRIPTION_RENEWED, EventType::all());
} }
public function test_refunds_have_their_own_event_type(): void
{
$this->assertSame('order.refunded', EventType::ORDER_REFUNDED);
$this->assertContains(EventType::ORDER_REFUNDED, EventType::all());
}
public function test_a_change_carries_the_previous_attributes(): void public function test_a_change_carries_the_previous_attributes(): void
{ {
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [ $this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [