# 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