- Task 1: Fix feed response hydration by adding `to_thought_response` helper and updating feed handlers to return full `ThoughtResponse`. - Task 2: Wire follower/following REST routes for user feeds. - Task 3: Add user listing and count endpoints, including `GET /users` and `GET /users/count`. - Task 4: Implement popular tags feature with `GET /tags/popular`. - Task 5: Enhance configuration with HOST, CORS_ORIGINS, and optional rate limiting using tower-governor.
20 KiB
Merge Readiness 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: Close the remaining gaps between v2 and v1 so the new Rust backend can replace the old one. Five tasks: fix feed response hydration, wire missing follower/following routes, add user listing endpoints, add popular tags, harden config (HOST, CORS, rate limiting).
Architecture: All changes are in presentation, domain/ports, adapters/postgres, and bootstrap. No changes to application or worker.
File Map
Task 1 — Feed hydration:
Modify: crates/presentation/src/handlers/feed.rs ← add to_thought_response helper, fix 4 handlers
Modify: crates/presentation/src/handlers/auth.rs ← move/export to_feed_entry helper if needed
Task 2 — Wire follower/following routes:
Modify: crates/presentation/src/routes.rs ← add 2 routes
Task 3 — User listing + count:
Modify: crates/domain/src/ports.rs ← add count() to UserRepository
Modify: crates/adapters/postgres/src/user.rs ← implement count()
Modify: crates/domain/src/testing.rs ← add count() to TestStore
Modify: crates/presentation/src/handlers/users.rs ← add get_users, get_user_count handlers
Modify: crates/presentation/src/routes.rs ← add 2 routes
Task 4 — Popular tags:
Modify: crates/domain/src/ports.rs ← add popular_tags() to TagRepository
Modify: crates/adapters/postgres/src/tag.rs ← implement popular_tags()
Modify: crates/domain/src/testing.rs ← add popular_tags() to TestStore
Modify: crates/presentation/src/handlers/feed.rs ← add get_popular_tags handler
Modify: crates/presentation/src/routes.rs ← add 1 route (before /tags/{name})
Task 5 — Config: HOST, CORS_ORIGINS, RATE_LIMIT:
Modify: crates/bootstrap/src/config.rs ← 3 new fields
Modify: crates/bootstrap/src/main.rs ← use HOST, CORS layer, rate limit layer
Modify: crates/bootstrap/Cargo.toml ← add tower-governor
Modify: .env.example ← document new vars
Task 1: Fix feed response hydration
Files:
- Modify:
crates/presentation/src/handlers/feed.rs
Problem: home_feed and public_feed return only UUIDs. user_thoughts_handler and tag_thoughts_handler are missing author, in_reply_to_id, sensitive, content_warning, viewer flags. All four need to use ThoughtResponse.
The ThoughtResponse DTO in api-types already has every needed field. FeedEntry in domain already carries like_count, boost_count, reply_count, liked_by_viewer, boosted_by_viewer. The conversion is straightforward.
- Add
to_thought_responsehelper at the top offeed.rs(after existing imports). This is a private free function:
use api_types::responses::ThoughtResponse;
fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
ThoughtResponse {
id: e.thought.id.as_uuid(),
content: e.thought.content.as_str().to_string(),
author: crate::handlers::auth::to_user_response(&e.author),
in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()),
visibility: e.thought.visibility.as_str().to_string(),
content_warning: e.thought.content_warning.clone(),
sensitive: e.thought.sensitive,
like_count: e.like_count,
boost_count: e.boost_count,
reply_count: e.reply_count,
liked_by_viewer: e.liked_by_viewer,
boosted_by_viewer: e.boosted_by_viewer,
created_at: e.thought.created_at,
updated_at: e.thought.updated_at,
}
}
- Fix
home_feed— replace the UUID-only mapping:
pub async fn home_feed(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams { page: q.page(), per_page: q.per_page() };
let result = get_home_feed(&*s.feed, &*s.follows, &uid, page).await?;
Ok(Json(serde_json::json!({
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
"total": result.total,
"page": result.page,
"per_page": result.per_page,
})))
}
- Fix
public_feed— same pattern:
pub async fn public_feed(
State(s): State<AppState>,
OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams { page: q.page(), per_page: q.per_page() };
let result = get_public_feed(&*s.feed, viewer.as_ref(), page).await?;
Ok(Json(serde_json::json!({
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
"total": result.total,
"page": result.page,
"per_page": result.per_page,
})))
}
- Fix
user_thoughts_handler— replace the partial mapping withto_thought_response:
pub async fn user_thoughts_handler(
State(s): State<AppState>,
Path(username): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user = get_user_by_username(&*s.users, &username).await?;
let page = PageParams { page: q.page(), per_page: q.per_page() };
let result = get_user_feed(&*s.thoughts, &user.id, page).await?;
Ok(Json(serde_json::json!({
"total": result.total,
"page": result.page,
"per_page": result.per_page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
})))
}
- Fix
tag_thoughts_handler— same:
pub async fn tag_thoughts_handler(
State(s): State<AppState>,
Path(tag_name): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams { page: q.page(), per_page: q.per_page() };
let result = get_by_tag(&*s.tags, &tag_name, page).await?;
Ok(Json(serde_json::json!({
"tag": tag_name,
"total": result.total,
"page": result.page,
"per_page": result.per_page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
})))
}
NOTE: get_by_tag returns Paginated<Thought>, not Paginated<FeedEntry> — it won't have author or counts. Check the use case signature. If it returns Paginated<Thought>, map manually keeping available fields only (id, content, visibility, dates). If it returns Paginated<FeedEntry>, use to_thought_response.
-
Run:
cargo check -p presentation— Expected: no errors. -
Commit:
git add crates/presentation/src/handlers/feed.rs
git commit -m "fix(presentation): hydrate feed responses with full ThoughtResponse — remove UUID-only stubs"
Task 2: Wire follower/following REST routes
Files:
- Modify:
crates/presentation/src/routes.rs
get_followers_handler and get_following_handler already exist in feed.rs (lines 75–80). The AP routes own /users/{username}/followers and /users/{username}/following. Wire the REST handlers at non-conflicting paths:
- Add two routes to
api_routesinroutes.rs, in the users section (before/thoughts):
.route("/users/{username}/follower-list", get(feed::get_followers_handler))
.route("/users/{username}/following-list", get(feed::get_following_handler))
-
Run:
cargo check -p presentation— Expected: no errors. -
Commit:
git add crates/presentation/src/routes.rs
git commit -m "feat(presentation): wire GET /users/{username}/follower-list and /following-list"
Task 3: User listing + count
Files:
-
Modify:
crates/domain/src/ports.rs -
Modify:
crates/adapters/postgres/src/user.rs -
Modify:
crates/domain/src/testing.rs -
Modify:
crates/presentation/src/handlers/users.rs -
Modify:
crates/presentation/src/routes.rs -
Add
count()toUserRepositoryincrates/domain/src/ports.rs:
async fn count(&self) -> Result<i64, DomainError>;
- Implement
count()in postgres — findimpl UserRepository for PgUserRepositoryincrates/adapters/postgres/src/user.rsand add:
async fn count(&self) -> Result<i64, DomainError> {
let row = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true")
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(row)
}
- Implement
count()in TestStore incrates/domain/src/testing.rs:
async fn count(&self) -> Result<i64, DomainError> {
Ok(self.users.lock().unwrap().iter().filter(|u| u.local).count() as i64)
}
- Add handlers to
crates/presentation/src/handlers/users.rs:
use domain::models::feed::UserSummary;
#[utoipa::path(
get, path = "/users",
params(
("q" = Option<String>, Query, description = "Search query"),
PaginationQuery,
),
responses((status = 200, description = "User list"))
)]
pub async fn get_users(
State(s): State<AppState>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, ApiError> {
use domain::models::feed::PageParams;
let page = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1u64);
let per_page = params.get("per_page").and_then(|v| v.parse().ok()).unwrap_or(20u64);
let page_params = PageParams { page, per_page };
if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) {
let result = s.search.search_users(q, &page_params).await?;
let users: Vec<_> = result.items.iter().map(|u| crate::handlers::auth::to_user_response(u)).collect();
return Ok(Json(serde_json::json!({ "items": users, "total": result.total, "page": result.page, "per_page": result.per_page })));
}
let all = s.users.list_with_stats().await?;
let total = all.len() as i64;
let start = ((page - 1) * per_page) as usize;
let items: Vec<_> = all.into_iter().skip(start).take(per_page as usize)
.map(|u| serde_json::json!({
"id": u.id.as_uuid(),
"username": u.username,
"display_name": u.display_name,
"avatar_url": u.avatar_url,
"bio": u.bio,
"thought_count": u.thought_count,
"follower_count": u.follower_count,
"following_count": u.following_count,
}))
.collect();
Ok(Json(serde_json::json!({ "items": items, "total": total, "page": page, "per_page": per_page })))
}
#[utoipa::path(
get, path = "/users/count",
responses((status = 200, description = "Local user count"))
)]
pub async fn get_user_count(
State(s): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> {
let count = s.users.count().await?;
Ok(Json(serde_json::json!({ "count": count })))
}
Note: get_users needs use api_types::requests::PaginationQuery; added to imports if not already there. Check the file's existing imports.
- Add routes to
routes.rs— add BEFORE/users/me(static paths must come before parameterised):
.route("/users", get(users::get_users))
.route("/users/count", get(users::get_user_count))
-
Run:
cargo check --workspace— Expected: no errors. -
Commit:
git add crates/domain/src/ports.rs \
crates/adapters/postgres/src/user.rs \
crates/domain/src/testing.rs \
crates/presentation/src/handlers/users.rs \
crates/presentation/src/routes.rs
git commit -m "feat: GET /users (search/list) and GET /users/count"
Task 4: Popular tags
Files:
-
Modify:
crates/domain/src/ports.rs -
Modify:
crates/adapters/postgres/src/tag.rs -
Modify:
crates/domain/src/testing.rs -
Modify:
crates/presentation/src/handlers/feed.rs -
Modify:
crates/presentation/src/routes.rs -
Add
popular_tags()toTagRepositoryincrates/domain/src/ports.rs:
/// Returns (tag_name, thought_count) pairs, most-used first.
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError>;
- Implement
popular_tags()in postgres — findimpl TagRepository for PgTagRepositoryincrates/adapters/postgres/src/tag.rsand add:
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
let rows = sqlx::query_as::<_, (String, i64)>(
"SELECT t.name, COUNT(tt.thought_id) AS thought_count
FROM tags t
JOIN thought_tags tt ON t.id = tt.tag_id
GROUP BY t.id, t.name
ORDER BY thought_count DESC
LIMIT $1"
)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(rows)
}
- Implement
popular_tags()in TestStore incrates/domain/src/testing.rs:
async fn popular_tags(&self, _limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
Ok(vec![])
}
- Add
get_popular_tagshandler tocrates/presentation/src/handlers/feed.rs:
pub async fn get_popular_tags(
State(s): State<AppState>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, ApiError> {
let limit: usize = params.get("limit").and_then(|v| v.parse().ok()).unwrap_or(20);
let tags = s.tags.popular_tags(limit.min(100)).await?;
Ok(Json(serde_json::json!({
"tags": tags.iter().map(|(name, count)| serde_json::json!({
"name": name,
"thought_count": count,
})).collect::<Vec<_>>()
})))
}
- Wire
GET /tags/popularinroutes.rs— add BEFORE/tags/{name}(otherwisepopularis captured as the{name}param):
.route("/tags/popular", get(feed::get_popular_tags))
.route("/tags/{name}", get(feed::tag_thoughts_handler))
The existing .route("/tags/{name}", ...) line can stay — just add the popular route immediately before it.
-
Run:
cargo check --workspace— Expected: no errors. -
Run unit tests:
cargo test --workspace --exclude postgres --exclude postgres-federation --exclude postgres-search— Expected: all pass. -
Commit:
git add crates/domain/src/ports.rs \
crates/adapters/postgres/src/tag.rs \
crates/domain/src/testing.rs \
crates/presentation/src/handlers/feed.rs \
crates/presentation/src/routes.rs
git commit -m "feat: GET /tags/popular — top tags by usage count"
Task 5: Config — HOST, CORS_ORIGINS, RATE_LIMIT
Files:
-
Modify:
crates/bootstrap/src/config.rs -
Modify:
crates/bootstrap/src/main.rs -
Modify:
crates/bootstrap/Cargo.toml -
Modify:
.env.example -
Add
tower-governortocrates/bootstrap/Cargo.toml:
tower-governor = "0.6"
- Add three fields to
Configincrates/bootstrap/src/config.rs:
pub struct Config {
pub database_url: String,
pub jwt_secret: String,
pub base_url: String,
pub nats_url: Option<String>,
pub port: u16,
pub host: String,
pub allow_registration: bool,
pub debug: bool,
/// Comma-separated allowed origins, or "*" for permissive. Default: "*".
pub cors_origins: String,
/// Max requests per minute per IP. None = disabled.
pub rate_limit: Option<u32>,
}
In from_env() add:
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()),
cors_origins: std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "*".into()),
rate_limit: std::env::var("RATE_LIMIT").ok().and_then(|v| v.parse().ok()),
- Update
crates/bootstrap/src/main.rs:
mod config;
mod factory;
use std::sync::Arc;
use http::HeaderValue;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() {
let cfg = config::Config::from_env();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let infra = factory::build(&cfg).await;
// CORS
let cors = if cfg.cors_origins.trim() == "*" {
CorsLayer::permissive()
} else {
let origins: Vec<HeaderValue> = cfg.cors_origins
.split(',')
.map(|o| o.trim())
.filter_map(|o| o.parse().ok())
.collect();
CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any)
};
let app = presentation::routes::router(&infra.fed_config)
.with_state(infra.state)
.layer(cors);
// Rate limiting (optional)
let app = if let Some(rate_limit) = cfg.rate_limit {
use tower_governor::{GovernorLayer, GovernorConfigBuilder};
let governor_config = Arc::new(
GovernorConfigBuilder::default()
.per_millisecond(60_000 / rate_limit as u64)
.burst_size(rate_limit)
.use_headers()
.finish()
.expect("valid rate limit config"),
);
let limiter = governor_config.limiter().clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
interval.tick().await;
limiter.retain_recent();
}
});
app.layer(GovernorLayer { config: governor_config })
} else {
app
};
let addr = format!("{}:{}", cfg.host, cfg.port);
tracing::info!("Listening on {addr}");
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Note: tower-governor's GovernorLayer API may differ slightly — check the actual 0.6.x docs and adjust. The GovernorConfigBuilder might use .per_second() instead of .per_millisecond(). Verify and use whichever method produces the desired requests-per-minute rate.
Note 2: Axum Router::layer returns the same type when adding a standard layer. GovernorLayer returns a different type. If the type system complains, wrap the app in tower::ServiceBuilder or use .layer(tower::ServiceBuilder::new().layer(GovernorLayer { ... }).into_inner()).
- Update
.env.example— add the three new vars:
# Optional
HOST=0.0.0.0
PORT=3000
ALLOW_REGISTRATION=true
RUST_ENV=development
# CORS — comma-separated origins, or * for permissive (default: *)
CORS_ORIGINS=*
# CORS_ORIGINS=https://your-nextjs-app.example.com
# Rate limiting — max requests per minute per IP (disabled by default)
# RATE_LIMIT=60
-
Run:
cargo check -p bootstrap— Expected: no errors (fix tower-governor API if needed). -
Commit:
git add crates/bootstrap/ .env.example
git commit -m "feat(bootstrap): configurable HOST, CORS_ORIGINS, and optional rate limiting"
Self-Review
Spec coverage:
- ✅
home_feed/public_feedreturn fullThoughtResponse(Task 1) - ✅
user_thoughts_handler/tag_thoughts_handleruseto_thought_response(Task 1) - ✅
GET /users/{username}/follower-listand/following-listwired (Task 2) - ✅
GET /users(search + list) +GET /users/count(Task 3) - ✅
UserRepository::count()in port + postgres + TestStore (Task 3) - ✅
GET /tags/popularwired before/tags/{name}(Task 4) - ✅
TagRepository::popular_tags()in port + postgres + TestStore (Task 4) - ✅
HOST,CORS_ORIGINS,RATE_LIMITin Config (Task 5) - ✅ CORS layer uses configured origins (Task 5)
- ✅ Rate limiting via tower-governor, disabled by default (Task 5)
Placeholder scan: None.
Type consistency:
to_thought_responsemapsFeedEntry→ThoughtResponse— both types confirmed in sourcetag_thoughts_handlerusesget_by_tagwhich returnsPaginated<Thought>— verify whether it returnsThoughtorFeedEntryand adjust the mapping accordinglypopular_tags()returnsVec<(String, i64)>— matches the SQL query's two columnsGovernorLayerAPI — implementer must verify against installed tower-governor version