33 KiB
REST API Cleanup Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Rename routes, unify local/remote follow, add content negotiation at GET /users/{username}, and switch notification state changes to PATCH — no new features, pure cleanup.
Architecture: The domain FederationActionPort gains actor_json so the presentation layer can serve AP actor JSON without depending on activitypub-base. Content negotiation happens in a single handler that inspects the Accept header. The unified follow handler detects @ in the path param to route local vs remote. All route string changes land in routes.rs and main.rs.
Tech Stack: Rust (axum, domain ports), Next.js 15 (App Router), TypeScript, Zod.
File Map
| Action | Path | Change |
|---|---|---|
| Modify | crates/domain/src/ports.rs |
Add actor_json to FederationActionPort |
| Modify | crates/domain/src/testing.rs |
Add actor_json to TestStore impl + test |
| Modify | crates/adapters/activitypub-base/src/service.rs |
Impl actor_json; fix handle format in lookup_actor |
| Modify | crates/api-types/src/requests.rs |
Add NotificationUpdateRequest; remove FollowRemoteRequest |
| Modify | crates/presentation/src/handlers/notifications.rs |
Replace POST handlers with PATCH |
| Modify | crates/presentation/src/handlers/users.rs |
Content negotiation in get_user; move lookup_handler from federation; rename get_me_following_list |
| Modify | crates/presentation/src/handlers/social.rs |
Unified post_follow; delete_follow rejects remote; fix OpenAPI {id}→{username} |
| Delete | crates/presentation/src/handlers/federation.rs |
Both handlers gone: lookup_handler → users.rs; follow_remote_handler → deleted |
| Modify | crates/presentation/src/handlers/mod.rs |
Remove pub mod federation; |
| Modify | crates/presentation/src/routes.rs |
All route string changes |
| Modify | crates/bootstrap/src/main.rs |
Remove /users/{username} from AP router |
| Modify | thoughts-frontend/lib/api.ts |
URL/method updates + new notification functions |
| Modify | thoughts-frontend/components/remote-user-card.tsx |
followRemoteUser → followUser |
Task 1: Domain — add actor_json to FederationActionPort
Files:
-
Modify:
crates/domain/src/ports.rs -
Modify:
crates/domain/src/testing.rs -
Step 1: Add
actor_jsonto the trait
Read crates/domain/src/ports.rs. In the FederationActionPort trait block, add the new method:
#[async_trait]
pub trait FederationActionPort: Send + Sync {
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError>;
}
- Step 2: Write the failing test
At the bottom of the federation_port_tests module in crates/domain/src/testing.rs, add:
#[tokio::test]
async fn test_store_actor_json_returns_not_found() {
let store = TestStore::default();
let err = store.actor_json(&UserId::new()).await.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
}
- Step 3: Run to see it fail
cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10
Expected: compile error — actor_json not in TestStore's FederationActionPort impl.
- Step 4: Implement
actor_jsononTestStore
In crates/domain/src/testing.rs, inside impl FederationActionPort for TestStore, add:
async fn actor_json(&self, _user_id: &UserId) -> Result<String, DomainError> {
Err(DomainError::NotFound)
}
- Step 5: Run tests to confirm pass
cd /mnt/drive/dev/thoughts && cargo test -p domain -- federation_port_tests 2>&1 | tail -10
Expected: all 3 tests pass.
- Step 6: Compile check
cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -5
- Step 7: Commit
cd /mnt/drive/dev/thoughts
git add crates/domain/src/ports.rs crates/domain/src/testing.rs
git commit -m "feat(domain): add actor_json to FederationActionPort"
Task 2: activitypub-base — implement actor_json + fix handle format
Files:
-
Modify:
crates/adapters/activitypub-base/src/service.rs -
Step 1: Add compile-time assert
In crates/adapters/activitypub-base/src/tests/service.rs, the existing _assert_impl_federation_action_port function will now fail to compile because actor_json is missing. Run to confirm:
cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -10
Expected: error about missing actor_json impl.
- Step 2: Implement
actor_jsonin theFederationActionPortimpl
Read crates/adapters/activitypub-base/src/service.rs. In the impl domain::ports::FederationActionPort for ActivityPubService block, add after follow_remote:
async fn actor_json(
&self,
user_id: &domain::value_objects::UserId,
) -> Result<String, domain::errors::DomainError> {
ActivityPubService::actor_json(self, &user_id.as_uuid().to_string())
.await
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
}
Note: ActivityPubService::actor_json is the existing inherent method at line ~210 that takes &str. Calling it as ActivityPubService::actor_json(self, ...) avoids ambiguity with the trait method.
- Step 3: Fix
lookup_actorto return fulluser@domainhandle
In the same file, find the lookup_actor impl. Currently it sets handle: actor.username.clone() (just the preferred_username). Replace the Ok(...) block with:
let domain_str = actor.ap_id.host_str().unwrap_or("");
let full_handle = format!("{}@{}", actor.username, domain_str);
Ok(domain::models::remote_actor::RemoteActor {
url: actor.ap_id.to_string(),
handle: full_handle,
display_name: Some(actor.username.clone()),
inbox_url: actor.inbox_url.to_string(),
shared_inbox_url: None,
public_key: actor.public_key_pem.clone(),
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
last_fetched_at: actor.last_refreshed_at,
})
- Step 4: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5
Expected: no errors.
- Step 5: Full workspace check
cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -5
- Step 6: Commit
cd /mnt/drive/dev/thoughts
git add crates/adapters/activitypub-base/src/service.rs
git commit -m "feat(activitypub-base): impl actor_json port; return full user@domain handle from lookup"
Task 3: Notification handlers — PATCH
Files:
-
Modify:
crates/api-types/src/requests.rs -
Modify:
crates/presentation/src/handlers/notifications.rs -
Step 1: Add
NotificationUpdateRequestand removeFollowRemoteRequest
Read crates/api-types/src/requests.rs. Remove the FollowRemoteRequest struct (it was only used by the federation handler being deleted). Add:
#[derive(serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct NotificationUpdateRequest {
pub read: bool,
}
- Step 2: Write failing tests
Add to crates/presentation/src/handlers/notifications.rs (inside a #[cfg(test)] mod tests block at the bottom, following the same pattern as federation.rs tests — use TestStore and tower::ServiceExt::oneshot):
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{Request, header},
routing::{get, patch},
Router,
};
use domain::testing::TestStore;
use std::sync::Arc;
use tower::ServiceExt;
// Re-use the same NoOpAuth/NoOpHasher stubs from federation.rs tests pattern:
// Check crates/presentation/src/handlers/federation.rs for the exact stub code
// and copy it here (NoOpAuth implementing AuthService, NoOpHasher implementing PasswordHasher).
fn make_state() -> crate::state::AppState {
let store = Arc::new(TestStore::default());
crate::state::AppState {
users: store.clone(),
thoughts: store.clone(),
likes: store.clone(),
boosts: store.clone(),
follows: store.clone(),
blocks: store.clone(),
tags: store.clone(),
api_keys: store.clone(),
top_friends: store.clone(),
notifications: store.clone(),
remote_actors: store.clone(),
feed: store.clone(),
search: store.clone(),
auth: Arc::new(NoOpAuth),
hasher: Arc::new(NoOpHasher),
events: store.clone(),
federation: store.clone(),
}
}
fn app() -> Router {
Router::new()
.route("/notifications", patch(mark_all_read))
.route("/notifications/:id", patch(mark_notification_read))
.with_state(make_state())
}
#[tokio::test]
async fn patch_notification_without_auth_returns_401() {
let resp = app()
.oneshot(
Request::builder()
.method("PATCH")
.uri("/notifications/00000000-0000-0000-0000-000000000001")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"read":true}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn patch_all_without_auth_returns_401() {
let resp = app()
.oneshot(
Request::builder()
.method("PATCH")
.uri("/notifications")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"read":true}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
}
Note: copy the NoOpAuth and NoOpHasher struct definitions from crates/presentation/src/handlers/federation.rs — they are defined inline in the test module there.
- Step 3: Run to see compile/test failure
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::notifications::tests 2>&1 | tail -20
Expected: compile error — mark_notification_read and mark_all_read don't accept JSON body yet.
- Step 4: Replace the POST handlers with PATCH handlers
Replace the full content of crates/presentation/src/handlers/notifications.rs with:
use api_types::requests::NotificationUpdateRequest;
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
use application::use_cases::notifications::{
list_notifications as uc_list_notifications, mark_all_notifications_read,
mark_notification_read as uc_mark_notification_read,
};
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use domain::{models::feed::PageParams, value_objects::NotificationId};
use uuid::Uuid;
pub async fn list_notifications(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams { page: 1, per_page: 20 };
let result = uc_list_notifications(&*s.notifications, &uid, page).await?;
Ok(Json(serde_json::json!({
"total": result.total,
"unread": result.items.iter().filter(|n| !n.read).count()
})))
}
pub async fn mark_notification_read(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<NotificationUpdateRequest>,
) -> Result<StatusCode, ApiError> {
if body.read {
uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?;
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn mark_all_read(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
Json(body): Json<NotificationUpdateRequest>,
) -> Result<StatusCode, ApiError> {
if body.read {
mark_all_notifications_read(&*s.notifications, &uid).await?;
}
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
// ... (same test block from Step 2)
}
- Step 5: Run tests to confirm pass
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::notifications::tests 2>&1 | tail -10
Expected: both tests pass (401 without auth).
- Step 6: Compile check
cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -10
If there are errors about FollowRemoteRequest still being used (e.g. in federation.rs), that's fine — Task 5 deletes that file.
- Step 7: Commit
cd /mnt/drive/dev/thoughts
git add crates/api-types/src/requests.rs crates/presentation/src/handlers/notifications.rs
git commit -m "refactor(api): notification state changes use PATCH"
Task 4: Users handler — content negotiation + lookup move
Files:
-
Modify:
crates/presentation/src/handlers/users.rs -
Step 1: Write failing tests
Add a #[cfg(test)] mod tests block at the bottom of crates/presentation/src/handlers/users.rs. The NoOpAuth/NoOpHasher pattern is the same as in Task 3. Add:
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{Request, header},
routing::get,
Router,
};
use domain::testing::TestStore;
use std::sync::Arc;
use tower::ServiceExt;
// (copy NoOpAuth, NoOpHasher structs from federation.rs test module)
fn make_state() -> crate::state::AppState {
let store = Arc::new(TestStore::default());
crate::state::AppState {
users: store.clone(),
thoughts: store.clone(),
likes: store.clone(),
boosts: store.clone(),
follows: store.clone(),
blocks: store.clone(),
tags: store.clone(),
api_keys: store.clone(),
top_friends: store.clone(),
notifications: store.clone(),
remote_actors: store.clone(),
feed: store.clone(),
search: store.clone(),
auth: Arc::new(NoOpAuth),
hasher: Arc::new(NoOpHasher),
events: store.clone(),
federation: store.clone(),
}
}
fn app() -> Router {
Router::new()
.route("/users/:username", get(get_user))
.route("/users/lookup", get(lookup_handler))
.with_state(make_state())
}
#[tokio::test]
async fn get_unknown_user_returns_404() {
let resp = app()
.oneshot(Request::builder().uri("/users/nobody").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn get_user_with_ap_accept_calls_actor_json_returns_404_when_not_found() {
// TestStore.actor_json returns NotFound, so AP requests to unknown users → 404
let resp = app()
.oneshot(
Request::builder()
.uri("/users/nobody")
.header(header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn lookup_unknown_handle_returns_404() {
let resp = app()
.oneshot(
Request::builder()
.uri("/users/lookup?handle=%40alice%40example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 404);
}
}
- Step 2: Run to confirm tests compile but need implementation changes
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::users::tests 2>&1 | tail -20
Expected: compile errors until we add lookup_handler to users.rs and modify get_user.
- Step 3: Update
users.rs
Read the full crates/presentation/src/handlers/users.rs.
3a. Add new imports at the top:
use axum::http::{HeaderMap, header};
use axum::response::{IntoResponse, Response};
use api_types::responses::RemoteActorResponse;
3b. Replace the get_user handler (currently returns Result<Json<UserResponse>, ApiError>) with:
pub async fn get_user(
State(s): State<AppState>,
Path(username): Path<String>,
OptionalAuthUser(viewer): OptionalAuthUser,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let user = get_user_by_username(&*s.users, &username).await?;
let accept = headers
.get(header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json") {
let json = s.federation.actor_json(&user.id).await?;
Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response())
} else {
let is_followed = if let Some(viewer_id) = viewer {
s.follows.find(&viewer_id, &user.id).await?.is_some()
} else {
false
};
let mut resp = to_user_response(&user);
resp.is_followed_by_viewer = is_followed;
Ok(Json(resp).into_response())
}
}
3c. Rename get_me_following_list → get_me_following (just the function name — update it in place):
Find pub async fn get_me_following_list and rename to pub async fn get_me_following.
3d. Add LookupQuery and lookup_handler from federation.rs:
#[derive(serde::Deserialize)]
pub struct LookupQuery {
pub handle: String,
}
pub async fn lookup_handler(
State(s): State<AppState>,
Query(q): Query<LookupQuery>,
) -> Result<Json<RemoteActorResponse>, ApiError> {
let actor = s.federation.lookup_actor(&q.handle).await?;
Ok(Json(RemoteActorResponse {
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url,
url: actor.url,
}))
}
- Step 4: Run tests to confirm pass
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::users::tests 2>&1 | tail -10
Expected: all 3 tests pass.
- Step 5: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p presentation 2>&1 | tail -10
There will be errors about federation.rs still defining lookup_handler (duplicate) — that's resolved in Task 5 when we delete federation.rs. For now, just ensure users.rs itself compiles.
- Step 6: Commit
cd /mnt/drive/dev/thoughts
git add crates/presentation/src/handlers/users.rs
git commit -m "refactor(users): content negotiation at GET /users/{username}; move lookup handler"
Task 5: Social handler cleanup + delete federation.rs
Files:
-
Modify:
crates/presentation/src/handlers/social.rs -
Delete:
crates/presentation/src/handlers/federation.rs -
Modify:
crates/presentation/src/handlers/mod.rs -
Step 1: Write failing tests for unified follow
Add a #[cfg(test)] mod tests block at the bottom of crates/presentation/src/handlers/social.rs:
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::Request,
routing::{delete, post},
Router,
};
use domain::testing::TestStore;
use std::sync::Arc;
use tower::ServiceExt;
// (copy NoOpAuth, NoOpHasher structs from federation.rs test module)
fn make_state() -> crate::state::AppState {
let store = Arc::new(TestStore::default());
crate::state::AppState {
users: store.clone(),
thoughts: store.clone(),
likes: store.clone(),
boosts: store.clone(),
follows: store.clone(),
blocks: store.clone(),
tags: store.clone(),
api_keys: store.clone(),
top_friends: store.clone(),
notifications: store.clone(),
remote_actors: store.clone(),
feed: store.clone(),
search: store.clone(),
auth: Arc::new(NoOpAuth),
hasher: Arc::new(NoOpHasher),
events: store.clone(),
federation: store.clone(),
}
}
fn app() -> Router {
Router::new()
.route("/users/:username/follow", post(post_follow).delete(delete_follow))
.with_state(make_state())
}
#[tokio::test]
async fn follow_without_auth_returns_401() {
let resp = app()
.oneshot(Request::builder().method("POST").uri("/users/alice/follow").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
#[tokio::test]
async fn unfollow_remote_handle_without_auth_returns_401() {
let resp = app()
.oneshot(Request::builder().method("DELETE").uri("/users/alice@example.com/follow").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
}
- Step 2: Run to see compile state
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::social::tests 2>&1 | tail -15
- Step 3: Update
post_followto unify local and remote follows
In crates/presentation/src/handlers/social.rs, replace post_follow with:
#[utoipa::path(
post, path = "/users/{username}/follow",
params(("username" = String, Path, description = "Username or user@domain handle")),
responses((status = 204, description = "Following")),
security(("bearer_auth" = []))
)]
pub async fn post_follow(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
Path(username): Path<String>,
) -> Result<StatusCode, ApiError> {
if username.contains('@') {
s.federation.follow_remote(&uid, &username).await?;
} else {
let target = get_user_by_username(&*s.users, &username).await?;
follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
}
Ok(StatusCode::NO_CONTENT)
}
- Step 4: Update
delete_followto reject remote handles
Replace delete_follow with:
#[utoipa::path(
delete, path = "/users/{username}/follow",
params(("username" = String, Path, description = "Username")),
responses((status = 204, description = "Unfollowed")),
security(("bearer_auth" = []))
)]
pub async fn delete_follow(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
Path(username): Path<String>,
) -> Result<StatusCode, ApiError> {
if username.contains('@') {
return Err(ApiError::BadRequest("remote unfollow not yet supported".into()));
}
let target = get_user_by_username(&*s.users, &username).await?;
unfollow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
Ok(StatusCode::NO_CONTENT)
}
- Step 5: Fix
{id}→{username}in OpenAPI annotations for block handlers
In social.rs, update the #[utoipa::path] annotations on post_block and delete_block:
- Change
path = "/users/{id}/block"→path = "/users/{username}/block" - Change
("id" = uuid::Uuid, Path, description = "User ID")→("username" = String, Path, description = "Username")
Same for post_follow and delete_follow (already done in steps above).
- Step 6: Delete
federation.rsand updatemod.rs
Delete the file:
rm /mnt/drive/dev/thoughts/crates/presentation/src/handlers/federation.rs
In crates/presentation/src/handlers/mod.rs, remove the line:
pub mod federation;
- Step 7: Run tests
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::social::tests 2>&1 | tail -10
Expected: both tests pass (401 without auth).
- Step 8: Compile check
cd /mnt/drive/dev/thoughts && cargo check -p presentation 2>&1 | tail -10
Expected: no errors (all federation:: references removed from routes in next task — routes.rs will fail until Task 6).
- Step 9: Commit
cd /mnt/drive/dev/thoughts
git add crates/presentation/src/handlers/social.rs \
crates/presentation/src/handlers/mod.rs
git rm crates/presentation/src/handlers/federation.rs
git commit -m "refactor(social): unified follow handler; remove federation handler module"
Task 6: Routes + bootstrap
Files:
-
Modify:
crates/presentation/src/routes.rs -
Modify:
crates/bootstrap/src/main.rs -
Step 1: Replace
routes.rswith the cleaned-up route table
Read crates/presentation/src/routes.rs first. Replace the full api_routes builder chain with:
pub fn router() -> Router<AppState> {
let api_routes = Router::new()
// health
.route("/health", get(health::health_handler))
// auth
.route("/auth/register", post(auth::post_register))
.route("/auth/login", post(auth::post_login))
// users — static before parameterised
.route("/users", get(users::get_users))
.route("/users/count", get(users::get_user_count))
.route("/users/lookup", get(users::lookup_handler))
.route(
"/users/me",
get(users::get_me).patch(users::patch_profile),
)
.route("/users/me/following", get(users::get_me_following))
.route("/users/me/top-friends", put(social::put_top_friends))
.route("/users/{username}", get(users::get_user))
.route(
"/users/{username}/top-friends",
get(social::get_top_friends_handler),
)
.route(
"/users/{username}/follow",
post(social::post_follow).delete(social::delete_follow),
)
.route(
"/users/{username}/block",
post(social::post_block).delete(social::delete_block),
)
.route(
"/users/{username}/followers",
get(feed::get_followers_handler),
)
.route(
"/users/{username}/following",
get(feed::get_following_handler),
)
.route(
"/users/{username}/thoughts",
get(feed::user_thoughts_handler),
)
// thoughts
.route("/thoughts", post(thoughts::post_thought))
.route(
"/thoughts/{id}",
get(thoughts::get_thought_handler)
.patch(thoughts::patch_thought)
.delete(thoughts::delete_thought_handler),
)
.route("/thoughts/{id}/thread", get(thoughts::get_thread_handler))
// likes & boosts
.route(
"/thoughts/{id}/like",
post(social::post_like).delete(social::delete_like),
)
.route(
"/thoughts/{id}/boost",
post(social::post_boost).delete(social::delete_boost),
)
// feeds
.route("/feed", get(feed::home_feed))
.route("/feed/public", get(feed::public_feed))
.route("/search", get(feed::search_handler))
.route("/tags/popular", get(feed::get_popular_tags))
.route("/tags/{name}", get(feed::tag_thoughts_handler))
// notifications
.route(
"/notifications",
get(notifications::list_notifications).patch(notifications::mark_all_read),
)
.route(
"/notifications/{id}",
patch(notifications::mark_notification_read),
)
// api keys
.route(
"/api-keys",
get(api_keys::get_api_keys).post(api_keys::post_api_key),
)
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler));
openapi::serve(api_routes)
}
Make sure patch is imported: use axum::routing::{delete, get, patch, post, put};.
- Step 2: Remove
/users/{username}from the AP router inmain.rs
Read crates/bootstrap/src/main.rs. In the ap_router builder, remove this line:
.route("/users/{username}", axum::routing::get(actor_handler))
Also remove the actor_handler import from activitypub_base if it's no longer used anywhere in main.rs:
use activitypub_base::{
actor_handler::actor_handler, // ← remove this line
followers_handler::{followers_handler, following_handler},
...
};
- Step 3: Full compile check
cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -15
Expected: no errors. If actor_handler is still imported but unused, remove it.
- Step 4: Run all tests
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -10
Expected: all tests pass.
- Step 5: Commit
cd /mnt/drive/dev/thoughts
git add crates/presentation/src/routes.rs crates/bootstrap/src/main.rs
git commit -m "refactor(routes): clean RESTful route table; content negotiation at /users/{username}"
Task 7: Frontend — api.ts + remote-user-card.tsx
Files:
-
Modify:
thoughts-frontend/lib/api.ts -
Modify:
thoughts-frontend/components/remote-user-card.tsx -
Step 1: Update all changed URLs and methods in
api.ts
Read thoughts-frontend/lib/api.ts. Make these targeted edits:
getUserProfile — change URL:
export const getUserProfile = (username: string, token: string | null) =>
apiFetch(`/users/${username}`, {}, UserSchema, token);
getFollowersList — change URL:
export const getFollowersList = (username: string, token: string | null) =>
apiFetch(`/users/${username}/followers`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
getFollowingList — change URL:
export const getFollowingList = (username: string, token: string | null) =>
apiFetch(`/users/${username}/following`, {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
getMeFollowingList — change URL:
export const getMeFollowingList = (token: string) =>
apiFetch("/users/me/following", {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
lookupRemoteActor — change URL:
export const lookupRemoteActor = (handle: string, token: string | null) =>
apiFetch(
`/users/lookup?handle=${encodeURIComponent(handle)}`,
{},
RemoteActorSchema,
token
);
Delete followRemoteUser — remove this entire function (unified follow now uses followUser with the full user@domain handle):
// DELETE this:
export const followRemoteUser = (handle: string, token: string) =>
apiFetch(
`/federation/follow`,
{ method: "POST", body: JSON.stringify({ handle }) },
z.null(),
token
);
Add markNotificationRead:
export const markNotificationRead = (id: string, token: string) =>
apiFetch(
`/notifications/${id}`,
{ method: "PATCH", body: JSON.stringify({ read: true }) },
z.null(),
token
);
Add markAllNotificationsRead:
export const markAllNotificationsRead = (token: string) =>
apiFetch(
"/notifications",
{ method: "PATCH", body: JSON.stringify({ read: true }) },
z.null(),
token
);
- Step 2: Update
remote-user-card.tsx
Read thoughts-frontend/components/remote-user-card.tsx. Change the follow button's action from followRemoteUser to followUser:
Replace:
import { followRemoteUser, RemoteActor } from "@/lib/api";
With:
import { followUser, RemoteActor } from "@/lib/api";
Replace:
await followRemoteUser(actor.handle, token);
With:
await followUser(actor.handle, token);
This works because actor.handle is now the full user@domain format (e.g. gabrielkaszewski@mastodon.social) from the fixed lookup_actor, and followUser calls POST /users/gabrielkaszewski@mastodon.social/follow, which the unified handler detects as a remote follow.
- Step 3: Type-check
cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20
Expected: no errors. If any page references followRemoteUser, update it to followUser.
- Step 4: Commit
cd /mnt/drive/dev/thoughts
git add thoughts-frontend/lib/api.ts thoughts-frontend/components/remote-user-card.tsx
git commit -m "refactor(frontend): update API client to match cleaned REST routes"
Self-Review
Spec coverage:
- ✅
GET /users/{username}content negotiation — Tasks 1, 2, 4, 6 - ✅
GET /users/lookupmoved from/federation/lookup— Tasks 4, 6 - ✅
POST /users/{username}/followunified — Task 5, 6 - ✅
DELETE /users/{username}/follow400 for remote — Task 5 - ✅
{id}→{username}param rename in follow/block — Tasks 5, 6 - ✅
followers/followingroute rename — Task 6 - ✅
me/followingrename — Tasks 4, 6 - ✅
PATCH /notifications/{id}— Tasks 3, 6 - ✅
PATCH /notificationsbulk — Tasks 3, 6 - ✅
PUT /users/meremoved — Task 6 - ✅
POST /federation/followremoved — Tasks 5, 6 - ✅ Frontend api.ts updates — Task 7
- ✅
remote-user-card.tsxfollowUser — Task 7 - ✅ Handle format fix (
user@domain) inlookup_actor— Task 2
Placeholder scan: None found.
Type consistency:
actor_json(&self, user_id: &UserId)defined in Task 1, implemented in Task 2, called in Task 4 ✅get_me_followingrenamed in Task 4, referenced in Task 6 routes ✅lookup_handlerdefined in Task 4 (users.rs), referenced in Task 6 routes asusers::lookup_handler✅NotificationUpdateRequestdefined in Task 3 (api-types), used in Task 3 (notifications.rs) ✅followUser(actor.handle, token)—actor.handleis fulluser@domainafter Task 2 fix ✅