1055 lines
33 KiB
Markdown
1055 lines
33 KiB
Markdown
# 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_json` to the trait**
|
|
|
|
Read `crates/domain/src/ports.rs`. In the `FederationActionPort` trait block, add the new method:
|
|
|
|
```rust
|
|
#[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:
|
|
|
|
```rust
|
|
#[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**
|
|
|
|
```bash
|
|
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_json` on `TestStore`**
|
|
|
|
In `crates/domain/src/testing.rs`, inside `impl FederationActionPort for TestStore`, add:
|
|
|
|
```rust
|
|
async fn actor_json(&self, _user_id: &UserId) -> Result<String, DomainError> {
|
|
Err(DomainError::NotFound)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests to confirm pass**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -5
|
|
```
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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_json` in the `FederationActionPort` impl**
|
|
|
|
Read `crates/adapters/activitypub-base/src/service.rs`. In the `impl domain::ports::FederationActionPort for ActivityPubService` block, add after `follow_remote`:
|
|
|
|
```rust
|
|
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_actor` to return full `user@domain` handle**
|
|
|
|
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:
|
|
|
|
```rust
|
|
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**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo check -p activitypub-base 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: no errors.
|
|
|
|
- [ ] **Step 5: Full workspace check**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo check 2>&1 | tail -5
|
|
```
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
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 `NotificationUpdateRequest` and remove `FollowRemoteRequest`**
|
|
|
|
Read `crates/api-types/src/requests.rs`. Remove the `FollowRemoteRequest` struct (it was only used by the federation handler being deleted). Add:
|
|
|
|
```rust
|
|
#[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`):
|
|
|
|
```rust
|
|
#[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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```rust
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```rust
|
|
#[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**
|
|
|
|
```bash
|
|
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:**
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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`:**
|
|
|
|
```rust
|
|
#[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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```rust
|
|
#[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**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test -p presentation -- handlers::social::tests 2>&1 | tail -15
|
|
```
|
|
|
|
- [ ] **Step 3: Update `post_follow` to unify local and remote follows**
|
|
|
|
In `crates/presentation/src/handlers/social.rs`, replace `post_follow` with:
|
|
|
|
```rust
|
|
#[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_follow` to reject remote handles**
|
|
|
|
Replace `delete_follow` with:
|
|
|
|
```rust
|
|
#[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.rs` and update `mod.rs`**
|
|
|
|
Delete the file:
|
|
```bash
|
|
rm /mnt/drive/dev/thoughts/crates/presentation/src/handlers/federation.rs
|
|
```
|
|
|
|
In `crates/presentation/src/handlers/mod.rs`, remove the line:
|
|
```rust
|
|
pub mod federation;
|
|
```
|
|
|
|
- [ ] **Step 7: Run tests**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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.rs` with the cleaned-up route table**
|
|
|
|
Read `crates/presentation/src/routes.rs` first. Replace the full `api_routes` builder chain with:
|
|
|
|
```rust
|
|
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 in `main.rs`**
|
|
|
|
Read `crates/bootstrap/src/main.rs`. In the `ap_router` builder, remove this line:
|
|
|
|
```rust
|
|
.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`:
|
|
|
|
```rust
|
|
use activitypub_base::{
|
|
actor_handler::actor_handler, // ← remove this line
|
|
followers_handler::{followers_handler, following_handler},
|
|
...
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 3: Full compile check**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
cd /mnt/drive/dev/thoughts && cargo test 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
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:
|
|
```typescript
|
|
export const getUserProfile = (username: string, token: string | null) =>
|
|
apiFetch(`/users/${username}`, {}, UserSchema, token);
|
|
```
|
|
|
|
**`getFollowersList`** — change URL:
|
|
```typescript
|
|
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:
|
|
```typescript
|
|
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:
|
|
```typescript
|
|
export const getMeFollowingList = (token: string) =>
|
|
apiFetch("/users/me/following", {}, z.object({ total: z.number(), items: z.array(UserSchema) }), token);
|
|
```
|
|
|
|
**`lookupRemoteActor`** — change URL:
|
|
```typescript
|
|
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):
|
|
```typescript
|
|
// DELETE this:
|
|
export const followRemoteUser = (handle: string, token: string) =>
|
|
apiFetch(
|
|
`/federation/follow`,
|
|
{ method: "POST", body: JSON.stringify({ handle }) },
|
|
z.null(),
|
|
token
|
|
);
|
|
```
|
|
|
|
**Add `markNotificationRead`**:
|
|
```typescript
|
|
export const markNotificationRead = (id: string, token: string) =>
|
|
apiFetch(
|
|
`/notifications/${id}`,
|
|
{ method: "PATCH", body: JSON.stringify({ read: true }) },
|
|
z.null(),
|
|
token
|
|
);
|
|
```
|
|
|
|
**Add `markAllNotificationsRead`**:
|
|
```typescript
|
|
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:
|
|
```typescript
|
|
import { followRemoteUser, RemoteActor } from "@/lib/api";
|
|
```
|
|
With:
|
|
```typescript
|
|
import { followUser, RemoteActor } from "@/lib/api";
|
|
```
|
|
|
|
Replace:
|
|
```typescript
|
|
await followRemoteUser(actor.handle, token);
|
|
```
|
|
With:
|
|
```typescript
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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/lookup` moved from `/federation/lookup` — Tasks 4, 6
|
|
- ✅ `POST /users/{username}/follow` unified — Task 5, 6
|
|
- ✅ `DELETE /users/{username}/follow` 400 for remote — Task 5
|
|
- ✅ `{id}` → `{username}` param rename in follow/block — Tasks 5, 6
|
|
- ✅ `followers`/`following` route rename — Task 6
|
|
- ✅ `me/following` rename — Tasks 4, 6
|
|
- ✅ `PATCH /notifications/{id}` — Tasks 3, 6
|
|
- ✅ `PATCH /notifications` bulk — Tasks 3, 6
|
|
- ✅ `PUT /users/me` removed — Task 6
|
|
- ✅ `POST /federation/follow` removed — Tasks 5, 6
|
|
- ✅ Frontend api.ts updates — Task 7
|
|
- ✅ `remote-user-card.tsx` followUser — Task 7
|
|
- ✅ Handle format fix (`user@domain`) in `lookup_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_following` renamed in Task 4, referenced in Task 6 routes ✅
|
|
- `lookup_handler` defined in Task 4 (users.rs), referenced in Task 6 routes as `users::lookup_handler` ✅
|
|
- `NotificationUpdateRequest` defined in Task 3 (api-types), used in Task 3 (notifications.rs) ✅
|
|
- `followUser(actor.handle, token)` — `actor.handle` is full `user@domain` after Task 2 fix ✅
|