From 93967e53a28ee3239ece7f50ba3d08bd1b454845 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 20:38:05 +0200 Subject: [PATCH] docs: REST API cleanup design spec --- .../specs/2026-05-14-api-cleanup-design.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-14-api-cleanup-design.md diff --git a/docs/superpowers/specs/2026-05-14-api-cleanup-design.md b/docs/superpowers/specs/2026-05-14-api-cleanup-design.md new file mode 100644 index 0000000..a509ed8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-api-cleanup-design.md @@ -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` 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/`)