mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-07 16:18:33 +00:00
Read a connected user's profile by ID for trusted apps
This commit is contained in:
3 files changed
+249
No files matched your search
@@ -6,6 +6,7 @@ namespace Anikeen\Id\Concerns;
|
||||
use Anikeen\Id\ApiOperations\Get;
|
||||
use Anikeen\Id\ApiOperations\Post;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Resources\User;
|
||||
use Anikeen\Id\Result;
|
||||
use Anikeen\Id\Support\AppRequest;
|
||||
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).
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user