mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-06 23:58:35 +00:00
Document preferences, their scopes and order.refunded
This commit is contained in:
2 files changed
+355
-10
No files matched your search
@@ -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,10 @@ 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, followed by `order.cancelled`.
|
||||||
|
|
||||||
### 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 +931,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 +1247,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 +1414,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
|
||||||
|
|||||||
+168
-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)
|
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,10 @@ 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, followed by `order.cancelled`.
|
||||||
|
|
||||||
### 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 +931,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.
|
||||||
|
|||||||
Reference in new issue
Block a user