From fb8c75af72b3e2da1a7550a2bcfc94881e77e6aa Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 11:27:43 +0200 Subject: [PATCH] docs: OpenAPI documentation implementation plan --- .../plans/2026-05-14-openapi-docs.md | 822 ++++++++++++++++++ 1 file changed, 822 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-14-openapi-docs.md diff --git a/docs/superpowers/plans/2026-05-14-openapi-docs.md b/docs/superpowers/plans/2026-05-14-openapi-docs.md new file mode 100644 index 0000000..3e996d8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-openapi-docs.md @@ -0,0 +1,822 @@ +# OpenAPI / Swagger Docs 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:** Add utoipa OpenAPI documentation to all REST handlers, served at `/docs` (Swagger UI) and `/scalar` — mirroring the movies-diary pattern. + +**Architecture:** `#[utoipa::path]` annotations go on handler functions. `#[derive(utoipa::ToSchema)]` goes on api-types DTOs. Feature-grouped doc structs in `presentation/src/openapi/` assemble the spec. `openapi::serve(router)` merges Swagger UI and Scalar into the axum router. Handlers returning `serde_json::Value` use `inline((status = 200, description = "..."))` or reference inline schema objects. + +**Tech Stack:** utoipa 5.5, utoipa-scalar 0.3, utoipa-swagger-ui 9.0 + +--- + +## File Map + +``` +Modify: crates/presentation/Cargo.toml ← add utoipa, utoipa-scalar, utoipa-swagger-ui +Modify: crates/api-types/Cargo.toml ← add utoipa with uuid feature +Modify: crates/api-types/src/requests.rs ← add #[derive(ToSchema, IntoParams)] +Modify: crates/api-types/src/responses.rs ← add #[derive(ToSchema)] +Create: crates/presentation/src/openapi/mod.rs ← assembles all doc structs, serves /docs + /scalar +Create: crates/presentation/src/openapi/auth.rs +Create: crates/presentation/src/openapi/users.rs +Create: crates/presentation/src/openapi/thoughts.rs +Create: crates/presentation/src/openapi/feed.rs +Create: crates/presentation/src/openapi/social.rs +Create: crates/presentation/src/openapi/notifications.rs +Create: crates/presentation/src/openapi/api_keys.rs +Modify: crates/presentation/src/handlers/auth.rs ← add #[utoipa::path] to 2 handlers +Modify: crates/presentation/src/handlers/users.rs ← add #[utoipa::path] to 3 handlers +Modify: crates/presentation/src/handlers/thoughts.rs ← add #[utoipa::path] to 5 handlers +Modify: crates/presentation/src/handlers/feed.rs ← add #[utoipa::path] to 5 handlers +Modify: crates/presentation/src/handlers/social.rs ← add #[utoipa::path] to 10 handlers +Modify: crates/presentation/src/handlers/notifications.rs ← add #[utoipa::path] to 3 handlers +Modify: crates/presentation/src/handlers/api_keys.rs ← add #[utoipa::path] to 3 handlers +Modify: crates/presentation/src/handlers/health.rs ← add #[utoipa::path] +Modify: crates/presentation/src/handlers/mod.rs ← add pub mod openapi +Modify: crates/presentation/src/routes.rs ← call openapi::serve(router) +Modify: crates/presentation/src/lib.rs ← pub mod openapi +``` + +--- + +### Task 1: Dependencies + ToSchema on api-types + +**Files:** +- Modify: `crates/presentation/Cargo.toml` +- Modify: `crates/api-types/Cargo.toml` +- Modify: `crates/api-types/src/requests.rs` +- Modify: `crates/api-types/src/responses.rs` + +- [ ] **Add deps to `crates/presentation/Cargo.toml`:** + +```toml +utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] } +utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] } +``` + +- [ ] **Add dep to `crates/api-types/Cargo.toml`:** + +```toml +utoipa = { version = "5.5.0", features = ["uuid", "chrono"] } +``` + +- [ ] **Add `#[derive(utoipa::ToSchema)]` and `#[derive(utoipa::IntoParams)]` to `crates/api-types/src/requests.rs`:** + +Replace the file with: + +```rust +use serde::Deserialize; +use uuid::Uuid; + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct RegisterRequest { + /// Username (1-32 chars, alphanumeric + underscore) + pub username: String, + pub email: String, + pub password: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreateThoughtRequest { + /// Up to 128 characters + pub content: String, + pub in_reply_to_id: Option, + /// One of: "public", "followers", "unlisted", "direct" + pub visibility: Option, + pub content_warning: Option, + pub sensitive: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct EditThoughtRequest { + pub content: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateProfileRequest { + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub header_url: Option, + pub custom_css: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct SetTopFriendsRequest { + /// Ordered list of user UUIDs, max 8 + pub friend_ids: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreateApiKeyRequest { + pub name: String, +} + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct PaginationQuery { + pub page: Option, + pub per_page: Option, +} +impl PaginationQuery { + pub fn page(&self) -> u64 { self.page.unwrap_or(1).max(1) } + pub fn per_page(&self) -> u64 { self.per_page.unwrap_or(20).min(100) } +} + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct SearchQuery { + pub q: String, + pub page: Option, + pub per_page: Option, +} +``` + +- [ ] **Add `#[derive(utoipa::ToSchema)]` to `crates/api-types/src/responses.rs`:** + +Replace the file with: + +```rust +use chrono::{DateTime, Utc}; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct AuthResponse { + pub token: String, + pub user: UserResponse, +} + +#[derive(Serialize, Clone, utoipa::ToSchema)] +pub struct UserResponse { + pub id: Uuid, + pub username: String, + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub header_url: Option, + pub local: bool, + pub created_at: DateTime, +} + +#[derive(Serialize, Clone, utoipa::ToSchema)] +pub struct ThoughtResponse { + pub id: Uuid, + pub content: String, + pub author: UserResponse, + pub in_reply_to_id: Option, + pub visibility: String, + pub content_warning: Option, + pub sensitive: bool, + pub like_count: i64, + pub boost_count: i64, + pub reply_count: i64, + pub liked_by_viewer: bool, + pub boosted_by_viewer: bool, + pub created_at: DateTime, + pub updated_at: Option>, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct PagedResponse { + pub items: Vec, + pub total: i64, + pub page: u64, + pub per_page: u64, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct ApiKeyResponse { + pub id: Uuid, + pub name: String, + pub created_at: DateTime, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct NotificationResponse { + pub id: Uuid, + pub notification_type: String, + pub from_user: Option, + pub thought_id: Option, + pub read: bool, + pub created_at: DateTime, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct ErrorResponse { + pub error: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub struct CreatedApiKeyResponse { + pub id: Uuid, + pub name: String, + /// Raw API key — shown only once at creation + pub key: String, +} +``` + +- [ ] **Run:** `cargo check -p api-types` — Expected: no errors. + +- [ ] **Commit:** + +```bash +git add crates/presentation/Cargo.toml crates/api-types/ +git commit -m "feat(api-types): add utoipa ToSchema and IntoParams derives" +``` + +--- + +### Task 2: Annotate handlers + create openapi modules + +**Files:** All handler files + `crates/presentation/src/openapi/` + +- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/auth.rs`:** + +```rust +#[utoipa::path( + post, path = "/auth/register", + request_body = RegisterRequest, + responses( + (status = 201, description = "User registered", body = AuthResponse), + (status = 409, description = "Username or email taken", body = ErrorResponse), + (status = 422, description = "Invalid input", body = ErrorResponse), + ) +)] +pub async fn post_register(...) { ... } + +#[utoipa::path( + post, path = "/auth/login", + request_body = LoginRequest, + responses( + (status = 200, description = "Login successful", body = AuthResponse), + (status = 401, description = "Invalid credentials", body = ErrorResponse), + ) +)] +pub async fn post_login(...) { ... } +``` + +- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/users.rs`:** + +```rust +#[utoipa::path( + get, path = "/users/me", + responses( + (status = 200, body = UserResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_me(...) { ... } + +#[utoipa::path( + get, path = "/users/{username}", + params(("username" = String, Path, description = "Username")), + responses( + (status = 200, body = UserResponse), + (status = 404, description = "User not found", body = ErrorResponse), + ) +)] +pub async fn get_user(...) { ... } + +#[utoipa::path( + patch, path = "/users/me", + request_body = UpdateProfileRequest, + responses( + (status = 200, body = UserResponse), + (status = 401, description = "Unauthorized", body = ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn patch_profile(...) { ... } +``` + +- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/thoughts.rs`:** + +```rust +#[utoipa::path( + post, path = "/thoughts", + request_body = CreateThoughtRequest, + responses( + (status = 201, description = "Thought created"), + (status = 401, description = "Unauthorized", body = ErrorResponse), + (status = 422, description = "Content too long", body = ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn post_thought(...) { ... } + +#[utoipa::path( + get, path = "/thoughts/{id}", + params(("id" = Uuid, Path, description = "Thought ID")), + responses( + (status = 200, description = "Thought with author info"), + (status = 404, description = "Not found", body = ErrorResponse), + ) +)] +pub async fn get_thought_handler(...) { ... } + +#[utoipa::path( + patch, path = "/thoughts/{id}", + params(("id" = Uuid, Path, description = "Thought ID")), + request_body = EditThoughtRequest, + responses( + (status = 204, description = "Updated"), + (status = 401, description = "Unauthorized", body = ErrorResponse), + (status = 404, description = "Not found or not owner", body = ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn patch_thought(...) { ... } + +#[utoipa::path( + delete, path = "/thoughts/{id}", + params(("id" = Uuid, Path, description = "Thought ID")), + responses( + (status = 204, description = "Deleted"), + (status = 401, description = "Unauthorized", body = ErrorResponse), + (status = 404, description = "Not found or not owner", body = ErrorResponse), + ), + security(("bearer_auth" = [])) +)] +pub async fn delete_thought_handler(...) { ... } + +#[utoipa::path( + get, path = "/thoughts/{id}/thread", + params(("id" = Uuid, Path, description = "Root thought ID")), + responses( + (status = 200, description = "Thread (root + replies)"), + ) +)] +pub async fn get_thread_handler(...) { ... } +``` + +- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/feed.rs`:** + +```rust +#[utoipa::path( + get, path = "/feed", + params(PaginationQuery), + responses((status = 200, description = "Home feed (followed users' thoughts)")), + security(("bearer_auth" = [])) +)] +pub async fn home_feed(...) { ... } + +#[utoipa::path( + get, path = "/feed/public", + params(PaginationQuery), + responses((status = 200, description = "Public feed (all local thoughts)")) +)] +pub async fn public_feed(...) { ... } + +#[utoipa::path( + get, path = "/search", + params(SearchQuery), + responses((status = 200, description = "Search results: {thoughts, users}")) +)] +pub async fn search_handler(...) { ... } + +#[utoipa::path( + get, path = "/users/{username}/thoughts", + params( + ("username" = String, Path, description = "Username"), + PaginationQuery, + ), + responses((status = 200, description = "User's public thoughts")), +)] +pub async fn user_thoughts_handler(...) { ... } + +#[utoipa::path( + get, path = "/tags/{name}", + params( + ("name" = String, Path, description = "Tag name"), + PaginationQuery, + ), + responses((status = 200, description = "Thoughts with this tag")), +)] +pub async fn tag_thoughts_handler(...) { ... } +``` + +- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/social.rs`:** + +```rust +#[utoipa::path(post, path = "/thoughts/{id}/like", params(("id" = Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Liked")), security(("bearer_auth" = [])))] +pub async fn post_like(...) { ... } + +#[utoipa::path(delete, path = "/thoughts/{id}/like", params(("id" = Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unliked")), security(("bearer_auth" = [])))] +pub async fn delete_like(...) { ... } + +#[utoipa::path(post, path = "/thoughts/{id}/boost", params(("id" = Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Boosted")), security(("bearer_auth" = [])))] +pub async fn post_boost(...) { ... } + +#[utoipa::path(delete, path = "/thoughts/{id}/boost", params(("id" = Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unboosted")), security(("bearer_auth" = [])))] +pub async fn delete_boost(...) { ... } + +#[utoipa::path(post, path = "/users/{id}/follow", params(("id" = Uuid, Path, description = "User ID")), responses((status = 204, description = "Following")), security(("bearer_auth" = [])))] +pub async fn post_follow(...) { ... } + +#[utoipa::path(delete, path = "/users/{id}/follow", params(("id" = Uuid, Path, description = "User ID")), responses((status = 204, description = "Unfollowed")), security(("bearer_auth" = [])))] +pub async fn delete_follow(...) { ... } + +#[utoipa::path(post, path = "/users/{id}/block", params(("id" = Uuid, Path, description = "User ID")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))] +pub async fn post_block(...) { ... } + +#[utoipa::path(delete, path = "/users/{id}/block", params(("id" = Uuid, Path, description = "User ID")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))] +pub async fn delete_block(...) { ... } + +#[utoipa::path( + put, path = "/users/me/top-friends", + request_body = SetTopFriendsRequest, + responses((status = 204, description = "Top friends updated")), + security(("bearer_auth" = [])) +)] +pub async fn put_top_friends(...) { ... } + +#[utoipa::path( + get, path = "/users/{username}/top-friends", + params(("username" = String, Path, description = "Username")), + responses((status = 200, description = "Top friends list")) +)] +pub async fn get_top_friends_handler(...) { ... } +``` + +- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/notifications.rs`:** + +```rust +#[utoipa::path( + get, path = "/notifications", + responses((status = 200, description = "Notification summary")), + security(("bearer_auth" = [])) +)] +pub async fn list_notifications(...) { ... } + +#[utoipa::path( + post, path = "/notifications/{id}/read", + params(("id" = Uuid, Path, description = "Notification ID")), + responses((status = 204, description = "Marked read")), + security(("bearer_auth" = [])) +)] +pub async fn mark_notification_read(...) { ... } + +#[utoipa::path( + post, path = "/notifications/read-all", + responses((status = 204, description = "All marked read")), + security(("bearer_auth" = [])) +)] +pub async fn mark_all_read(...) { ... } +``` + +- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/api_keys.rs`:** + +```rust +#[utoipa::path( + get, path = "/api-keys", + responses((status = 200, description = "List of API keys", body = Vec)), + security(("bearer_auth" = [])) +)] +pub async fn get_api_keys(...) { ... } + +#[utoipa::path( + post, path = "/api-keys", + request_body = CreateApiKeyRequest, + responses((status = 200, description = "Created API key — raw key shown once", body = CreatedApiKeyResponse)), + security(("bearer_auth" = [])) +)] +pub async fn post_api_key(...) { ... } + +#[utoipa::path( + delete, path = "/api-keys/{id}", + params(("id" = Uuid, Path, description = "API key ID")), + responses((status = 204, description = "Deleted")), + security(("bearer_auth" = [])) +)] +pub async fn delete_api_key_handler(...) { ... } +``` + +- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/health.rs`:** + +```rust +#[utoipa::path( + get, path = "/health", + responses((status = 200, description = "Service health status")) +)] +pub async fn health_handler(...) { ... } +``` + +- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. Fix any utoipa annotation compile errors (missing imports, wrong types). + +- [ ] **Commit:** + +```bash +git add crates/presentation/src/handlers/ +git commit -m "feat(presentation): add utoipa path annotations to all handlers" +``` + +--- + +### Task 3: OpenAPI doc modules + serve /docs and /scalar + +**Files:** `crates/presentation/src/openapi/` (all new), modify `routes.rs`, `lib.rs` + +- [ ] **Create `crates/presentation/src/openapi/auth.rs`:** + +```rust +use utoipa::OpenApi; +use api_types::{requests::{LoginRequest, RegisterRequest}, responses::{AuthResponse, ErrorResponse}}; + +#[derive(OpenApi)] +#[openapi( + paths(crate::handlers::auth::post_register, crate::handlers::auth::post_login), + components(schemas(RegisterRequest, LoginRequest, AuthResponse, ErrorResponse)) +)] +pub struct AuthDoc; +``` + +- [ ] **Create `crates/presentation/src/openapi/users.rs`:** + +```rust +use utoipa::OpenApi; +use api_types::{requests::UpdateProfileRequest, responses::{UserResponse, ErrorResponse}}; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::users::get_me, + crate::handlers::users::get_user, + crate::handlers::users::patch_profile, + ), + components(schemas(UserResponse, UpdateProfileRequest, ErrorResponse)) +)] +pub struct UsersDoc; +``` + +- [ ] **Create `crates/presentation/src/openapi/thoughts.rs`:** + +```rust +use utoipa::OpenApi; +use api_types::{requests::{CreateThoughtRequest, EditThoughtRequest}, responses::ErrorResponse}; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::thoughts::post_thought, + crate::handlers::thoughts::get_thought_handler, + crate::handlers::thoughts::patch_thought, + crate::handlers::thoughts::delete_thought_handler, + crate::handlers::thoughts::get_thread_handler, + ), + components(schemas(CreateThoughtRequest, EditThoughtRequest, ErrorResponse)) +)] +pub struct ThoughtsDoc; +``` + +- [ ] **Create `crates/presentation/src/openapi/feed.rs`:** + +```rust +use utoipa::OpenApi; +use api_types::requests::{PaginationQuery, SearchQuery}; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::feed::home_feed, + crate::handlers::feed::public_feed, + crate::handlers::feed::search_handler, + crate::handlers::feed::user_thoughts_handler, + crate::handlers::feed::tag_thoughts_handler, + ), + components(schemas(PaginationQuery, SearchQuery)) +)] +pub struct FeedDoc; +``` + +- [ ] **Create `crates/presentation/src/openapi/social.rs`:** + +```rust +use utoipa::OpenApi; +use api_types::requests::SetTopFriendsRequest; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::social::post_like, + crate::handlers::social::delete_like, + crate::handlers::social::post_boost, + crate::handlers::social::delete_boost, + crate::handlers::social::post_follow, + crate::handlers::social::delete_follow, + crate::handlers::social::post_block, + crate::handlers::social::delete_block, + crate::handlers::social::put_top_friends, + crate::handlers::social::get_top_friends_handler, + ), + components(schemas(SetTopFriendsRequest)) +)] +pub struct SocialDoc; +``` + +- [ ] **Create `crates/presentation/src/openapi/notifications.rs`:** + +```rust +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi(paths( + crate::handlers::notifications::list_notifications, + crate::handlers::notifications::mark_notification_read, + crate::handlers::notifications::mark_all_read, +))] +pub struct NotificationsDoc; +``` + +- [ ] **Create `crates/presentation/src/openapi/api_keys.rs`:** + +```rust +use utoipa::OpenApi; +use api_types::{requests::CreateApiKeyRequest, responses::{ApiKeyResponse, CreatedApiKeyResponse}}; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::handlers::api_keys::get_api_keys, + crate::handlers::api_keys::post_api_key, + crate::handlers::api_keys::delete_api_key_handler, + ), + components(schemas(CreateApiKeyRequest, ApiKeyResponse, CreatedApiKeyResponse)) +)] +pub struct ApiKeysDoc; +``` + +- [ ] **Create `crates/presentation/src/openapi/health.rs`:** + +```rust +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi(paths(crate::handlers::health::health_handler))] +pub struct HealthDoc; +``` + +- [ ] **Create `crates/presentation/src/openapi/mod.rs`:** + +```rust +mod api_keys; +mod auth; +mod feed; +mod health; +mod notifications; +mod social; +mod thoughts; +mod users; + +use axum::Router; +use utoipa::{ + Modify, OpenApi, + openapi::security::{ApiKey, ApiKeyValue, Http, HttpAuthScheme, SecurityScheme}, +}; +use utoipa_scalar::{Scalar, Servable}; +use utoipa_swagger_ui::SwaggerUi; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "bearer_auth", + SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)), + ); + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-Api-Key"))), + ); + } +} + +fn build() -> utoipa::openapi::OpenApi { + let mut api = auth::AuthDoc::openapi(); + api.info = utoipa::openapi::InfoBuilder::new() + .title("Thoughts API") + .version("2.0.0") + .description(Some( + "Federated social network API. Authenticate via `POST /auth/login` to get a Bearer token, \ + or use `X-Api-Key` header with a key from `POST /api-keys`." + )) + .build(); + api.merge(users::UsersDoc::openapi()); + api.merge(thoughts::ThoughtsDoc::openapi()); + api.merge(feed::FeedDoc::openapi()); + api.merge(social::SocialDoc::openapi()); + api.merge(notifications::NotificationsDoc::openapi()); + api.merge(api_keys::ApiKeysDoc::openapi()); + api.merge(health::HealthDoc::openapi()); + SecurityAddon.modify(&mut api); + api +} + +pub fn serve(router: Router) -> Router { + tracing::info!("API docs at /docs (Swagger UI) and /scalar (Scalar)"); + let spec = build(); + router + .merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone())) + .merge(Scalar::with_url("/scalar", spec)) +} +``` + +- [ ] **Add `pub mod openapi;`** to `crates/presentation/src/lib.rs`. + +- [ ] **Call `openapi::serve` in `crates/presentation/src/routes.rs`** — update the final return in `router()`: + +```rust +pub fn router(fed_config: &ApFederationConfig) -> Router { + let api_routes = Router::new() + // ... all existing routes unchanged ... + ; + + let ap_routes = Router::new() + // ... all existing AP routes unchanged ... + ; + + let combined = Router::new() + .merge(api_routes) + .merge(ap_routes) + .layer(FederationMiddleware::new(fed_config.0.clone())); + + openapi::serve(combined) +} +``` + +Note: `openapi::serve` takes the combined router and merges the `/docs` and `/scalar` routes. Since it returns `Router` and the swagger/scalar routes don't need state, this works cleanly. + +- [ ] **Run:** `cargo build -p presentation` — Expected: clean build. + +- [ ] **Smoke test:** + +```bash +DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \ +JWT_SECRET=dev BASE_URL=http://localhost:3000 \ +cargo run -p presentation & +sleep 3 + +# Verify OpenAPI JSON is valid +curl -s http://localhost:3000/openapi.json | jq '.info.title' +# Expected: "Thoughts API" + +# Verify docs pages load +curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/docs/ +# Expected: 200 + +curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/scalar +# Expected: 200 + +kill %1 +``` + +- [ ] **Run full test suite:** + +```bash +DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3 +``` + +Expected: all tests pass. + +- [ ] **Commit:** + +```bash +git add crates/presentation/src/openapi/ \ + crates/presentation/src/lib.rs \ + crates/presentation/src/routes.rs +git commit -m "feat(presentation): OpenAPI docs at /docs (Swagger) and /scalar" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ All REST handlers annotated with `#[utoipa::path]` (Task 2) +- ✅ All request DTOs get `ToSchema` or `IntoParams` (Task 1) +- ✅ All response DTOs get `ToSchema` (Task 1) +- ✅ `CreatedApiKeyResponse` added for the create-key endpoint (Task 1) +- ✅ 8 feature-grouped doc structs assembled in `openapi/mod.rs` (Task 3) +- ✅ Both Bearer token and X-Api-Key security schemes registered (Task 3) +- ✅ `/docs` (Swagger UI) and `/scalar` served (Task 3) +- ✅ `/openapi.json` served (Task 3) + +**Placeholder scan:** None. + +**Type consistency:** +- `CreatedApiKeyResponse` defined in responses.rs (Task 1), referenced in `api_keys.rs` openapi module (Task 3) and annotated in handler (Task 2) +- `PaginationQuery` and `SearchQuery` get `IntoParams` (not `ToSchema`) — correct for query params +- `openapi::serve` takes `Router` generic — works with `Router` from routes.rs + +**Notes:** +- `utoipa-swagger-ui` with `"vendored"` feature bundles the Swagger UI static assets — no CDN dependency +- Handlers returning `serde_json::Value` get response descriptions without body schemas — still useful for documenting status codes and security requirements +- ActivityPub endpoints (inbox, outbox, webfinger, nodeinfo) are intentionally excluded — they serve AP JSON-LD, not REST JSON