Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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.

Expand Down
24 changes: 24 additions & 0 deletions app/Http/Controllers/Api/UserFollowersController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Requests\ListApiUserFollowersRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Queries\UserConnectionsQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

final readonly class UserFollowersController
{
/**
* The people following a user, 30 a page as on the web. A signed request also gets whether its user follows each
* one, and whether each one follows them.
*/
public function index(ListApiUserFollowersRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserConnectionsQuery $connections): AnonymousResourceCollection
{
return UserResource::collection($connections->builder($user->followers(), $currentUser)->cursorPaginate(30));
}
}
24 changes: 24 additions & 0 deletions app/Http/Controllers/Api/UserFollowingController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Requests\ListApiUserFollowingRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Queries\UserConnectionsQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

final readonly class UserFollowingController
{
/**
* The people a user follows, 30 a page as on the web. A signed request also gets whether its user follows each
* one, and whether each one follows them.
*/
public function index(ListApiUserFollowingRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserConnectionsQuery $connections): AnonymousResourceCollection
{
return UserResource::collection($connections->builder($user->following(), $currentUser)->cursorPaginate(30));
}
}
38 changes: 38 additions & 0 deletions app/Http/Requests/ListApiUserFollowersRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Exceptions\ApiException;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;

final class ListApiUserFollowersRequest extends FormRequest
{
/**
* Anyone may list the people following a user, but the web shows neither of two people across a block the other's
* connections, so a signed request across one gets a code instead.
*/
public function authorize(): bool
{
$user = $this->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 [];
}
}
38 changes: 38 additions & 0 deletions app/Http/Requests/ListApiUserFollowingRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Exceptions\ApiException;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;

final class ListApiUserFollowingRequest extends FormRequest
{
/**
* Anyone may list the people a user follows, but the web shows neither of two people across a block the other's
* connections, so a signed request across one gets a code instead.
*/
public function authorize(): bool
{
$user = $this->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 [];
}
}
3 changes: 2 additions & 1 deletion app/Http/Resources/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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'),
];
}
}
33 changes: 33 additions & 0 deletions app/Queries/UserConnectionsQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Queries;

use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

final readonly class UserConnectionsQuery
{
/**
* A user's followers, or the people they follow, as the web lists them: the newest follow first. A viewer also
* gets whether they follow each one, and whether each one follows them.
*
* @param BelongsToMany<User, User> $connections
* @return BelongsToMany<User, User>
*/
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');
}
}
6 changes: 6 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
});
Expand Down
133 changes: 133 additions & 0 deletions tests/Feature/Controllers/Api/UserFollowersControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

declare(strict_types=1);

use App\Models\User;
use Illuminate\Support\Facades\DB;

it('lists the followers of a user to guests, newest follow first', function (): void {
$user = User::factory()->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();
});
Loading
Loading