mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-06 23:58:35 +00:00
Merge pull request #23 from anikeen-com/feature/preferences
Add the preferences API
This commit is contained in:
24 files changed
+1324
-28
No files matched your search
@@ -1,5 +1,26 @@
|
||||
# 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)
|
||||
|
||||
App billing features; they need an Anikeen ID release that supports them.
|
||||
|
||||
@@ -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)
|
||||
4. [Payments](#payments)
|
||||
5. [Trusted apps](#trusted-apps)
|
||||
6. [API authentication](#api-authentication)
|
||||
7. [General](#general)
|
||||
8. [Examples](#examples)
|
||||
9. [Documentation](#documentation)
|
||||
10. [Development](#development)
|
||||
6. [Preferences](#preferences)
|
||||
7. [API authentication](#api-authentication)
|
||||
8. [General](#general)
|
||||
9. [Examples](#examples)
|
||||
10. [Documentation](#documentation)
|
||||
11. [Development](#development)
|
||||
|
||||
## 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`;
|
||||
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
|
||||
|
||||
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
|
||||
[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
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
```php
|
||||
@@ -1241,6 +1415,15 @@ public function setBillable(Billable|Model $billable): self
|
||||
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
|
||||
|
||||
```php
|
||||
|
||||
+169
-5
@@ -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)
|
||||
4. [Payments](#payments)
|
||||
5. [Trusted apps](#trusted-apps)
|
||||
6. [API authentication](#api-authentication)
|
||||
7. [General](#general)
|
||||
8. [Examples](#examples)
|
||||
9. [Documentation](#documentation)
|
||||
10. [Development](#development)
|
||||
6. [Preferences](#preferences)
|
||||
7. [API authentication](#api-authentication)
|
||||
8. [General](#general)
|
||||
9. [Examples](#examples)
|
||||
10. [Documentation](#documentation)
|
||||
11. [Development](#development)
|
||||
|
||||
## 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`;
|
||||
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
|
||||
|
||||
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
|
||||
[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
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
Use this when your own API accepts access tokens issued by Anikeen ID.
|
||||
|
||||
+14
-10
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Anikeen\Id;
|
||||
|
||||
use Anikeen\Id\Concerns\ManagesPreferences;
|
||||
use Anikeen\Id\Concerns\ManagesPricing;
|
||||
use Anikeen\Id\Concerns\ManagesProducts;
|
||||
use Anikeen\Id\Concerns\ManagesShippingLabels;
|
||||
@@ -23,6 +24,7 @@ use Illuminate\Contracts\Auth\Authenticatable;
|
||||
class AnikeenId
|
||||
{
|
||||
use OauthTrait;
|
||||
use ManagesPreferences;
|
||||
use ManagesPricing;
|
||||
use ManagesProducts;
|
||||
use ManagesShippingLabels;
|
||||
@@ -369,10 +371,12 @@ class AnikeenId
|
||||
/**
|
||||
* 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 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) {
|
||||
$parameters[$paginator->action] = $paginator->cursor();
|
||||
@@ -380,7 +384,7 @@ class AnikeenId
|
||||
|
||||
try {
|
||||
$response = $this->client->request($method, $path, [
|
||||
'headers' => $this->buildHeaders((bool)$payload, $useClientSecret),
|
||||
'headers' => $this->buildHeaders((bool)$payload, $useClientSecret) + $headers,
|
||||
'query' => Query::build($parameters),
|
||||
'json' => $payload ?: null,
|
||||
]);
|
||||
@@ -419,35 +423,35 @@ class AnikeenId
|
||||
* @throws GuzzleException
|
||||
* @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 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 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 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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use Anikeen\Id\Concerns\ManagesInvoices;
|
||||
use Anikeen\Id\Concerns\ManagesOrders;
|
||||
use Anikeen\Id\Concerns\ManagesPaymentMethods;
|
||||
use Anikeen\Id\Concerns\ManagesProfile;
|
||||
use Anikeen\Id\Concerns\ManagesUserPreferences;
|
||||
use Anikeen\Id\Concerns\ManagesSubscriptions;
|
||||
use Anikeen\Id\Concerns\ManagesTaxation;
|
||||
use Anikeen\Id\Concerns\ManagesTransactions;
|
||||
@@ -27,6 +28,7 @@ trait Billable
|
||||
use ManagesSubscriptions;
|
||||
use ManagesTaxation;
|
||||
use ManagesTransactions;
|
||||
use ManagesUserPreferences;
|
||||
use ManagesUserSshKeys;
|
||||
|
||||
/*
|
||||
@@ -40,6 +42,8 @@ trait Billable
|
||||
protected mixed $invoicesCache = null;
|
||||
protected mixed $ordersCache = null;
|
||||
protected mixed $paymentMethodsCache = null;
|
||||
/** @var array<string, \Anikeen\Id\Resources\Preferences> by namespace */
|
||||
protected array $preferencesCache = [];
|
||||
protected mixed $subscriptionsCache = null;
|
||||
protected mixed $transactionsCache = null;
|
||||
protected mixed $userDataCache = null;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@ class Scope
|
||||
const TRANSACTIONS_READ = 'transactions:read';
|
||||
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_READ = 'ssh-keys:read';
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -28,10 +28,16 @@ class ApiException extends ResourceException
|
||||
public static function fromResult(Result $result): static
|
||||
{
|
||||
$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(
|
||||
rtrim((string) ($body->message ?? $result->error()), '.'),
|
||||
$result->response()?->getStatusCode() ?? 0,
|
||||
$status,
|
||||
$body->error ?? null,
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,36 +5,40 @@ namespace Anikeen\Id\Support;
|
||||
use Anikeen\Id\AnikeenId;
|
||||
use Anikeen\Id\Contracts\AppTokenRepository;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Exceptions\PreconditionFailedException;
|
||||
use Anikeen\Id\Result;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $headers Extra request headers, e.g. `If-Match`.
|
||||
* @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) {
|
||||
$tokens = app(AppTokenRepository::class);
|
||||
|
||||
if (method_exists($tokens, 'forgetAccessToken')) {
|
||||
$tokens->forgetAccessToken();
|
||||
$result = self::attempt($method, $path, $payload, $query);
|
||||
$result = self::attempt($method, $path, $payload, $query, $headers);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$result->success()) {
|
||||
$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()),
|
||||
$result->response()?->getStatusCode() ?? 0,
|
||||
$body->error ?? null,
|
||||
@@ -48,10 +52,10 @@ final class AppRequest
|
||||
/**
|
||||
* @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 {
|
||||
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) {
|
||||
throw $e;
|
||||
} catch (GuzzleException|Throwable $e) {
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ final class EventType
|
||||
public const ORDER_PAYMENT_FAILED = 'order.payment_failed';
|
||||
public const ORDER_CANCELLED = 'order.cancelled';
|
||||
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_ACTIVATED = 'subscription.activated';
|
||||
|
||||
@@ -19,6 +19,8 @@ class BillableModelTest extends TestCase
|
||||
foreach (['addresses', 'countries', 'defaultPaymentMethod', 'invoices', 'orders', 'paymentMethods', 'subscriptions', 'transactions', 'userData'] as $name) {
|
||||
$this->{$name.'Cache'} = 'cached';
|
||||
}
|
||||
|
||||
$this->preferencesCache['global'] = 'cached';
|
||||
}
|
||||
};
|
||||
$user->email = 'ada@example.com';
|
||||
|
||||
@@ -283,4 +283,133 @@ class EndpointsTest extends TestCase
|
||||
|
||||
$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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,13 @@ class ScopeTest extends TestCase
|
||||
$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
|
||||
{
|
||||
// the Console's product APIs accept `user`, `user:read` and `products:read` only
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,12 @@ class WebhookTest extends TestCase
|
||||
$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
|
||||
{
|
||||
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [
|
||||
|
||||
Reference in new issue
Block a user