mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-07 16:18:33 +00:00
Compare commits
10
Commits
3.4.0-beta.1
...
3.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31de31aa47 | ||
|
|
0205696719 | ||
|
|
9c9603591a | ||
|
|
57efac2bf5 | ||
|
|
0ccf46d69a | ||
|
|
5ecc535e07 | ||
|
|
93f6d79a0a | ||
|
|
2eceb34faa | ||
|
|
d458d5d7fc | ||
|
|
a117a6a3c9 |
No files matched your search
+17
-2
@@ -1,8 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased
|
## 3.4.0 (2026-11-07)
|
||||||
|
|
||||||
Preferences, charges and the billing limit; they need an Anikeen ID release that supports them.
|
Preferences, charges, the billing limit, user profiles and the user and preference webhooks; they
|
||||||
|
need an Anikeen ID release that supports them.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -27,6 +28,20 @@ Preferences, charges and the billing limit; they need an Anikeen ID release that
|
|||||||
(`EventType::BILLING_LIMIT_REACHED`, `BILLING_LIMIT_AVAILABLE`).
|
(`EventType::BILLING_LIMIT_REACHED`, `BILLING_LIMIT_AVAILABLE`).
|
||||||
- An optional trailing `$headers` argument on `AnikeenId::request()`, `get()`, `post()`, `put()`,
|
- An optional trailing `$headers` argument on `AnikeenId::request()`, `get()`, `post()`, `put()`,
|
||||||
`delete()` and `AppRequest::send()`; calls without it send the same request as before.
|
`delete()` and `AppRequest::send()`; calls without it send the same request as before.
|
||||||
|
- Profile updates with the user's token (scope `user`): `updateProfile()` (name, username, email;
|
||||||
|
a new email address waits for the user's confirmation), `uploadAvatar()` and `removeAvatar()` on
|
||||||
|
`Billable`. They return the updated profile and clear the cached `getUserData()`.
|
||||||
|
- Reading a connected user's profile by ID for trusted apps: `findUserById()` (`null` for an
|
||||||
|
unknown, deleted or unconnected user).
|
||||||
|
- `Resources\User` with `emailVerified()`, `version()` (`profile_version` or a webhook's
|
||||||
|
`version`), `pendingEmail()` and `pendingEmailExpiresAt()`; it also reads the object of a
|
||||||
|
`user.updated` event.
|
||||||
|
- `ValidationFailedException` (a subclass of `ApiException`) for 422 responses, with
|
||||||
|
`errorCodes()`, `errorCodeFor()`, `hasErrorCode()` and `errors()`; catching `ApiException` keeps
|
||||||
|
working.
|
||||||
|
- `AnikeenId::requestMultipart()` for file uploads.
|
||||||
|
- Opt-in webhook events `user.updated`, `user.deleted` and `preference.updated`
|
||||||
|
(`EventType::USER_UPDATED`, `USER_DELETED`, `PREFERENCE_UPDATED`).
|
||||||
|
|
||||||
## 3.3.0 (2026-11-06)
|
## 3.3.0 (2026-11-06)
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ 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. [Preferences](#preferences)
|
6. [User profiles](#user-profiles)
|
||||||
7. [API authentication](#api-authentication)
|
7. [Preferences](#preferences)
|
||||||
8. [General](#general)
|
8. [API authentication](#api-authentication)
|
||||||
9. [Examples](#examples)
|
9. [General](#general)
|
||||||
10. [Documentation](#documentation)
|
10. [Examples](#examples)
|
||||||
11. [Development](#development)
|
11. [Documentation](#documentation)
|
||||||
|
12. [Development](#development)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -182,6 +183,8 @@ 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`.
|
||||||
|
|
||||||
|
Changing the user's [profile](#user-profiles) needs `Scope::USER` (`user`); `user:read` only reads.
|
||||||
|
|
||||||
Anikeen ID's [preferences](#preferences) use these scopes:
|
Anikeen ID's [preferences](#preferences) use these scopes:
|
||||||
|
|
||||||
| Scope | Constant | Grants |
|
| Scope | Constant | Grants |
|
||||||
@@ -571,6 +574,71 @@ refund or a partial cancellation, with a credit note); the order carries its `re
|
|||||||
`refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the
|
`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.
|
order of the two events.
|
||||||
|
|
||||||
|
#### User and preference events
|
||||||
|
|
||||||
|
These events are about the user rather than your app's orders. They are **opt-in**: select them
|
||||||
|
explicitly for your endpoint, `*` doesn't include them.
|
||||||
|
|
||||||
|
| Type | Constant | Sent to | `data` |
|
||||||
|
|------|----------|---------|--------|
|
||||||
|
| `user.updated` | `EventType::USER_UPDATED` | apps the user is connected to (untrusted apps only with a token of the user carrying `user`, `user:read` or `*`), the app that made the change included | `object`: the full profile `{object: "user", id, name, username, email, email_verified, avatar, version}`; `changed`: the changed fields, e.g. `["name", "avatar"]` (never old values) |
|
||||||
|
| `user.deleted` | `EventType::USER_DELETED` | apps the user was connected to | `object`: `{object: "user", id, deleted: true, deleted_at}` |
|
||||||
|
| `preference.updated` | `EventType::PREFERENCE_UPDATED` | [trusted](#trusted-apps) apps that may read the namespace, never the app that made the change | `object`: `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by: {type: "user"\|"app", client_id}}`, never the value |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": "01JEVT…", "type": "user.updated", "created": 1762500000,
|
||||||
|
"data": {"object": {"object": "user", "id": "01JUSER…", "name": "Erika Mustermann", "username": "erika",
|
||||||
|
"email": "erika@example.com", "email_verified": true,
|
||||||
|
"avatar": "https://id.anikeen.com/avatars/01JUSER…", "version": 8},
|
||||||
|
"changed": ["name", "avatar"]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`user.updated`** is sent for changes to `name`, `username`, the verified email address,
|
||||||
|
`email_verified` and the profile picture, from any source (the user's settings, the
|
||||||
|
[profile API](#changing-the-profile-user-token), support). **A new email address arrives only
|
||||||
|
once it is verified**: `email` is `null` while the address isn't, and a requested change sends
|
||||||
|
nothing until the user confirms it (then `changed` is `["email", "email_verified"]` or
|
||||||
|
`["email"]`). `avatar` is a stable URL; `avatar` in `changed` means the picture behind it changed:
|
||||||
|
drop a cached copy. `new User($object)` (`Anikeen\Id\Resources\User`) reads the object.
|
||||||
|
- **Versions:** `version` grows with every such change (`profile_version` in the REST shapes).
|
||||||
|
Events can arrive late or out of order: keep the highest version you have seen and ignore older
|
||||||
|
ones. A missed event can be caught up with [`findUserById()`](#reading-a-user-by-id-trusted-apps).
|
||||||
|
- **`user.deleted`** is final and sent once: delete or anonymise your copy of the account (and
|
||||||
|
what you keep only for it), and stop acting for the user. No other event about the user follows
|
||||||
|
it, also none still queued. If a `user.updated` arrives later for the same ID, support restored
|
||||||
|
the account: treat it as a returning user.
|
||||||
|
- **`preference.updated`** is thin: fetch the preference again (`preferenceByUserId()`) or drop your
|
||||||
|
copy when `deleted` is `true`, and ignore events with a lower `version` than the one you hold
|
||||||
|
(versions are per user). Nothing is sent for a write that changes nothing, and none when the
|
||||||
|
account is deleted (`user.deleted` covers that).
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Anikeen\Id\Resources\User;
|
||||||
|
|
||||||
|
$object = $event['data']['object'];
|
||||||
|
|
||||||
|
if ($event['type'] === EventType::USER_UPDATED) {
|
||||||
|
$profile = new User($object);
|
||||||
|
$account = Account::firstWhere('anikeen_id', $profile->id());
|
||||||
|
|
||||||
|
if ($account && $profile->version() > $account->profile_version) {
|
||||||
|
$account->update([
|
||||||
|
'name' => $profile->name(),
|
||||||
|
'email' => $profile->email() ?? $account->email, // null while unverified
|
||||||
|
'profile_version' => $profile->version(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event['type'] === EventType::USER_DELETED) {
|
||||||
|
Account::firstWhere('anikeen_id', $object['id'])?->anonymise();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event['type'] === EventType::PREFERENCE_UPDATED) {
|
||||||
|
Cache::forget("preferences:{$object['user_id']}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 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
|
||||||
@@ -587,6 +655,11 @@ well as with the app's (`app*()`). It carries:
|
|||||||
- `body()`: the error response, for errors with details (e.g. `remaining` of
|
- `body()`: the error response, for errors with details (e.g. `remaining` of
|
||||||
`billing_limit_reached`).
|
`billing_limit_reached`).
|
||||||
|
|
||||||
|
Refused input (422) throws `ValidationFailedException`, a subclass of `ApiException`. Endpoints
|
||||||
|
with codes per field (the [profile API](#changing-the-profile-user-token)) fill `errorCodes()`
|
||||||
|
(`['username' => 'username_taken']`), `errorCodeFor($field)` and `hasErrorCode($code)`; `errors()`
|
||||||
|
has the messages per field.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Anikeen\Id\Exceptions\ApiException;
|
use Anikeen\Id\Exceptions\ApiException;
|
||||||
|
|
||||||
@@ -1065,6 +1138,81 @@ $result->data->invitation_sent; // only for an account created by this ca
|
|||||||
other than an unknown address.
|
other than an unknown address.
|
||||||
|
|
||||||
|
|
||||||
|
## User profiles
|
||||||
|
|
||||||
|
### Changing the profile (user token)
|
||||||
|
|
||||||
|
With a token of the user carrying `Scope::USER` (`user`), your app can change the user's name,
|
||||||
|
username, email address and profile picture. Each call returns the updated profile
|
||||||
|
(`Anikeen\Id\Resources\User`) and clears the cached `getUserData()`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Anikeen\Id\Exceptions\ValidationFailedException;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$profile = $user->updateProfile([ // only the fields sent change
|
||||||
|
'name' => 'Erika Mustermann',
|
||||||
|
'username' => 'erika',
|
||||||
|
'email' => 'erika.new@example.com',
|
||||||
|
]);
|
||||||
|
} catch (ValidationFailedException $e) {
|
||||||
|
return back()->withErrors(match ($e->errorCodeFor('username')) {
|
||||||
|
'username_taken' => ['username' => 'This username is taken.'],
|
||||||
|
default => $e->errors(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile->email(); // still the current address
|
||||||
|
$profile->pendingEmail(); // "erika.new@example.com" until the user confirms it
|
||||||
|
$profile->pendingEmailExpiresAt();
|
||||||
|
|
||||||
|
$user->uploadAvatar($request->file('avatar')); // a path or a file
|
||||||
|
$user->uploadAvatar($path, ['x' => 120, 'y' => 40, 'size' => 512]); // a square, in pixels
|
||||||
|
$user->removeAvatar(); // back to the initials
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Email addresses** never change right away. Anikeen ID mails a confirmation link to the new
|
||||||
|
address and a notice to the current one; the new address becomes the user's address only once
|
||||||
|
they confirm it, signed in to Anikeen ID. Until then the current one stays valid for sign-in and
|
||||||
|
mails, and `user.updated` follows the confirmation. A new request replaces a pending one; a
|
||||||
|
pending address is dropped after 7 days (`pendingEmailExpiresAt()`).
|
||||||
|
- **Pictures:** jpeg, png, webp or gif, at most 5 MB and 64 to 8000 pixels per side; without
|
||||||
|
`crop` the centre is used. The `avatar` URL stays the same when the picture changes.
|
||||||
|
- **Limits:** 30 requests a minute per app and user, new email addresses 5 an hour and picture
|
||||||
|
uploads 10 an hour per user (429 `too_many_requests` with `Retry-After`). Requests with an address
|
||||||
|
that is taken count too.
|
||||||
|
|
||||||
|
| Status | `error` |
|
||||||
|
|--------|---------|
|
||||||
|
| 401 | `unauthenticated` |
|
||||||
|
| 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) |
|
||||||
|
| 413 | `avatar_too_large` (the upload is larger than the server accepts) |
|
||||||
|
| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid` (keyed by `crop.x`, `crop.y` or `crop.size`; `errorCodeFor('crop')` finds it), else `validation_failed` |
|
||||||
|
| 429 | `too_many_requests` |
|
||||||
|
|
||||||
|
Branch on the codes, the messages are for people and may change.
|
||||||
|
|
||||||
|
### Reading a user by ID (trusted apps)
|
||||||
|
|
||||||
|
[Trusted apps](#trusted-apps) can read the profile of a user connected to them (a token of the
|
||||||
|
user, a subscription or an order of the app) with the app's own token, e.g. to catch up on a
|
||||||
|
missed `user.updated`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$profile = app(AnikeenId::class)->findUserById($user->anikeen_id); // null: unknown, deleted or not connected
|
||||||
|
|
||||||
|
$profile?->name();
|
||||||
|
$profile?->email(); // returned even while unverified
|
||||||
|
$profile?->emailVerified();
|
||||||
|
$profile?->avatar();
|
||||||
|
$profile?->version(); // compare with the version of user.updated
|
||||||
|
$profile?->locale; // also username, created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429
|
||||||
|
`too_many_requests` and other failures. The profile carries no billing or account data; read those
|
||||||
|
through their own endpoints.
|
||||||
|
|
||||||
## Preferences
|
## Preferences
|
||||||
|
|
||||||
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they
|
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they
|
||||||
@@ -1205,7 +1353,9 @@ Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference()
|
|||||||
on the model instance (one request per instance); `setGlobalPreference()` and
|
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
|
`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
|
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:
|
after about an hour, since other apps and the user can change shared preferences too (trusted
|
||||||
|
apps can select the [`preference.updated`](#user-and-preference-events) webhook to drop it
|
||||||
|
sooner):
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
|
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
|
||||||
@@ -1447,6 +1597,7 @@ public function sshKeysByUserId(string $userId): SshKeys
|
|||||||
public function getAuthedUser(): Result
|
public function getAuthedUser(): Result
|
||||||
public function createUser(array $attributes): Result
|
public function createUser(array $attributes): Result
|
||||||
public function findUserByEmail(string $email): ?Result
|
public function findUserByEmail(string $email): ?Result
|
||||||
|
public function findUserById(string $userId): ?User
|
||||||
public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result
|
public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result
|
||||||
public function refreshToken(string $storedRefreshToken, string $scope = ''): Result
|
public function refreshToken(string $storedRefreshToken, string $scope = ''): Result
|
||||||
public function isEmailExisting(string $email): Result
|
public function isEmailExisting(string $email): Result
|
||||||
@@ -1519,6 +1670,9 @@ public function getBillable(): Billable
|
|||||||
|
|
||||||
```php
|
```php
|
||||||
public function profilePortalUrl(?string $returnUrl = null, array $options = []): string
|
public function profilePortalUrl(?string $returnUrl = null, array $options = []): string
|
||||||
|
public function updateProfile(array $attributes): User
|
||||||
|
public function uploadAvatar(SplFileInfo|string $file, ?array $crop = null): User
|
||||||
|
public function removeAvatar(): User
|
||||||
public function setBillable(Billable|Model $billable): self
|
public function setBillable(Billable|Model $billable): self
|
||||||
public function getBillable(): Billable
|
public function getBillable(): Billable
|
||||||
```
|
```
|
||||||
|
|||||||
+157
-7
@@ -16,12 +16,13 @@ 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. [Preferences](#preferences)
|
6. [User profiles](#user-profiles)
|
||||||
7. [API authentication](#api-authentication)
|
7. [Preferences](#preferences)
|
||||||
8. [General](#general)
|
8. [API authentication](#api-authentication)
|
||||||
9. [Examples](#examples)
|
9. [General](#general)
|
||||||
10. [Documentation](#documentation)
|
10. [Examples](#examples)
|
||||||
11. [Development](#development)
|
11. [Documentation](#documentation)
|
||||||
|
12. [Development](#development)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -182,6 +183,8 @@ 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`.
|
||||||
|
|
||||||
|
Changing the user's [profile](#user-profiles) needs `Scope::USER` (`user`); `user:read` only reads.
|
||||||
|
|
||||||
Anikeen ID's [preferences](#preferences) use these scopes:
|
Anikeen ID's [preferences](#preferences) use these scopes:
|
||||||
|
|
||||||
| Scope | Constant | Grants |
|
| Scope | Constant | Grants |
|
||||||
@@ -571,6 +574,71 @@ refund or a partial cancellation, with a credit note); the order carries its `re
|
|||||||
`refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the
|
`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.
|
order of the two events.
|
||||||
|
|
||||||
|
#### User and preference events
|
||||||
|
|
||||||
|
These events are about the user rather than your app's orders. They are **opt-in**: select them
|
||||||
|
explicitly for your endpoint, `*` doesn't include them.
|
||||||
|
|
||||||
|
| Type | Constant | Sent to | `data` |
|
||||||
|
|------|----------|---------|--------|
|
||||||
|
| `user.updated` | `EventType::USER_UPDATED` | apps the user is connected to (untrusted apps only with a token of the user carrying `user`, `user:read` or `*`), the app that made the change included | `object`: the full profile `{object: "user", id, name, username, email, email_verified, avatar, version}`; `changed`: the changed fields, e.g. `["name", "avatar"]` (never old values) |
|
||||||
|
| `user.deleted` | `EventType::USER_DELETED` | apps the user was connected to | `object`: `{object: "user", id, deleted: true, deleted_at}` |
|
||||||
|
| `preference.updated` | `EventType::PREFERENCE_UPDATED` | [trusted](#trusted-apps) apps that may read the namespace, never the app that made the change | `object`: `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by: {type: "user"\|"app", client_id}}`, never the value |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"id": "01JEVT…", "type": "user.updated", "created": 1762500000,
|
||||||
|
"data": {"object": {"object": "user", "id": "01JUSER…", "name": "Erika Mustermann", "username": "erika",
|
||||||
|
"email": "erika@example.com", "email_verified": true,
|
||||||
|
"avatar": "https://id.anikeen.com/avatars/01JUSER…", "version": 8},
|
||||||
|
"changed": ["name", "avatar"]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`user.updated`** is sent for changes to `name`, `username`, the verified email address,
|
||||||
|
`email_verified` and the profile picture, from any source (the user's settings, the
|
||||||
|
[profile API](#changing-the-profile-user-token), support). **A new email address arrives only
|
||||||
|
once it is verified**: `email` is `null` while the address isn't, and a requested change sends
|
||||||
|
nothing until the user confirms it (then `changed` is `["email", "email_verified"]` or
|
||||||
|
`["email"]`). `avatar` is a stable URL; `avatar` in `changed` means the picture behind it changed:
|
||||||
|
drop a cached copy. `new User($object)` (`Anikeen\Id\Resources\User`) reads the object.
|
||||||
|
- **Versions:** `version` grows with every such change (`profile_version` in the REST shapes).
|
||||||
|
Events can arrive late or out of order: keep the highest version you have seen and ignore older
|
||||||
|
ones. A missed event can be caught up with [`findUserById()`](#reading-a-user-by-id-trusted-apps).
|
||||||
|
- **`user.deleted`** is final and sent once: delete or anonymise your copy of the account (and
|
||||||
|
what you keep only for it), and stop acting for the user. No other event about the user follows
|
||||||
|
it, also none still queued. If a `user.updated` arrives later for the same ID, support restored
|
||||||
|
the account: treat it as a returning user.
|
||||||
|
- **`preference.updated`** is thin: fetch the preference again (`preferenceByUserId()`) or drop your
|
||||||
|
copy when `deleted` is `true`, and ignore events with a lower `version` than the one you hold
|
||||||
|
(versions are per user). Nothing is sent for a write that changes nothing, and none when the
|
||||||
|
account is deleted (`user.deleted` covers that).
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Anikeen\Id\Resources\User;
|
||||||
|
|
||||||
|
$object = $event['data']['object'];
|
||||||
|
|
||||||
|
if ($event['type'] === EventType::USER_UPDATED) {
|
||||||
|
$profile = new User($object);
|
||||||
|
$account = Account::firstWhere('anikeen_id', $profile->id());
|
||||||
|
|
||||||
|
if ($account && $profile->version() > $account->profile_version) {
|
||||||
|
$account->update([
|
||||||
|
'name' => $profile->name(),
|
||||||
|
'email' => $profile->email() ?? $account->email, // null while unverified
|
||||||
|
'profile_version' => $profile->version(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event['type'] === EventType::USER_DELETED) {
|
||||||
|
Account::firstWhere('anikeen_id', $object['id'])?->anonymise();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event['type'] === EventType::PREFERENCE_UPDATED) {
|
||||||
|
Cache::forget("preferences:{$object['user_id']}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 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
|
||||||
@@ -587,6 +655,11 @@ well as with the app's (`app*()`). It carries:
|
|||||||
- `body()`: the error response, for errors with details (e.g. `remaining` of
|
- `body()`: the error response, for errors with details (e.g. `remaining` of
|
||||||
`billing_limit_reached`).
|
`billing_limit_reached`).
|
||||||
|
|
||||||
|
Refused input (422) throws `ValidationFailedException`, a subclass of `ApiException`. Endpoints
|
||||||
|
with codes per field (the [profile API](#changing-the-profile-user-token)) fill `errorCodes()`
|
||||||
|
(`['username' => 'username_taken']`), `errorCodeFor($field)` and `hasErrorCode($code)`; `errors()`
|
||||||
|
has the messages per field.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Anikeen\Id\Exceptions\ApiException;
|
use Anikeen\Id\Exceptions\ApiException;
|
||||||
|
|
||||||
@@ -1065,6 +1138,81 @@ $result->data->invitation_sent; // only for an account created by this ca
|
|||||||
other than an unknown address.
|
other than an unknown address.
|
||||||
|
|
||||||
|
|
||||||
|
## User profiles
|
||||||
|
|
||||||
|
### Changing the profile (user token)
|
||||||
|
|
||||||
|
With a token of the user carrying `Scope::USER` (`user`), your app can change the user's name,
|
||||||
|
username, email address and profile picture. Each call returns the updated profile
|
||||||
|
(`Anikeen\Id\Resources\User`) and clears the cached `getUserData()`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Anikeen\Id\Exceptions\ValidationFailedException;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$profile = $user->updateProfile([ // only the fields sent change
|
||||||
|
'name' => 'Erika Mustermann',
|
||||||
|
'username' => 'erika',
|
||||||
|
'email' => 'erika.new@example.com',
|
||||||
|
]);
|
||||||
|
} catch (ValidationFailedException $e) {
|
||||||
|
return back()->withErrors(match ($e->errorCodeFor('username')) {
|
||||||
|
'username_taken' => ['username' => 'This username is taken.'],
|
||||||
|
default => $e->errors(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile->email(); // still the current address
|
||||||
|
$profile->pendingEmail(); // "erika.new@example.com" until the user confirms it
|
||||||
|
$profile->pendingEmailExpiresAt();
|
||||||
|
|
||||||
|
$user->uploadAvatar($request->file('avatar')); // a path or a file
|
||||||
|
$user->uploadAvatar($path, ['x' => 120, 'y' => 40, 'size' => 512]); // a square, in pixels
|
||||||
|
$user->removeAvatar(); // back to the initials
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Email addresses** never change right away. Anikeen ID mails a confirmation link to the new
|
||||||
|
address and a notice to the current one; the new address becomes the user's address only once
|
||||||
|
they confirm it, signed in to Anikeen ID. Until then the current one stays valid for sign-in and
|
||||||
|
mails, and `user.updated` follows the confirmation. A new request replaces a pending one; a
|
||||||
|
pending address is dropped after 7 days (`pendingEmailExpiresAt()`).
|
||||||
|
- **Pictures:** jpeg, png, webp or gif, at most 5 MB and 64 to 8000 pixels per side; without
|
||||||
|
`crop` the centre is used. The `avatar` URL stays the same when the picture changes.
|
||||||
|
- **Limits:** 30 requests a minute per app and user, new email addresses 5 an hour and picture
|
||||||
|
uploads 10 an hour per user (429 `too_many_requests` with `Retry-After`). Requests with an address
|
||||||
|
that is taken count too.
|
||||||
|
|
||||||
|
| Status | `error` |
|
||||||
|
|--------|---------|
|
||||||
|
| 401 | `unauthenticated` |
|
||||||
|
| 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) |
|
||||||
|
| 413 | `avatar_too_large` (the upload is larger than the server accepts) |
|
||||||
|
| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid` (keyed by `crop.x`, `crop.y` or `crop.size`; `errorCodeFor('crop')` finds it), else `validation_failed` |
|
||||||
|
| 429 | `too_many_requests` |
|
||||||
|
|
||||||
|
Branch on the codes, the messages are for people and may change.
|
||||||
|
|
||||||
|
### Reading a user by ID (trusted apps)
|
||||||
|
|
||||||
|
[Trusted apps](#trusted-apps) can read the profile of a user connected to them (a token of the
|
||||||
|
user, a subscription or an order of the app) with the app's own token, e.g. to catch up on a
|
||||||
|
missed `user.updated`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$profile = app(AnikeenId::class)->findUserById($user->anikeen_id); // null: unknown, deleted or not connected
|
||||||
|
|
||||||
|
$profile?->name();
|
||||||
|
$profile?->email(); // returned even while unverified
|
||||||
|
$profile?->emailVerified();
|
||||||
|
$profile?->avatar();
|
||||||
|
$profile?->version(); // compare with the version of user.updated
|
||||||
|
$profile?->locale; // also username, created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429
|
||||||
|
`too_many_requests` and other failures. The profile carries no billing or account data; read those
|
||||||
|
through their own endpoints.
|
||||||
|
|
||||||
## Preferences
|
## Preferences
|
||||||
|
|
||||||
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they
|
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they
|
||||||
@@ -1205,7 +1353,9 @@ Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference()
|
|||||||
on the model instance (one request per instance); `setGlobalPreference()` and
|
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
|
`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
|
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:
|
after about an hour, since other apps and the user can change shared preferences too (trusted
|
||||||
|
apps can select the [`preference.updated`](#user-and-preference-events) webhook to drop it
|
||||||
|
sooner):
|
||||||
|
|
||||||
```php
|
```php
|
||||||
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
|
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
|
||||||
|
|||||||
@@ -397,6 +397,32 @@ class AnikeenId
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a multipart/form-data request (file uploads), with the same headers and error handling
|
||||||
|
* as request(), which stays JSON only.
|
||||||
|
*
|
||||||
|
* @param list<array{name: string, contents: mixed, filename?: string, headers?: array<string, string>}> $multipart
|
||||||
|
* Guzzle's multipart parts
|
||||||
|
* @throws GuzzleException
|
||||||
|
* @throws RequestRequiresClientIdException
|
||||||
|
*/
|
||||||
|
public function requestMultipart(string $method, string $path, array $multipart): Result
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = $this->client->request($method, $path, [
|
||||||
|
// no Content-Type: Guzzle sets it with the boundary
|
||||||
|
'headers' => $this->buildHeaders(),
|
||||||
|
'multipart' => $multipart,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = new Result($response, null, $this);
|
||||||
|
} catch (RequestException $exception) {
|
||||||
|
$result = new Result($exception->getResponse(), $exception, $this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build headers for request.
|
* Build headers for request.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,8 +2,19 @@
|
|||||||
|
|
||||||
namespace Anikeen\Id\Concerns;
|
namespace Anikeen\Id\Concerns;
|
||||||
|
|
||||||
|
use Anikeen\Id\Exceptions\ApiException;
|
||||||
|
use Anikeen\Id\Exceptions\ValidationFailedException;
|
||||||
|
use Anikeen\Id\Resources\User;
|
||||||
|
use Anikeen\Id\Result;
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use GuzzleHttp\Psr7\Utils;
|
||||||
|
use SplFileInfo;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's own profile, with the user's token. Changes need the scope `user` (`user:read`
|
||||||
|
* only reads; 403 "missing_scope").
|
||||||
|
*/
|
||||||
trait ManagesProfile
|
trait ManagesProfile
|
||||||
{
|
{
|
||||||
use HasBillable;
|
use HasBillable;
|
||||||
@@ -23,4 +34,100 @@ trait ManagesProfile
|
|||||||
'options' => $options,
|
'options' => $options,
|
||||||
])->data->url;
|
])->data->url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the user's profile (scope `user`). Only the fields sent change. A new email address
|
||||||
|
* doesn't replace the current one: Anikeen ID mails a confirmation link to it, and it becomes
|
||||||
|
* the user's address once they confirm it signed in to Anikeen ID (see pendingEmail() of the
|
||||||
|
* result; user.updated follows then). Clears the cached getUserData().
|
||||||
|
*
|
||||||
|
* @param array{
|
||||||
|
* name?: string,
|
||||||
|
* username?: string,
|
||||||
|
* email?: string
|
||||||
|
* } $attributes The fields to change
|
||||||
|
* - name: The display name
|
||||||
|
* - username: The username (unique regardless of case)
|
||||||
|
* - email: A new email address (lower-cased; needs the user's confirmation)
|
||||||
|
* @return User the user as `GET v1/user` returns them, plus `pending_email` and
|
||||||
|
* `pending_email_expires_at`
|
||||||
|
* @throws ValidationFailedException 422 with the codes "name_invalid", "username_taken",
|
||||||
|
* "username_invalid", "email_taken", "email_invalid" (errorCodeFor($field))
|
||||||
|
* @throws ApiException 401 "unauthenticated", 403 "missing_scope", "user_token_required",
|
||||||
|
* 429 "too_many_requests" (30 a minute per app and user, new email addresses 5 an hour)
|
||||||
|
*/
|
||||||
|
public function updateProfile(array $attributes): User
|
||||||
|
{
|
||||||
|
$anikeenId = $this->anikeenId();
|
||||||
|
|
||||||
|
return $this->sendProfileRequest(fn () => $anikeenId->request('PATCH', 'v1/user/profile', $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a new profile picture (scope `user`): jpeg, png, webp or gif, at most 5 MB, 64 to
|
||||||
|
* 8000 pixels per side. The avatar URL stays the same, the picture behind it changes. Clears
|
||||||
|
* the cached getUserData().
|
||||||
|
*
|
||||||
|
* @param string|SplFileInfo $file a path or a file, e.g. an UploadedFile
|
||||||
|
* @param array{x: int, y: int, size: int}|null $crop the square to use, in pixels of the
|
||||||
|
* upright picture; the centre without one
|
||||||
|
* @throws ValidationFailedException 422 with the codes "avatar_invalid", "avatar_too_large",
|
||||||
|
* "crop_invalid" (keyed by crop.x, crop.y or crop.size; errorCodeFor('crop') finds them)
|
||||||
|
* @throws ApiException 413 "avatar_too_large", 401, 403 "missing_scope", "user_token_required",
|
||||||
|
* 429 "too_many_requests" (also more than 10 uploads an hour)
|
||||||
|
* @throws \RuntimeException when the file can't be opened (nothing is sent then)
|
||||||
|
*/
|
||||||
|
public function uploadAvatar(string|SplFileInfo $file, ?array $crop = null): User
|
||||||
|
{
|
||||||
|
$path = $file instanceof SplFileInfo ? $file->getPathname() : $file;
|
||||||
|
$filename = $file instanceof SplFileInfo && method_exists($file, 'getClientOriginalName')
|
||||||
|
? $file->getClientOriginalName()
|
||||||
|
: basename($path);
|
||||||
|
|
||||||
|
$multipart = [['name' => 'avatar', 'contents' => Utils::tryFopen($path, 'r'), 'filename' => $filename]];
|
||||||
|
|
||||||
|
foreach (['x', 'y', 'size'] as $key) {
|
||||||
|
if (isset($crop[$key])) {
|
||||||
|
$multipart[] = ['name' => "crop[{$key}]", 'contents' => (string) $crop[$key]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$anikeenId = $this->anikeenId();
|
||||||
|
|
||||||
|
return $this->sendProfileRequest(fn () => $anikeenId->requestMultipart('POST', 'v1/user/profile/avatar', $multipart));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the uploaded profile picture (scope `user`); the initials are shown again when it
|
||||||
|
* was the one shown. Clears the cached getUserData().
|
||||||
|
*
|
||||||
|
* @throws ApiException 401, 403 "missing_scope", "user_token_required", 429 "too_many_requests"
|
||||||
|
*/
|
||||||
|
public function removeAvatar(): User
|
||||||
|
{
|
||||||
|
$anikeenId = $this->anikeenId();
|
||||||
|
|
||||||
|
return $this->sendProfileRequest(fn () => $anikeenId->request('DELETE', 'v1/user/profile/avatar'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable(): Result $send
|
||||||
|
* @throws ApiException
|
||||||
|
*/
|
||||||
|
private function sendProfileRequest(callable $send): User
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $send();
|
||||||
|
} catch (GuzzleException $e) {
|
||||||
|
throw ApiException::unreachable($e);
|
||||||
|
} finally {
|
||||||
|
$this->userDataCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$result->success()) {
|
||||||
|
throw ApiException::fromResult($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new User($result->data);
|
||||||
|
}
|
||||||
}␍
|
}␍
|
||||||
@@ -6,6 +6,7 @@ namespace Anikeen\Id\Concerns;
|
|||||||
use Anikeen\Id\ApiOperations\Get;
|
use Anikeen\Id\ApiOperations\Get;
|
||||||
use Anikeen\Id\ApiOperations\Post;
|
use Anikeen\Id\ApiOperations\Post;
|
||||||
use Anikeen\Id\Exceptions\ApiException;
|
use Anikeen\Id\Exceptions\ApiException;
|
||||||
|
use Anikeen\Id\Resources\User;
|
||||||
use Anikeen\Id\Result;
|
use Anikeen\Id\Result;
|
||||||
use Anikeen\Id\Support\AppRequest;
|
use Anikeen\Id\Support\AppRequest;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@@ -95,6 +96,30 @@ trait ManagesUsers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile of a user connected to your app, by their Anikeen ID (trusted apps, app token),
|
||||||
|
* e.g. to catch up on a missed user.updated webhook. The email address is returned even while
|
||||||
|
* it isn't verified (emailVerified() is false then). No scope needed.
|
||||||
|
*
|
||||||
|
* @return User|null null when the user is unknown, deleted or not connected to your app
|
||||||
|
* (404 "user_not_found"; the three look the same)
|
||||||
|
* @throws ApiException 403 "client_not_allowed" (not a trusted app), 429 "too_many_requests",
|
||||||
|
* or another failure
|
||||||
|
*/
|
||||||
|
public function findUserById(string $userId): ?User
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return new User(AppRequest::send('GET', sprintf('v1/client/users/%s', rawurlencode($userId)))->data);
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
// only Anikeen ID's own answer means "no such user", not e.g. an unknown route
|
||||||
|
if ($e->isNotFound() && $e->error === 'user_not_found') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the user of a social login or create it (trusted apps, app token).
|
* Find the user of a social login or create it (trusted apps, app token).
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ class ApiException extends ResourceException
|
|||||||
return PreconditionFailedException::fromResult($result);
|
return PreconditionFailedException::fromResult($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refused input, with per-field codes on some endpoints
|
||||||
|
if ($status === 422 && static::class === self::class) {
|
||||||
|
return ValidationFailedException::fromResult($result);
|
||||||
|
}
|
||||||
|
|
||||||
return new static(
|
return new static(
|
||||||
rtrim((string) ($body->message ?? $result->error()), '.'),
|
rtrim((string) ($body->message ?? $result->error()), '.'),
|
||||||
$status,
|
$status,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anikeen ID refused the input (422). `error` is the first code; endpoints with per-field codes
|
||||||
|
* (the profile API) send them as `codes`: `{field: code}`, e.g. `["username" => "username_taken"]`.
|
||||||
|
* Branch on the codes, the messages are for people and may change.
|
||||||
|
*/
|
||||||
|
class ValidationFailedException extends ApiException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, string> field => code; empty when the endpoint sends none
|
||||||
|
*/
|
||||||
|
public function errorCodes(): array
|
||||||
|
{
|
||||||
|
$codes = $this->body()->codes ?? null;
|
||||||
|
|
||||||
|
return is_object($codes) || is_array($codes) ? array_map('strval', (array) $codes) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The code of $field, or of its first failed sub-field when the codes are keyed by those
|
||||||
|
* (`crop` finds `crop.x`, `crop.y` or `crop.size`).
|
||||||
|
*/
|
||||||
|
public function errorCodeFor(string $field): ?string
|
||||||
|
{
|
||||||
|
$codes = $this->errorCodes();
|
||||||
|
|
||||||
|
if (isset($codes[$field])) {
|
||||||
|
return $codes[$field];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($codes as $key => $code) {
|
||||||
|
if (str_starts_with((string) $key, $field . '.')) {
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any field failed with $code, or the response's `error` is $code.
|
||||||
|
*/
|
||||||
|
public function hasErrorCode(string $code): bool
|
||||||
|
{
|
||||||
|
return $this->error === $code || in_array($code, $this->errorCodes(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The messages per field (`errors`), for showing them to people.
|
||||||
|
*
|
||||||
|
* @return array<string, list<string>>
|
||||||
|
*/
|
||||||
|
public function errors(): array
|
||||||
|
{
|
||||||
|
$errors = $this->body()->errors ?? null;
|
||||||
|
|
||||||
|
return is_object($errors) || is_array($errors) ? array_map(fn ($messages) => array_values((array) $messages), (array) $errors) : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Resources;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use JsonSerializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user's profile: AnikeenId::findUserById(), the result of the profile updates on Billable
|
||||||
|
* (updateProfile(), uploadAvatar(), removeAvatar()) and the `data.object` of a user.updated
|
||||||
|
* webhook (`new User($event['data']['object'])`).
|
||||||
|
*
|
||||||
|
* The REST shapes carry `email_verified_at` and `profile_version`, the webhook `email_verified`
|
||||||
|
* and `version`; emailVerified() and version() read either. The profile updates also return the
|
||||||
|
* other fields of `GET v1/user` plus `pending_email` and `pending_email_expires_at`.
|
||||||
|
*
|
||||||
|
* @property string $id
|
||||||
|
* @property ?string $name
|
||||||
|
* @property ?string $username
|
||||||
|
* @property ?string $email in a webhook only once verified (null while it isn't)
|
||||||
|
* @property ?string $email_verified_at REST shapes
|
||||||
|
* @property bool $email_verified webhook
|
||||||
|
* @property ?string $avatar a stable URL; the picture behind it can change
|
||||||
|
* @property ?string $locale REST shapes
|
||||||
|
* @property int $profile_version REST shapes
|
||||||
|
* @property int $version webhook
|
||||||
|
* @property ?string $created_at REST shapes
|
||||||
|
* @property ?string $pending_email profile updates only: a new address waiting for confirmation
|
||||||
|
* @property ?string $pending_email_expires_at profile updates only
|
||||||
|
*/
|
||||||
|
class User implements JsonSerializable
|
||||||
|
{
|
||||||
|
protected object $data;
|
||||||
|
|
||||||
|
public function __construct(object|array $data)
|
||||||
|
{
|
||||||
|
$this->data = (object) $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __get(string $name): mixed
|
||||||
|
{
|
||||||
|
return $this->data->{$name} ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __isset(string $name): bool
|
||||||
|
{
|
||||||
|
return isset($this->data->{$name});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): string
|
||||||
|
{
|
||||||
|
return (string) ($this->data->id ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): ?string
|
||||||
|
{
|
||||||
|
return $this->data->name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function username(): ?string
|
||||||
|
{
|
||||||
|
return $this->data->username ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function email(): ?string
|
||||||
|
{
|
||||||
|
return $this->data->email ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From `email_verified` (webhooks) or `email_verified_at` (REST shapes).
|
||||||
|
*/
|
||||||
|
public function emailVerified(): bool
|
||||||
|
{
|
||||||
|
if (isset($this->data->email_verified)) {
|
||||||
|
return (bool) $this->data->email_verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !empty($this->data->email_verified_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL of the profile picture. It stays the same when the picture changes (user.updated
|
||||||
|
* with `avatar` in `changed`): drop a cached copy then.
|
||||||
|
*/
|
||||||
|
public function avatar(): ?string
|
||||||
|
{
|
||||||
|
return $this->data->avatar ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grows with every change apps see (`profile_version` in REST shapes, `version` in webhooks):
|
||||||
|
* keep the highest one seen and ignore older data.
|
||||||
|
*/
|
||||||
|
public function version(): int
|
||||||
|
{
|
||||||
|
return (int) ($this->data->profile_version ?? $this->data->version ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new email address waiting for the user's confirmation (profile updates only); `email`
|
||||||
|
* stays the current address until then.
|
||||||
|
*/
|
||||||
|
public function pendingEmail(): ?string
|
||||||
|
{
|
||||||
|
return $this->data->pending_email ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pendingEmailExpiresAt(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return !empty($this->data->pending_email_expires_at) ? new DateTimeImmutable($this->data->pending_email_expires_at) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return (array) $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return $this->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use Anikeen\Id\AnikeenId;
|
|||||||
use Anikeen\Id\Contracts\AppTokenRepository;
|
use Anikeen\Id\Contracts\AppTokenRepository;
|
||||||
use Anikeen\Id\Exceptions\ApiException;
|
use Anikeen\Id\Exceptions\ApiException;
|
||||||
use Anikeen\Id\Exceptions\PreconditionFailedException;
|
use Anikeen\Id\Exceptions\PreconditionFailedException;
|
||||||
|
use Anikeen\Id\Exceptions\ValidationFailedException;
|
||||||
use Anikeen\Id\Result;
|
use Anikeen\Id\Result;
|
||||||
use GuzzleHttp\Exception\GuzzleException;
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@@ -13,7 +14,7 @@ use Throwable;
|
|||||||
/**
|
/**
|
||||||
* Sends a request with the app's own token (client credentials). A rejected token is renewed
|
* Sends a request with the app's own token (client credentials). A rejected token is renewed
|
||||||
* once; every failure ends in an ApiException with status and error code (a 412 in a
|
* once; every failure ends in an ApiException with status and error code (a 412 in a
|
||||||
* PreconditionFailedException).
|
* PreconditionFailedException, a 422 in a ValidationFailedException).
|
||||||
*/
|
*/
|
||||||
final class AppRequest
|
final class AppRequest
|
||||||
{
|
{
|
||||||
@@ -36,7 +37,11 @@ final class AppRequest
|
|||||||
|
|
||||||
if (!$result->success()) {
|
if (!$result->success()) {
|
||||||
$body = is_object($result->data) ? $result->data : null;
|
$body = is_object($result->data) ? $result->data : null;
|
||||||
$exception = $result->status === 412 ? PreconditionFailedException::class : ApiException::class;
|
$exception = match ($result->status) {
|
||||||
|
412 => PreconditionFailedException::class,
|
||||||
|
422 => ValidationFailedException::class,
|
||||||
|
default => ApiException::class,
|
||||||
|
};
|
||||||
|
|
||||||
throw new $exception(
|
throw new $exception(
|
||||||
sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()),
|
sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()),
|
||||||
|
|||||||
@@ -76,6 +76,34 @@ final class EventType
|
|||||||
*/
|
*/
|
||||||
public const BILLING_LIMIT_AVAILABLE = 'billing_limit.available';
|
public const BILLING_LIMIT_AVAILABLE = 'billing_limit.available';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preference of a user was set, changed or deleted. Thin, never the value: `data.object` is
|
||||||
|
* `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by:
|
||||||
|
* {type: "user"|"app", client_id}}`; fetch the preference again (or drop your copy when
|
||||||
|
* `deleted`) and ignore events with a lower version than the one you hold. Opt-in (not through
|
||||||
|
* `*`); only to trusted apps that may read the namespace, never to the app that made the change.
|
||||||
|
*/
|
||||||
|
public const PREFERENCE_UPDATED = 'preference.updated';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile of a user connected to your app changed: name, username, the verified email
|
||||||
|
* address, email_verified or the avatar. `data.object` is the full profile `{object: "user", id,
|
||||||
|
* name, username, email, email_verified, avatar, version}` (Resources\User), `data.changed` names
|
||||||
|
* the changed fields (never their old values). An email address appears only once verified
|
||||||
|
* (`email` is null while it isn't); `avatar` in `changed` means the picture behind the stable URL
|
||||||
|
* changed. Keep the highest `version` and ignore older events. Opt-in (not through `*`); also
|
||||||
|
* sent to the app that made the change.
|
||||||
|
*/
|
||||||
|
public const USER_UPDATED = 'user.updated';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user connected to your app deleted their account (or it was deleted): delete or anonymise
|
||||||
|
* it and treat it as final. `data.object` is `{object: "user", id, deleted: true, deleted_at}`.
|
||||||
|
* Sent once; no other event about the user follows. A later user.updated for the same ID means
|
||||||
|
* the account was restored. Opt-in (not through `*`).
|
||||||
|
*/
|
||||||
|
public const USER_DELETED = 'user.deleted';
|
||||||
|
|
||||||
public const TEST = 'webhook.test';
|
public const TEST = 'webhook.test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Tests;
|
||||||
|
|
||||||
|
use Anikeen\Id\AnikeenId;
|
||||||
|
use Anikeen\Id\Exceptions\ApiException;
|
||||||
|
use Anikeen\Id\Resources\User;
|
||||||
|
use Anikeen\Id\Webhooks\EventType;
|
||||||
|
|
||||||
|
class UserByIdTest extends TestCase
|
||||||
|
{
|
||||||
|
private function sentCall(int $index): string
|
||||||
|
{
|
||||||
|
$request = $this->sent[$index]['request'];
|
||||||
|
|
||||||
|
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function profile(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return ['data' => [
|
||||||
|
'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', 'email' => 'ada@example.com',
|
||||||
|
'email_verified_at' => '2026-11-01T10:00:00.000000Z', 'avatar' => 'https://id.test/avatars/01JUSER',
|
||||||
|
'locale' => 'en', 'profile_version' => 8, 'created_at' => '2026-01-01T00:00:00.000000Z',
|
||||||
|
...$overrides,
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_find_user_by_id_reads_a_connected_user_with_the_app_token(): void
|
||||||
|
{
|
||||||
|
$this->respond(200, $this->profile());
|
||||||
|
|
||||||
|
$user = app(AnikeenId::class)->findUserById('01JUSER');
|
||||||
|
|
||||||
|
$this->assertSame('GET /api/v1/client/users/01JUSER Bearer app-token', $this->sentCall(0));
|
||||||
|
$this->assertInstanceOf(User::class, $user);
|
||||||
|
$this->assertSame('01JUSER', $user->id());
|
||||||
|
$this->assertSame('Ada King', $user->name());
|
||||||
|
$this->assertSame('ada', $user->username());
|
||||||
|
$this->assertSame('ada@example.com', $user->email());
|
||||||
|
$this->assertTrue($user->emailVerified());
|
||||||
|
$this->assertSame('https://id.test/avatars/01JUSER', $user->avatar());
|
||||||
|
$this->assertSame(8, $user->version());
|
||||||
|
$this->assertSame('en', $user->locale);
|
||||||
|
$this->assertNull($user->pendingEmail());
|
||||||
|
$this->assertSame('Ada King', $user->jsonSerialize()['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_find_user_by_id_encodes_the_id(): void
|
||||||
|
{
|
||||||
|
$this->respond(200, $this->profile());
|
||||||
|
|
||||||
|
app(AnikeenId::class)->findUserById('a/b');
|
||||||
|
|
||||||
|
$this->assertSame('/api/v1/client/users/a%2Fb', $this->sent[0]['request']->getUri()->getPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_find_user_by_id_returns_null_for_an_unknown_or_unconnected_user(): void
|
||||||
|
{
|
||||||
|
$this->respond(404, ['message' => 'User not found.', 'error' => 'user_not_found']);
|
||||||
|
|
||||||
|
$this->assertNull(app(AnikeenId::class)->findUserById('01JOTHER'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_find_user_by_id_throws_other_failures(): void
|
||||||
|
{
|
||||||
|
$this->respond(403, ['message' => 'Only trusted apps.', 'error' => 'client_not_allowed']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(AnikeenId::class)->findUserById('01JUSER');
|
||||||
|
$this->fail('No exception thrown');
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
$this->assertSame(403, $e->status);
|
||||||
|
$this->assertSame('client_not_allowed', $e->error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a 404 without Anikeen ID's code (e.g. an older release without the route) isn't "no user"
|
||||||
|
$this->respond(404, ['message' => 'Not Found']);
|
||||||
|
$this->expectException(ApiException::class);
|
||||||
|
app(AnikeenId::class)->findUserById('01JUSER');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_unverified_address_is_not_verified(): void
|
||||||
|
{
|
||||||
|
$this->respond(200, $this->profile(['email_verified_at' => null]));
|
||||||
|
|
||||||
|
$this->assertFalse(app(AnikeenId::class)->findUserById('01JUSER')->emailVerified());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_user_updated_webhook_object_is_a_user(): void
|
||||||
|
{
|
||||||
|
$user = new User(['object' => 'user', 'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada',
|
||||||
|
'email' => null, 'email_verified' => false, 'avatar' => 'https://id.test/avatars/01JUSER', 'version' => 9]);
|
||||||
|
|
||||||
|
$this->assertSame(9, $user->version());
|
||||||
|
$this->assertFalse($user->emailVerified());
|
||||||
|
$this->assertNull($user->email());
|
||||||
|
$this->assertSame('user', $user->object);
|
||||||
|
$this->assertSame('user.updated', EventType::USER_UPDATED);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Tests;
|
||||||
|
|
||||||
|
use Anikeen\Id\Billable;
|
||||||
|
use Anikeen\Id\Exceptions\ApiException;
|
||||||
|
use Anikeen\Id\Exceptions\ValidationFailedException;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use GuzzleHttp\Exception\ConnectException;
|
||||||
|
use GuzzleHttp\Psr7\Request;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class UserProfileTest 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 sentCall(int $index): string
|
||||||
|
{
|
||||||
|
$request = $this->sent[$index]['request'];
|
||||||
|
|
||||||
|
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function profile(array $overrides = []): array
|
||||||
|
{
|
||||||
|
return ['data' => [
|
||||||
|
'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', 'email' => 'ada@example.com',
|
||||||
|
'email_verified_at' => '2026-11-01T10:00:00.000000Z', 'avatar' => 'https://id.test/avatars/01JUSER',
|
||||||
|
'locale' => 'en', 'profile_version' => 8, 'created_at' => '2026-01-01T00:00:00.000000Z',
|
||||||
|
...$overrides,
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validationError(array $codes): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'message' => 'The given data was invalid.',
|
||||||
|
'error' => array_values($codes)[0],
|
||||||
|
'errors' => array_map(fn () => ['The value is invalid.'], $codes),
|
||||||
|
'codes' => $codes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_profile_patches_the_fields_with_the_user_token(): void
|
||||||
|
{
|
||||||
|
$this->respond(200, $this->profile(['name' => 'Ada Lovelace', 'profile_version' => 9,
|
||||||
|
'pending_email' => 'new@example.com', 'pending_email_expires_at' => '2026-11-07T11:00:00.000000Z']));
|
||||||
|
|
||||||
|
$user = $this->owner()->updateProfile(['name' => 'Ada Lovelace', 'email' => 'new@example.com']);
|
||||||
|
|
||||||
|
$this->assertSame('PATCH /api/v1/user/profile Bearer user-token', $this->sentCall(0));
|
||||||
|
$this->assertSame(['name' => 'Ada Lovelace', 'email' => 'new@example.com'], $this->sentJson(0));
|
||||||
|
$this->assertSame('Ada Lovelace', $user->name());
|
||||||
|
$this->assertSame('ada@example.com', $user->email());
|
||||||
|
$this->assertSame('new@example.com', $user->pendingEmail());
|
||||||
|
$this->assertEquals(new DateTimeImmutable('2026-11-07T11:00:00Z'), $user->pendingEmailExpiresAt());
|
||||||
|
$this->assertSame(9, $user->version());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_profile_clears_the_cached_user_data(): void
|
||||||
|
{
|
||||||
|
$owner = $this->owner();
|
||||||
|
$this->respond(200, $this->profile());
|
||||||
|
$this->respond(200, $this->profile(['name' => 'Ada Lovelace']));
|
||||||
|
$this->respond(200, $this->profile(['name' => 'Ada Lovelace']));
|
||||||
|
|
||||||
|
$this->assertSame('Ada King', $owner->getUserData()->name);
|
||||||
|
$owner->updateProfile(['name' => 'Ada Lovelace']);
|
||||||
|
|
||||||
|
$this->assertSame('Ada Lovelace', $owner->getUserData()->name);
|
||||||
|
$this->assertCount(3, $this->sent);
|
||||||
|
$this->assertSame('GET /api/v1/user Bearer user-token', $this->sentCall(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_refused_profile_changes_carry_codes_per_field(): void
|
||||||
|
{
|
||||||
|
$this->respond(422, $this->validationError(['username' => 'username_taken', 'email' => 'email_invalid']));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->owner()->updateProfile(['username' => 'taken', 'email' => 'nope']);
|
||||||
|
$this->fail('No exception thrown');
|
||||||
|
} catch (ValidationFailedException $e) {
|
||||||
|
$this->assertSame(422, $e->status);
|
||||||
|
$this->assertSame('username_taken', $e->error);
|
||||||
|
$this->assertSame(['username' => 'username_taken', 'email' => 'email_invalid'], $e->errorCodes());
|
||||||
|
$this->assertSame('email_invalid', $e->errorCodeFor('email'));
|
||||||
|
$this->assertNull($e->errorCodeFor('name'));
|
||||||
|
$this->assertTrue($e->hasErrorCode('email_invalid'));
|
||||||
|
$this->assertFalse($e->hasErrorCode('email_taken'));
|
||||||
|
$this->assertSame(['The value is invalid.'], $e->errors()['username']);
|
||||||
|
$this->assertInstanceOf(ApiException::class, $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_client_credentials_token_is_refused(): void
|
||||||
|
{
|
||||||
|
$this->respond(403, ['message' => 'This endpoint needs an access token of the user.', 'error' => 'user_token_required']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->owner()->updateProfile(['name' => 'Ada']);
|
||||||
|
$this->fail('No exception thrown');
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
$this->assertNotInstanceOf(ValidationFailedException::class, $e);
|
||||||
|
$this->assertSame('user_token_required', $e->error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_unreachable_service_is_an_api_exception(): void
|
||||||
|
{
|
||||||
|
$this->http->append(new ConnectException('Connection refused', new Request('PATCH', 'v1/user/profile')));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->owner()->updateProfile(['name' => 'Ada']);
|
||||||
|
$this->fail('No exception thrown');
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
$this->assertSame(0, $e->status);
|
||||||
|
$this->assertTrue($e->isTemporary());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_upload_avatar_sends_the_picture_as_multipart(): void
|
||||||
|
{
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'avatar');
|
||||||
|
file_put_contents($path, 'picture-bytes');
|
||||||
|
$this->respond(200, $this->profile(['profile_version' => 10]));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = $this->owner()->uploadAvatar($path, ['x' => 10, 'y' => 20, 'size' => 300]);
|
||||||
|
} finally {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->sent[0]['request'];
|
||||||
|
$body = (string) $request->getBody();
|
||||||
|
|
||||||
|
$this->assertSame('POST /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(0));
|
||||||
|
$this->assertStringStartsWith('multipart/form-data; boundary=', $request->getHeaderLine('Content-Type'));
|
||||||
|
$this->assertStringContainsString('name="avatar"; filename="' . basename($path) . '"', $body);
|
||||||
|
$this->assertStringContainsString('picture-bytes', $body);
|
||||||
|
$this->assertStringContainsString("name=\"crop[x]\"\r\nContent-Length: 2\r\n\r\n10", $body);
|
||||||
|
$this->assertStringContainsString('name="crop[y]"', $body);
|
||||||
|
$this->assertStringContainsString('name="crop[size]"', $body);
|
||||||
|
$this->assertSame(10, $user->version());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_upload_avatar_without_crop_sends_only_the_picture(): void
|
||||||
|
{
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'avatar');
|
||||||
|
file_put_contents($path, 'picture-bytes');
|
||||||
|
$this->respond(200, $this->profile());
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->owner()->uploadAvatar(new \SplFileInfo($path));
|
||||||
|
} finally {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = (string) $this->sent[0]['request']->getBody();
|
||||||
|
$this->assertStringContainsString('name="avatar"', $body);
|
||||||
|
$this->assertStringNotContainsString('crop', $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_refused_upload_carries_its_code(): void
|
||||||
|
{
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'avatar');
|
||||||
|
file_put_contents($path, 'not a picture');
|
||||||
|
$this->respond(422, $this->validationError(['avatar' => 'avatar_invalid']));
|
||||||
|
$this->respond(413, ['message' => 'The picture is too large.', 'error' => 'avatar_too_large']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
$this->owner()->uploadAvatar($path);
|
||||||
|
$this->fail('No exception thrown');
|
||||||
|
} catch (ValidationFailedException $e) {
|
||||||
|
$this->assertSame('avatar_invalid', $e->errorCodeFor('avatar'));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->owner()->uploadAvatar($path);
|
||||||
|
$this->fail('No exception thrown');
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
$this->assertSame(413, $e->status);
|
||||||
|
$this->assertSame('avatar_too_large', $e->error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_crop_codes_are_found_under_the_crop_field(): void
|
||||||
|
{
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'avatar');
|
||||||
|
file_put_contents($path, 'a picture');
|
||||||
|
$this->respond(422, $this->validationError(['crop.size' => 'crop_invalid']));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->owner()->uploadAvatar($path, ['x' => 0, 'y' => 0]);
|
||||||
|
$this->fail('No exception thrown');
|
||||||
|
} catch (ValidationFailedException $e) {
|
||||||
|
$this->assertSame('crop_invalid', $e->errorCodeFor('crop'));
|
||||||
|
$this->assertSame('crop_invalid', $e->errorCodeFor('crop.size'));
|
||||||
|
$this->assertNull($e->errorCodeFor('cro'));
|
||||||
|
$this->assertNull($e->errorCodeFor('avatar'));
|
||||||
|
} finally {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_unreadable_file_throws_before_anything_is_sent(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->owner()->uploadAvatar(sys_get_temp_dir() . '/missing-avatar-' . uniqid() . '.png');
|
||||||
|
$this->fail('No exception thrown');
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
$this->assertNotInstanceOf(ApiException::class, $e);
|
||||||
|
$this->assertCount(0, $this->sent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_remove_avatar_deletes_the_upload_and_clears_the_cache(): void
|
||||||
|
{
|
||||||
|
$owner = $this->owner();
|
||||||
|
$this->respond(200, $this->profile());
|
||||||
|
$this->respond(200, $this->profile(['profile_version' => 11]));
|
||||||
|
$this->respond(200, $this->profile(['profile_version' => 11]));
|
||||||
|
|
||||||
|
$owner->getUserData();
|
||||||
|
$user = $owner->removeAvatar();
|
||||||
|
$owner->getUserData();
|
||||||
|
|
||||||
|
$this->assertSame('DELETE /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(1));
|
||||||
|
$this->assertSame(11, $user->version());
|
||||||
|
$this->assertCount(3, $this->sent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Tests;
|
||||||
|
|
||||||
|
use Anikeen\Id\AnikeenId;
|
||||||
|
use Anikeen\Id\Billable;
|
||||||
|
use Anikeen\Id\Exceptions\PreconditionFailedException;
|
||||||
|
use Anikeen\Id\Exceptions\ValidationFailedException;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ValidationFailedTest 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_422_of_app_requests_is_a_validation_exception(): void
|
||||||
|
{
|
||||||
|
$this->respond(422, ['message' => 'The value is too large.', 'error' => 'value_too_large']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'example', 'theme', str_repeat('x', 10));
|
||||||
|
$this->fail('No exception thrown');
|
||||||
|
} catch (ValidationFailedException $e) {
|
||||||
|
$this->assertSame('value_too_large', $e->error);
|
||||||
|
$this->assertSame([], $e->errorCodes());
|
||||||
|
$this->assertTrue($e->hasErrorCode('value_too_large'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_412_stays_a_precondition_failure(): void
|
||||||
|
{
|
||||||
|
$this->respond(412, ['message' => 'Version mismatch.', 'error' => 'preference_version_mismatch', 'current_version' => 3]);
|
||||||
|
|
||||||
|
$this->expectException(PreconditionFailedException::class);
|
||||||
|
$this->owner()->setGlobalPreference('appearance', 'dark', ifVersion: 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_422_of_user_requests_is_a_validation_exception(): void
|
||||||
|
{
|
||||||
|
$this->respond(422, ['message' => 'Unknown value.', 'error' => 'invalid_shared_value']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->owner()->setGlobalPreference('appearance', 'purple');
|
||||||
|
$this->fail('No exception thrown');
|
||||||
|
} catch (ValidationFailedException $e) {
|
||||||
|
$this->assertSame(422, $e->status);
|
||||||
|
$this->assertSame('invalid_shared_value', $e->error);
|
||||||
|
$this->assertSame([], $e->errorCodes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,16 @@ class WebhookTest extends TestCase
|
|||||||
$this->assertContains(EventType::BILLING_LIMIT_AVAILABLE, EventType::all());
|
$this->assertContains(EventType::BILLING_LIMIT_AVAILABLE, EventType::all());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_user_and_preference_events_have_their_own_types(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('preference.updated', EventType::PREFERENCE_UPDATED);
|
||||||
|
$this->assertSame('user.updated', EventType::USER_UPDATED);
|
||||||
|
$this->assertSame('user.deleted', EventType::USER_DELETED);
|
||||||
|
$this->assertContains(EventType::PREFERENCE_UPDATED, EventType::all());
|
||||||
|
$this->assertContains(EventType::USER_UPDATED, EventType::all());
|
||||||
|
$this->assertContains(EventType::USER_DELETED, EventType::all());
|
||||||
|
}
|
||||||
|
|
||||||
public function test_a_change_carries_the_previous_attributes(): void
|
public function test_a_change_carries_the_previous_attributes(): void
|
||||||
{
|
{
|
||||||
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [
|
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [
|
||||||
|
|||||||
Reference in new issue
Block a user