diff --git a/README.md b/README.md index cefac4989..fcc948e5b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ Read-only, no authentication, JSON, versioned under `/api/v1`. Only approved com |---|---| | `GET /api/v1/users/{username}` | A public profile with commit, follower, and following counts | | `GET /api/v1/users/{username}/commits` | The user's top-level commits, newest first, 20 per page (`?replies=1` for their replies instead) | +| `GET /api/v1/users/{username}/followers` | The people following the user, newest follow first, 30 per page | +| `GET /api/v1/users/{username}/following` | The people the user follows, newest follow first, 30 per page | | `GET /api/v1/commits/{id}` | One commit with its author, public parent id, star and reply counts, link preview (with its `embed` player, if any), and images (`id`, `url`, `width`, `height`) | Lists are cursor-paginated: follow `links.next` (or pass `meta.next_cursor` as `?cursor=`) until it is `null`. @@ -81,7 +83,7 @@ The mobile app signs its requests to `/api/v1` with an SSH key it keeps on the d Commits in a signed response carry what the web's cards show: `preview_segments`, the excerpt a list shows of a long commit (or `null`), the `poll` with the user's view of its votes and each option's `percentage`, the `quoted_commit` (a tombstone once deleted, or `null` when the user may not see it, while `quoted_commit_id` still says a commit was quoted), and in lists the `reply_preview` the web shows under each commit. Its `next_cursor` continues with `GET /api/v1/commits/{id}/replies?newest=1&cursor=…`, as on the web. -A signed profile also says `is_muting` and has the web's `achievements`, with the Unix rank. +A signed profile also says `is_muting` and has the web's `achievements`, with the Unix rank. In a signed list of followers or following, each person says `is_followed`, whether the user follows them, and `follows_you`. `POST` and `DELETE /api/v1/users/{username}/mute` mute and unmute someone, and `POST` and `DELETE /api/v1/users/{username}/block` block and unblock them, as the web does: nobody can mute or block themselves, muting is silent, and blocking ends the follows both ways, which unblocking doesn't bring back. Each answers with the user and the new `is_muting` or `is_blocking`. `GET /api/v1/me/muted` and `GET /api/v1/me/blocked` list the people the user mutes and blocks, last first, as Settings → Safety does, 20 a page. diff --git a/app/Http/Controllers/Api/UserFollowersController.php b/app/Http/Controllers/Api/UserFollowersController.php new file mode 100644 index 000000000..8e24dc7f0 --- /dev/null +++ b/app/Http/Controllers/Api/UserFollowersController.php @@ -0,0 +1,24 @@ +builder($user->followers(), $currentUser)->cursorPaginate(30)); + } +} diff --git a/app/Http/Controllers/Api/UserFollowingController.php b/app/Http/Controllers/Api/UserFollowingController.php new file mode 100644 index 000000000..4ffa7ace3 --- /dev/null +++ b/app/Http/Controllers/Api/UserFollowingController.php @@ -0,0 +1,24 @@ +builder($user->following(), $currentUser)->cursorPaginate(30)); + } +} diff --git a/app/Http/Requests/ListApiUserFollowersRequest.php b/app/Http/Requests/ListApiUserFollowersRequest.php new file mode 100644 index 000000000..2fbebfc33 --- /dev/null +++ b/app/Http/Requests/ListApiUserFollowersRequest.php @@ -0,0 +1,38 @@ +route('user'); + assert($user instanceof User); + + $viewer = $this->user(); + $blockerId = $viewer instanceof User ? $viewer->blockerIdWith($user) : null; + + throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id, shown: UserResource::make($user))); + + return true; + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/ListApiUserFollowingRequest.php b/app/Http/Requests/ListApiUserFollowingRequest.php new file mode 100644 index 000000000..420f76836 --- /dev/null +++ b/app/Http/Requests/ListApiUserFollowingRequest.php @@ -0,0 +1,38 @@ +route('user'); + assert($user instanceof User); + + $viewer = $this->user(); + $blockerId = $viewer instanceof User ? $viewer->blockerIdWith($user) : null; + + throw_if($blockerId !== null, fn (): ApiException => ApiException::acrossBlock($user, blockedByViewer: $blockerId === $viewer?->id, shown: UserResource::make($user))); + + return true; + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 9ae6827ce..312984023 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -10,7 +10,7 @@ /** * A user's public profile, with their counts when they were loaded. A signed request also gets the lane, the color - * the web draws them in. + * the web draws them in, and in a list of people, whether its user follows each one and whether each one follows them. * * @mixin User */ @@ -39,6 +39,7 @@ public function toArray(Request $request): array 'following_count' => $this->whenCounted('following'), 'lane' => $this->when($request->user() instanceof User, fn (): int|string => $this->lane()), 'is_followed' => $this->whenHas('is_followed'), + 'follows_you' => $this->whenHas('follows_you'), ]; } } diff --git a/app/Queries/UserConnectionsQuery.php b/app/Queries/UserConnectionsQuery.php new file mode 100644 index 000000000..ee9560b1b --- /dev/null +++ b/app/Queries/UserConnectionsQuery.php @@ -0,0 +1,33 @@ + $connections + * @return BelongsToMany + */ + public function builder(BelongsToMany $connections, ?User $viewer): BelongsToMany + { + if ($viewer instanceof User) { + $connections->withExists([ + 'followers as is_followed' => fn (Builder $query): Builder => $query->whereKey($viewer->id), + 'following as follows_you' => fn (Builder $query): Builder => $query->whereKey($viewer->id), + ]); + } + + return $connections + ->latest('follows.created_at') + ->latest('users.id'); + } +} diff --git a/routes/api.php b/routes/api.php index 3c96792f5..63a0249e2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,6 +18,8 @@ use App\Http\Controllers\Api\UserCommitController; use App\Http\Controllers\Api\UserController; use App\Http\Controllers\Api\UserFollowController; +use App\Http\Controllers\Api\UserFollowersController; +use App\Http\Controllers\Api\UserFollowingController; use App\Http\Controllers\Api\UserLaneController; use App\Http\Controllers\Api\UserMuteController; use App\Http\Controllers\Api\UserPasteisModeController; @@ -46,6 +48,10 @@ ->name('api-user.show'); Route::get('users/{user:username}/commits', [UserCommitController::class, 'index']) ->name('api-user-commit.index'); + Route::get('users/{user:username}/followers', [UserFollowersController::class, 'index']) + ->name('api-user-followers.index'); + Route::get('users/{user:username}/following', [UserFollowingController::class, 'index']) + ->name('api-user-following.index'); Route::get('commits/{commit}', [ApiCommitController::class, 'show']) ->name('api-commit.show'); }); diff --git a/tests/Feature/Controllers/Api/UserFollowersControllerTest.php b/tests/Feature/Controllers/Api/UserFollowersControllerTest.php new file mode 100644 index 000000000..e65d8512a --- /dev/null +++ b/tests/Feature/Controllers/Api/UserFollowersControllerTest.php @@ -0,0 +1,133 @@ +create(['username' => 'octocat']); + $earlier = User::factory()->create(['bio' => 'Ships on Fridays.']); + $later = User::factory()->create(); + $user->followers()->attach($earlier, ['created_at' => now()->subMinute()]); + $user->followers()->attach($later); + $user->following()->attach(User::factory()->create()); + + $this->getJson('/api/v1/users/octocat/followers') + ->assertOk() + ->assertJsonPath('data.*.id', [$later->id, $earlier->id]) + ->assertJsonPath('data.1.username', $earlier->username) + ->assertJsonPath('data.1.bio', 'Ships on Fridays.') + ->assertJsonMissingPath('data.0.lane') + ->assertJsonMissingPath('data.0.is_followed') + ->assertJsonMissingPath('data.0.follows_you') + ->assertJsonMissingPath('data.0.email') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('says for a signed request whether its user follows each follower, and whether each follows them', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $mutual = User::factory()->create(); + $followed = User::factory()->create(); + $stranger = User::factory()->create(); + $user->followers()->attach($stranger, ['created_at' => now()->subMinutes(2)]); + $user->followers()->attach($followed, ['created_at' => now()->subMinute()]); + $user->followers()->attach($mutual); + $viewer->following()->attach([$followed->id, $mutual->id]); + $viewer->followers()->attach($mutual); + + signedApiRequest($keyPair, 'GET', route('api-user-followers.index', $user)) + ->assertOk() + ->assertJsonPath('data.*.id', [$mutual->id, $followed->id, $stranger->id]) + ->assertJsonPath('data.*.is_followed', [true, true, false]) + ->assertJsonPath('data.*.follows_you', [true, false, false]) + ->assertJsonPath('data.0.lane', $mutual->lane()); +}); + +it('lists the signed-in user among the followers, following nobody', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $user->followers()->attach($viewer); + + signedApiRequest($keyPair, 'GET', route('api-user-followers.index', $user)) + ->assertOk() + ->assertJsonPath('data.*.id', [$viewer->id]) + ->assertJsonPath('data.0.is_followed', false) + ->assertJsonPath('data.0.follows_you', false); +}); + +it('pages through followers 30 at a time with a cursor', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + $followers = User::factory()->count(31)->create(); + + foreach ($followers as $index => $follower) { + $user->followers()->attach($follower, ['created_at' => now()->subMinutes($index)]); + } + + $first = signedApiRequest($keyPair, 'GET', route('api-user-followers.index', $user)) + ->assertOk() + ->assertJsonCount(30, 'data') + ->assertJsonPath('meta.per_page', 30); + + expect($first->json('meta.next_cursor'))->toBeString(); + + signedApiRequest($keyPair, 'GET', $first->json('links.next')) + ->assertOk() + ->assertJsonPath('data.*.id', [$followers->last()->id]) + ->assertJsonPath('meta.next_cursor', null); +}); + +it('loads a page of followers without a query per follower', function (): void { + $keyPair = apiKeyPairFor(User::factory()->create()); + $user = User::factory()->create(); + + $queries = function () use ($keyPair, $user): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + + signedApiRequest($keyPair, 'GET', route('api-user-followers.index', $user))->assertOk(); + + return count(DB::getQueryLog()); + }; + + // The first signed request also syncs the user's keys with GitHub... + $queries(); + + $user->followers()->attach(User::factory()->create()); + $withOne = $queries(); + + $user->followers()->attach(User::factory()->count(5)->create()); + + expect($queries())->toBe($withOne); +}); + +it('lists no followers of a profile across a block for a signed request', function (Closure $block, string $code, bool $showsUser): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $user->followers()->attach(User::factory()->create()); + + $block($viewer, $user); + + signedApiRequest($keyPair, 'GET', route('api-user-followers.index', $user)) + ->assertForbidden() + ->assertJsonPath('code', $code) + // Only the one who blocked is told who, for the blocked page. + ->assertJsonPath('user.id', $showsUser ? $user->id : null) + ->assertJsonMissingPath('data'); + + $this->getJson(route('api-user-followers.index', $user)) + ->assertOk() + ->assertJsonCount(1, 'data'); +})->with([ + 'blocking' => [fn (User $viewer, User $user) => $viewer->blocking()->attach($user), 'user_blocked', true], + 'blocked' => [fn (User $viewer, User $user) => $user->blocking()->attach($viewer), 'user_unavailable', false], +]); + +it('returns not found for unknown usernames', function (): void { + $this->getJson('/api/v1/users/missing/followers')->assertNotFound(); +}); diff --git a/tests/Feature/Controllers/Api/UserFollowingControllerTest.php b/tests/Feature/Controllers/Api/UserFollowingControllerTest.php new file mode 100644 index 000000000..ca2dd5d88 --- /dev/null +++ b/tests/Feature/Controllers/Api/UserFollowingControllerTest.php @@ -0,0 +1,87 @@ +create(['username' => 'octocat']); + $earlier = User::factory()->create(); + $later = User::factory()->create(); + $user->following()->attach($earlier, ['created_at' => now()->subMinute()]); + $user->following()->attach($later); + $user->followers()->attach(User::factory()->create()); + + $this->getJson('/api/v1/users/octocat/following') + ->assertOk() + ->assertJsonPath('data.*.id', [$later->id, $earlier->id]) + ->assertJsonMissingPath('data.0.lane') + ->assertJsonMissingPath('data.0.is_followed') + ->assertJsonMissingPath('data.0.follows_you') + ->assertJsonPath('meta.next_cursor', null); +}); + +it('says for a signed request whether its user follows each one, and whether each follows them', function (): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $mutual = User::factory()->create(); + $followed = User::factory()->create(); + $stranger = User::factory()->create(); + $user->following()->attach($stranger, ['created_at' => now()->subMinutes(2)]); + $user->following()->attach($followed, ['created_at' => now()->subMinute()]); + $user->following()->attach($mutual); + $viewer->following()->attach([$followed->id, $mutual->id]); + $viewer->followers()->attach($mutual); + + signedApiRequest($keyPair, 'GET', route('api-user-following.index', $user)) + ->assertOk() + ->assertJsonPath('data.*.id', [$mutual->id, $followed->id, $stranger->id]) + ->assertJsonPath('data.*.is_followed', [true, true, false]) + ->assertJsonPath('data.*.follows_you', [true, false, false]) + ->assertJsonPath('data.0.lane', $mutual->lane()); +}); + +it('pages through the people a user follows 30 at a time', function (): void { + $user = User::factory()->create(); + $followed = User::factory()->count(31)->create(); + + foreach ($followed as $index => $person) { + $user->following()->attach($person, ['created_at' => now()->subMinutes($index)]); + } + + $first = $this->getJson(route('api-user-following.index', $user)) + ->assertOk() + ->assertJsonCount(30, 'data'); + + $this->getJson($first->json('links.next')) + ->assertOk() + ->assertJsonPath('data.*.id', [$followed->last()->id]); +}); + +it('lists no one a profile follows across a block for a signed request', function (Closure $block, string $code, bool $showsUser): void { + $viewer = User::factory()->create(); + $keyPair = apiKeyPairFor($viewer); + $user = User::factory()->create(); + $user->following()->attach(User::factory()->create()); + + $block($viewer, $user); + + signedApiRequest($keyPair, 'GET', route('api-user-following.index', $user)) + ->assertForbidden() + ->assertJsonPath('code', $code) + // Only the one who blocked is told who, for the blocked page. + ->assertJsonPath('user.id', $showsUser ? $user->id : null) + ->assertJsonMissingPath('data'); + + $this->getJson(route('api-user-following.index', $user)) + ->assertOk() + ->assertJsonCount(1, 'data'); +})->with([ + 'blocking' => [fn (User $viewer, User $user) => $viewer->blocking()->attach($user), 'user_blocked', true], + 'blocked' => [fn (User $viewer, User $user) => $user->blocking()->attach($viewer), 'user_unavailable', false], +]); + +it('returns not found for unknown usernames', function (): void { + $this->getJson('/api/v1/users/missing/following')->assertNotFound(); +}); diff --git a/tests/Unit/Queries/UserConnectionsQueryTest.php b/tests/Unit/Queries/UserConnectionsQueryTest.php new file mode 100644 index 000000000..98bc52224 --- /dev/null +++ b/tests/Unit/Queries/UserConnectionsQueryTest.php @@ -0,0 +1,43 @@ +create(); + $earlier = User::factory()->create(); + $later = User::factory()->create(); + $user->followers()->attach($later); + $user->followers()->attach($earlier, ['created_at' => now()->subMinute()]); + + expect(resolve(UserConnectionsQuery::class)->builder($user->followers(), null)->get()->modelKeys()) + ->toBe([$later->id, $earlier->id]); +}); + +it('says whether the viewer follows each one, and whether each follows the viewer', function (): void { + $viewer = User::factory()->create(); + $user = User::factory()->create(); + $mutual = User::factory()->create(); + $stranger = User::factory()->create(); + $user->following()->attach($stranger, ['created_at' => now()->subMinute()]); + $user->following()->attach($mutual); + $viewer->following()->attach($mutual); + $mutual->following()->attach($viewer); + + $connections = resolve(UserConnectionsQuery::class)->builder($user->following(), $viewer)->get(); + + expect($connections->modelKeys())->toBe([$mutual->id, $stranger->id]) + ->and($connections->pluck('is_followed')->all())->toBe([true, false]) + ->and($connections->pluck('follows_you')->all())->toBe([true, false]); +}); + +it('says neither to a guest', function (): void { + $user = User::factory()->create(); + $user->following()->attach(User::factory()->create()); + + $connection = resolve(UserConnectionsQuery::class)->builder($user->following(), null)->sole(); + + expect($connection->getAttributes())->not->toHaveKeys(['is_followed', 'follows_you']); +});