Files
thoughts/docs/superpowers/plans/2026-05-14-openapi-docs.md

26 KiB

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:

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:
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:

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<Uuid>,
    /// One of: "public", "followers", "unlisted", "direct"
    pub visibility: Option<String>,
    pub content_warning: Option<String>,
    pub sensitive: Option<bool>,
}

#[derive(Deserialize, utoipa::ToSchema)]
pub struct EditThoughtRequest {
    pub content: String,
}

#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateProfileRequest {
    pub display_name: Option<String>,
    pub bio: Option<String>,
    pub avatar_url: Option<String>,
    pub header_url: Option<String>,
    pub custom_css: Option<String>,
}

#[derive(Deserialize, utoipa::ToSchema)]
pub struct SetTopFriendsRequest {
    /// Ordered list of user UUIDs, max 8
    pub friend_ids: Vec<Uuid>,
}

#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateApiKeyRequest {
    pub name: String,
}

#[derive(Deserialize, utoipa::IntoParams)]
pub struct PaginationQuery {
    pub page: Option<u64>,
    pub per_page: Option<u64>,
}
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<u64>,
    pub per_page: Option<u64>,
}
  • Add #[derive(utoipa::ToSchema)] to crates/api-types/src/responses.rs:

Replace the file with:

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<String>,
    pub bio: Option<String>,
    pub avatar_url: Option<String>,
    pub header_url: Option<String>,
    pub local: bool,
    pub created_at: DateTime<Utc>,
}

#[derive(Serialize, Clone, utoipa::ToSchema)]
pub struct ThoughtResponse {
    pub id: Uuid,
    pub content: String,
    pub author: UserResponse,
    pub in_reply_to_id: Option<Uuid>,
    pub visibility: String,
    pub content_warning: Option<String>,
    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<Utc>,
    pub updated_at: Option<DateTime<Utc>>,
}

#[derive(Serialize, utoipa::ToSchema)]
pub struct PagedResponse<T: Serialize + utoipa::ToSchema> {
    pub items: Vec<T>,
    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<Utc>,
}

#[derive(Serialize, utoipa::ToSchema)]
pub struct NotificationResponse {
    pub id: Uuid,
    pub notification_type: String,
    pub from_user: Option<UserResponse>,
    pub thought_id: Option<Uuid>,
    pub read: bool,
    pub created_at: DateTime<Utc>,
}

#[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:

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:
#[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:
#[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:
#[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:
#[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:
#[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:
#[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:
#[utoipa::path(
    get, path = "/api-keys",
    responses((status = 200, description = "List of API keys", body = Vec<ApiKeyResponse>)),
    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:
#[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:

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:
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:
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:
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:
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:
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:
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:
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:
use utoipa::OpenApi;

#[derive(OpenApi)]
#[openapi(paths(crate::handlers::health::health_handler))]
pub struct HealthDoc;
  • Create crates/presentation/src/openapi/mod.rs:
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<S: Clone + Send + Sync + 'static>(router: Router<S>) -> Router<S> {
    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():

pub fn router(fed_config: &ApFederationConfig) -> Router<AppState> {
    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<AppState> and the swagger/scalar routes don't need state, this works cleanly.

  • Run: cargo build -p presentation — Expected: clean build.

  • Smoke test:

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:
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3

Expected: all tests pass.

  • Commit:
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<S> generic — works with Router<AppState> 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