docs: REST API cleanup design spec
This commit is contained in:
118
docs/superpowers/specs/2026-05-14-api-cleanup-design.md
Normal file
118
docs/superpowers/specs/2026-05-14-api-cleanup-design.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# REST API Cleanup Design
|
||||||
|
|
||||||
|
Clean up the REST API to be professional, consistent, and RESTful. No new features — only renames, unifications, and content negotiation.
|
||||||
|
|
||||||
|
## Route Changes
|
||||||
|
|
||||||
|
| Before | After | Reason |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| `GET /users/{username}/profile` | `GET /users/{username}` | content negotiation replaces the /profile workaround |
|
||||||
|
| `GET /federation/lookup?handle=` | `GET /users/lookup?handle=` | federation lookup belongs under /users |
|
||||||
|
| `POST /users/{id}/follow` | `POST /users/{username}/follow` | param was mislabelled; now also handles remote follows |
|
||||||
|
| `DELETE /users/{id}/follow` | `DELETE /users/{username}/follow` | param rename |
|
||||||
|
| `POST /users/{id}/block` | `POST /users/{username}/block` | param rename |
|
||||||
|
| `DELETE /users/{id}/block` | `DELETE /users/{username}/block` | param rename |
|
||||||
|
| `GET /users/{username}/follower-list` | `GET /users/{username}/followers` | verbose name |
|
||||||
|
| `GET /users/{username}/following-list` | `GET /users/{username}/following` | verbose name |
|
||||||
|
| `GET /users/me/following-list` | `GET /users/me/following` | verbose name |
|
||||||
|
| `POST /notifications/{id}/read` | `PATCH /notifications/{id}` | POST for state change → PATCH |
|
||||||
|
| `POST /notifications/read-all` | `PATCH /notifications` | POST bulk action → PATCH |
|
||||||
|
| `PUT /users/me` | removed | `PATCH /users/me` is sufficient |
|
||||||
|
| `POST /federation/follow` | removed | unified into `POST /users/{username}/follow` |
|
||||||
|
|
||||||
|
## Content Negotiation at `GET /users/{username}`
|
||||||
|
|
||||||
|
The AP router currently owns `/users/{username}` (returns `application/activity+json`). The REST profile was at `/users/{username}/profile` as a workaround.
|
||||||
|
|
||||||
|
**Solution:** Remove `/users/{username}` from the AP router. Add a single handler at `GET /users/{username}` in the REST router that checks the `Accept` header:
|
||||||
|
|
||||||
|
- `Accept: application/activity+json` → return AP actor JSON with `Content-Type: application/activity+json`
|
||||||
|
- Anything else → return `UserResponse` with `Content-Type: application/json`
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
|
||||||
|
Add `actor_json(&self, user_id: &UserId) -> Result<String, DomainError>` to `FederationActionPort` in domain. Implement in `ActivityPubService` by delegating to the existing `self.actor_json(&user_id.as_uuid().to_string())` inherent method.
|
||||||
|
|
||||||
|
The unified handler in `presentation/src/handlers/users.rs`:
|
||||||
|
1. Looks up user by username via `UserRepository` → 404 if not found
|
||||||
|
2. Checks `Accept` header
|
||||||
|
3. AP path: calls `s.federation.actor_json(&user.id)` → returns with `Content-Type: application/activity+json`
|
||||||
|
4. REST path: returns `UserResponse` as before
|
||||||
|
|
||||||
|
The AP router in `bootstrap/src/main.rs` no longer registers `/users/{username}`.
|
||||||
|
|
||||||
|
## Unified Follow at `POST /users/{username}/follow`
|
||||||
|
|
||||||
|
The handler detects whether `{username}` is a local user or a remote actor:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if username.contains('@') {
|
||||||
|
// Remote: e.g. "gabrielkaszewski@mastodon.social"
|
||||||
|
s.federation.follow_remote(&uid, &username).await?;
|
||||||
|
} else {
|
||||||
|
// Local: look up by username, call follow_user use case
|
||||||
|
let target = get_user_by_username(&*s.users, &username).await?;
|
||||||
|
follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /federation/follow` and `federation::follow_remote_handler` are deleted.
|
||||||
|
|
||||||
|
## Remote Actor Handle Format Fix
|
||||||
|
|
||||||
|
`lookup_actor` currently returns `handle: actor.username` (just `preferred_username`, e.g. `gabrielkaszewski`). Fix: return the full `user@domain` handle by extracting the domain from `actor.ap_id`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let domain = actor.ap_id.host_str().unwrap_or("");
|
||||||
|
let full_handle = format!("{}@{}", actor.username, domain);
|
||||||
|
// RemoteActor { handle: full_handle, ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
This means `RemoteActorResponse.handle` = `"gabrielkaszewski@mastodon.social"`, which the frontend passes directly to `POST /users/gabrielkaszewski@mastodon.social/follow`.
|
||||||
|
|
||||||
|
## Remote Unfollow Scope
|
||||||
|
|
||||||
|
`DELETE /users/{username}/follow` for a remote handle (contains `@`) is **out of scope**. The handler returns `501 Not Implemented` when `username` contains `@`. Remote unfollow requires an `Undo Follow` ActivityPub activity and is a separate feature.
|
||||||
|
|
||||||
|
## Notification Endpoints
|
||||||
|
|
||||||
|
Add `NotificationUpdateRequest { read: bool }` to `api-types/src/requests.rs`.
|
||||||
|
|
||||||
|
- `PATCH /notifications/{id}` — mark single notification read (body: `{"read": true}`)
|
||||||
|
- `PATCH /notifications` — mark all notifications read (body: `{"read": true}`)
|
||||||
|
|
||||||
|
Both replace their existing `POST` counterparts.
|
||||||
|
|
||||||
|
## Frontend (`thoughts-frontend/lib/api.ts`)
|
||||||
|
|
||||||
|
| Function | Change |
|
||||||
|
|----------|--------|
|
||||||
|
| `getUserProfile(username)` | URL: `/users/${username}/profile` → `/users/${username}` |
|
||||||
|
| `getFollowersList(username)` | URL: `/follower-list` → `/followers` |
|
||||||
|
| `getFollowingList(username)` | URL: `/following-list` → `/following` |
|
||||||
|
| `getMeFollowingList()` | URL: `/me/following-list` → `/me/following` |
|
||||||
|
| `lookupRemoteActor(handle)` | URL: `/federation/lookup?handle=` → `/users/lookup?handle=` |
|
||||||
|
| `followRemoteUser(handle)` | **Deleted** — use unified `followUser(handle)` instead |
|
||||||
|
| `markNotificationRead(id)` | **New** — `PATCH /notifications/{id}` with body `{"read":true}` (no prior frontend impl) |
|
||||||
|
| `markAllNotificationsRead()` | **New** — `PATCH /notifications` with body `{"read":true}` (no prior frontend impl) |
|
||||||
|
|
||||||
|
Also update `remote-user-card.tsx` to call `followUser(actor.handle, token)` instead of `followRemoteUser`.
|
||||||
|
|
||||||
|
## Files Touched
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `crates/domain/src/ports.rs` — add `actor_json` to `FederationActionPort`
|
||||||
|
- `crates/domain/src/testing.rs` — add `actor_json` to `TestStore` impl
|
||||||
|
- `crates/adapters/activitypub-base/src/service.rs` — add `actor_json` to `FederationActionPort` impl; fix `lookup_actor` handle format
|
||||||
|
- `crates/presentation/src/handlers/users.rs` — unified `GET /users/{username}` handler; remove old `get_user` (was /profile)
|
||||||
|
- `crates/presentation/src/handlers/social.rs` — unify `post_follow`; rename `{id}` → `{username}` in follow/block; rename follower/following list handlers
|
||||||
|
- `crates/presentation/src/handlers/federation.rs` — delete `follow_remote_handler`; move `lookup_handler` to `users.rs`; delete file if empty
|
||||||
|
- `crates/presentation/src/handlers/notifications.rs` — replace read handlers with PATCH
|
||||||
|
- `crates/presentation/src/routes.rs` — all route changes
|
||||||
|
- `crates/api-types/src/requests.rs` — add `NotificationUpdateRequest`
|
||||||
|
- `crates/bootstrap/src/main.rs` — remove `/users/{username}` from ap_router
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `thoughts-frontend/lib/api.ts` — all URL/method changes listed above
|
||||||
|
- `thoughts-frontend/components/remote-user-card.tsx` — use `followUser` instead of `followRemoteUser`
|
||||||
|
- Any page that calls `getFollowersList`, `getFollowingList`, `getMeFollowingList`, `markNotificationRead`, `markAllNotificationsRead` (check all pages under `app/`)
|
||||||
Reference in New Issue
Block a user