mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-06 23:58:35 +00:00
Add the preferences API for apps and users
This commit is contained in:
10 files changed
+703
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user