Compare commits
4 Commits
2524440fe4
...
1866eef770
| Author | SHA1 | Date | |
|---|---|---|---|
| 1866eef770 | |||
| 137d1a0c6a | |||
| 4f990afe5e | |||
| fb8c75af72 |
@@ -7,3 +7,4 @@ edition = "2021"
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
utoipa = { version = "5.5.0", features = ["uuid", "chrono"] }
|
||||||
|
|||||||
@@ -1,34 +1,37 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
pub struct RegisterRequest {
|
pub struct RegisterRequest {
|
||||||
|
/// Username (1-32 chars, alphanumeric + underscore)
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
pub struct CreateThoughtRequest {
|
pub struct CreateThoughtRequest {
|
||||||
|
/// Up to 128 characters
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub in_reply_to_id: Option<Uuid>,
|
pub in_reply_to_id: Option<Uuid>,
|
||||||
|
/// One of: "public", "followers", "unlisted", "direct"
|
||||||
pub visibility: Option<String>,
|
pub visibility: Option<String>,
|
||||||
pub content_warning: Option<String>,
|
pub content_warning: Option<String>,
|
||||||
pub sensitive: Option<bool>,
|
pub sensitive: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
pub struct EditThoughtRequest {
|
pub struct EditThoughtRequest {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
pub struct UpdateProfileRequest {
|
pub struct UpdateProfileRequest {
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
@@ -37,17 +40,18 @@ pub struct UpdateProfileRequest {
|
|||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
pub struct SetTopFriendsRequest {
|
pub struct SetTopFriendsRequest {
|
||||||
|
/// Ordered list of user UUIDs, max 8
|
||||||
pub friend_ids: Vec<Uuid>,
|
pub friend_ids: Vec<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, utoipa::ToSchema)]
|
||||||
pub struct CreateApiKeyRequest {
|
pub struct CreateApiKeyRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, utoipa::IntoParams)]
|
||||||
pub struct PaginationQuery {
|
pub struct PaginationQuery {
|
||||||
pub page: Option<u64>,
|
pub page: Option<u64>,
|
||||||
pub per_page: Option<u64>,
|
pub per_page: Option<u64>,
|
||||||
@@ -63,7 +67,7 @@ impl PaginationQuery {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize, utoipa::IntoParams)]
|
||||||
pub struct SearchQuery {
|
pub struct SearchQuery {
|
||||||
pub q: String,
|
pub q: String,
|
||||||
pub page: Option<u64>,
|
pub page: Option<u64>,
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub user: UserResponse,
|
pub user: UserResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone, utoipa::ToSchema)]
|
||||||
pub struct UserResponse {
|
pub struct UserResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -20,7 +20,7 @@ pub struct UserResponse {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone, utoipa::ToSchema)]
|
||||||
pub struct ThoughtResponse {
|
pub struct ThoughtResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
@@ -38,22 +38,22 @@ pub struct ThoughtResponse {
|
|||||||
pub updated_at: Option<DateTime<Utc>>,
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
pub struct PagedResponse<T: Serialize> {
|
pub struct PagedResponse<T: Serialize + utoipa::ToSchema> {
|
||||||
pub items: Vec<T>,
|
pub items: Vec<T>,
|
||||||
pub total: i64,
|
pub total: i64,
|
||||||
pub page: u64,
|
pub page: u64,
|
||||||
pub per_page: u64,
|
pub per_page: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
pub struct ApiKeyResponse {
|
pub struct ApiKeyResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
pub struct NotificationResponse {
|
pub struct NotificationResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub notification_type: String,
|
pub notification_type: String,
|
||||||
@@ -63,7 +63,15 @@ pub struct NotificationResponse {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, utoipa::ToSchema)]
|
||||||
pub struct ErrorResponse {
|
pub struct ErrorResponse {
|
||||||
pub error: String,
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ activitypub-base = { workspace = true }
|
|||||||
postgres-federation = { workspace = true }
|
postgres-federation = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
activitypub_federation = "0.7.0-beta.11"
|
activitypub_federation = "0.7.0-beta.11"
|
||||||
|
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"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
use axum::{extract::{Path, State}, http::StatusCode, Json};
|
use axum::{extract::{Path, State}, http::StatusCode, Json};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use api_types::{requests::CreateApiKeyRequest, responses::ApiKeyResponse};
|
use api_types::{requests::CreateApiKeyRequest, responses::{ApiKeyResponse, CreatedApiKeyResponse}};
|
||||||
use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys};
|
use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys};
|
||||||
use domain::value_objects::ApiKeyId;
|
use domain::value_objects::ApiKeyId;
|
||||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/api-keys", responses((status = 200, description = "API keys", body = Vec<ApiKeyResponse>)), security(("bearer_auth" = [])))]
|
||||||
pub async fn get_api_keys(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Json<Vec<ApiKeyResponse>>, ApiError> {
|
pub async fn get_api_keys(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Json<Vec<ApiKeyResponse>>, ApiError> {
|
||||||
let keys = list_api_keys(&*s.api_keys, &uid).await?;
|
let keys = list_api_keys(&*s.api_keys, &uid).await?;
|
||||||
Ok(Json(keys.into_iter().map(|k| ApiKeyResponse { id: k.id.as_uuid(), name: k.name, created_at: k.created_at }).collect()))
|
Ok(Json(keys.into_iter().map(|k| ApiKeyResponse { id: k.id.as_uuid(), name: k.name, created_at: k.created_at }).collect()))
|
||||||
}
|
}
|
||||||
|
#[utoipa::path(post, path = "/api-keys", request_body = CreateApiKeyRequest, responses((status = 200, description = "Created — raw key shown once", body = CreatedApiKeyResponse)), security(("bearer_auth" = [])))]
|
||||||
pub async fn post_api_key(State(s): State<AppState>, AuthUser(uid): AuthUser, Json(body): Json<CreateApiKeyRequest>) -> Result<Json<serde_json::Value>, ApiError> {
|
pub async fn post_api_key(State(s): State<AppState>, AuthUser(uid): AuthUser, Json(body): Json<CreateApiKeyRequest>) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let (key, raw) = create_api_key(&*s.api_keys, &uid, body.name).await?;
|
let (key, raw) = create_api_key(&*s.api_keys, &uid, body.name).await?;
|
||||||
Ok(Json(serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw })))
|
Ok(Json(serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw })))
|
||||||
}
|
}
|
||||||
|
#[utoipa::path(delete, path = "/api-keys/{id}", params(("id" = uuid::Uuid, Path, description = "Key ID")), responses((status = 204, description = "Deleted")), security(("bearer_auth" = [])))]
|
||||||
pub async fn delete_api_key_handler(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
pub async fn delete_api_key_handler(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
delete_api_key(&*s.api_keys, &uid, &ApiKeyId::from_uuid(id)).await?;
|
delete_api_key(&*s.api_keys, &uid, &ApiKeyId::from_uuid(id)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||||
use api_types::{requests::{LoginRequest, RegisterRequest}, responses::{AuthResponse, UserResponse}};
|
use api_types::{requests::{LoginRequest, RegisterRequest}, responses::{AuthResponse, ErrorResponse, UserResponse}};
|
||||||
use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
|
use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
|
||||||
use crate::{errors::ApiError, state::AppState};
|
use crate::{errors::ApiError, state::AppState};
|
||||||
|
|
||||||
@@ -16,6 +16,15 @@ pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(State(s): State<AppState>, Json(body): Json<RegisterRequest>) -> Result<impl IntoResponse, ApiError> {
|
pub async fn post_register(State(s): State<AppState>, Json(body): Json<RegisterRequest>) -> Result<impl IntoResponse, ApiError> {
|
||||||
let out = register(&*s.users, &*s.hasher, &*s.auth, &*s.events, RegisterInput {
|
let out = register(&*s.users, &*s.hasher, &*s.auth, &*s.events, RegisterInput {
|
||||||
username: body.username,
|
username: body.username,
|
||||||
@@ -26,6 +35,14 @@ pub async fn post_register(State(s): State<AppState>, Json(body): Json<RegisterR
|
|||||||
Ok((StatusCode::CREATED, Json(resp)))
|
Ok((StatusCode::CREATED, Json(resp)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(State(s): State<AppState>, Json(body): Json<LoginRequest>) -> Result<impl IntoResponse, ApiError> {
|
pub async fn post_login(State(s): State<AppState>, Json(body): Json<LoginRequest>) -> Result<impl IntoResponse, ApiError> {
|
||||||
let out = login(&*s.users, &*s.hasher, &*s.auth, LoginInput {
|
let out = login(&*s.users, &*s.hasher, &*s.auth, LoginInput {
|
||||||
email: body.email,
|
email: body.email,
|
||||||
|
|||||||
@@ -5,18 +5,34 @@ use domain::models::feed::PageParams;
|
|||||||
use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState};
|
use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState};
|
||||||
use application::use_cases::profile::get_user_by_username;
|
use application::use_cases::profile::get_user_by_username;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/feed",
|
||||||
|
params(PaginationQuery),
|
||||||
|
responses((status = 200, description = "Home feed")),
|
||||||
|
security(("bearer_auth" = []))
|
||||||
|
)]
|
||||||
pub async fn home_feed(State(s): State<AppState>, AuthUser(uid): AuthUser, Query(q): Query<PaginationQuery>) -> Result<Json<serde_json::Value>, ApiError> {
|
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 page = PageParams { page: q.page(), per_page: q.per_page() };
|
||||||
let result = get_home_feed(&*s.feed, &*s.follows, &uid, page).await?;
|
let result = get_home_feed(&*s.feed, &*s.follows, &uid, page).await?;
|
||||||
Ok(Json(serde_json::json!({ "items": result.items.iter().map(|e| e.thought.id.as_uuid()).collect::<Vec<_>>(), "total": result.total, "page": result.page })))
|
Ok(Json(serde_json::json!({ "items": result.items.iter().map(|e| e.thought.id.as_uuid()).collect::<Vec<_>>(), "total": result.total, "page": result.page })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/feed/public",
|
||||||
|
params(PaginationQuery),
|
||||||
|
responses((status = 200, description = "Public feed"))
|
||||||
|
)]
|
||||||
pub async fn public_feed(State(s): State<AppState>, OptionalAuthUser(viewer): OptionalAuthUser, Query(q): Query<PaginationQuery>) -> Result<Json<serde_json::Value>, ApiError> {
|
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 page = PageParams { page: q.page(), per_page: q.per_page() };
|
||||||
let result = get_public_feed(&*s.feed, viewer.as_ref(), page).await?;
|
let result = get_public_feed(&*s.feed, viewer.as_ref(), page).await?;
|
||||||
Ok(Json(serde_json::json!({ "items": result.items.iter().map(|e| e.thought.id.as_uuid()).collect::<Vec<_>>(), "total": result.total, "page": result.page })))
|
Ok(Json(serde_json::json!({ "items": result.items.iter().map(|e| e.thought.id.as_uuid()).collect::<Vec<_>>(), "total": result.total, "page": result.page })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/search",
|
||||||
|
params(SearchQuery),
|
||||||
|
responses((status = 200, description = "Search results: thoughts and users"))
|
||||||
|
)]
|
||||||
pub async fn search_handler(
|
pub async fn search_handler(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
@@ -63,6 +79,14 @@ pub async fn get_followers_handler(State(s): State<AppState>, Path(username): Pa
|
|||||||
Ok(Json(serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::<Vec<_>>() })))
|
Ok(Json(serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::<Vec<_>>() })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(
|
pub async fn user_thoughts_handler(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
@@ -88,6 +112,14 @@ 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(
|
pub async fn tag_thoughts_handler(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
Path(tag_name): Path<String>,
|
Path(tag_name): Path<String>,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/health", responses((status = 200, description = "Service health status")))]
|
||||||
pub async fn health_handler(State(s): State<AppState>) -> Json<serde_json::Value> {
|
pub async fn health_handler(State(s): State<AppState>) -> Json<serde_json::Value> {
|
||||||
let db_ok = s.users.list_with_stats().await.is_ok();
|
let db_ok = s.users.list_with_stats().await.is_ok();
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ use uuid::Uuid;
|
|||||||
use domain::{models::feed::PageParams, value_objects::NotificationId};
|
use domain::{models::feed::PageParams, value_objects::NotificationId};
|
||||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||||
|
|
||||||
|
#[utoipa::path(get, path = "/notifications", responses((status = 200, description = "Notification summary")), security(("bearer_auth" = [])))]
|
||||||
pub async fn list_notifications(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Json<serde_json::Value>, ApiError> {
|
pub async fn list_notifications(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let page = PageParams { page: 1, per_page: 20 };
|
let page = PageParams { page: 1, per_page: 20 };
|
||||||
let result = s.notifications.list_for_user(&uid, &page).await?;
|
let result = s.notifications.list_for_user(&uid, &page).await?;
|
||||||
Ok(Json(serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() })))
|
Ok(Json(serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() })))
|
||||||
}
|
}
|
||||||
|
#[utoipa::path(post, path = "/notifications/{id}/read", params(("id" = uuid::Uuid, Path, description = "Notification ID")), responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]
|
||||||
pub async fn mark_notification_read(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
pub async fn mark_notification_read(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
s.notifications.mark_read(&NotificationId::from_uuid(id), &uid).await?;
|
s.notifications.mark_read(&NotificationId::from_uuid(id), &uid).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
#[utoipa::path(post, path = "/notifications/read-all", responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))]
|
||||||
pub async fn mark_all_read(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<StatusCode, ApiError> {
|
pub async fn mark_all_read(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<StatusCode, ApiError> {
|
||||||
s.notifications.mark_all_read(&uid).await?;
|
s.notifications.mark_all_read(&uid).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
|||||||
@@ -6,43 +6,53 @@ use application::use_cases::profile::{get_top_friends, set_top_friends, get_user
|
|||||||
use domain::value_objects::{ThoughtId, UserId};
|
use domain::value_objects::{ThoughtId, UserId};
|
||||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||||
|
|
||||||
|
#[utoipa::path(post, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Liked")), security(("bearer_auth" = [])))]
|
||||||
pub async fn post_like(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
pub async fn post_like(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
like_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
like_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
#[utoipa::path(delete, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unliked")), security(("bearer_auth" = [])))]
|
||||||
pub async fn delete_like(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
pub async fn delete_like(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
unlike_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
unlike_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
#[utoipa::path(post, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Boosted")), security(("bearer_auth" = [])))]
|
||||||
pub async fn post_boost(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
pub async fn post_boost(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
boost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
boost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
#[utoipa::path(delete, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unboosted")), security(("bearer_auth" = [])))]
|
||||||
pub async fn delete_boost(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
pub async fn delete_boost(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
unboost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
unboost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
#[utoipa::path(post, path = "/users/{id}/follow", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Following")), security(("bearer_auth" = [])))]
|
||||||
pub async fn post_follow(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
pub async fn post_follow(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
follow_user(&*s.follows, &*s.events, &uid, &UserId::from_uuid(target)).await?;
|
follow_user(&*s.follows, &*s.events, &uid, &UserId::from_uuid(target)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
#[utoipa::path(delete, path = "/users/{id}/follow", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Unfollowed")), security(("bearer_auth" = [])))]
|
||||||
pub async fn delete_follow(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
pub async fn delete_follow(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
unfollow_user(&*s.follows, &*s.events, &uid, &UserId::from_uuid(target)).await?;
|
unfollow_user(&*s.follows, &*s.events, &uid, &UserId::from_uuid(target)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
#[utoipa::path(post, path = "/users/{id}/block", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))]
|
||||||
pub async fn post_block(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
pub async fn post_block(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
block_user(&*s.blocks, &*s.events, &uid, &UserId::from_uuid(target)).await?;
|
block_user(&*s.blocks, &*s.events, &uid, &UserId::from_uuid(target)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
#[utoipa::path(delete, path = "/users/{id}/block", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))]
|
||||||
pub async fn delete_block(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
pub async fn delete_block(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
unblock_user(&*s.blocks, &*s.events, &uid, &UserId::from_uuid(target)).await?;
|
unblock_user(&*s.blocks, &*s.events, &uid, &UserId::from_uuid(target)).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
#[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(State(s): State<AppState>, AuthUser(uid): AuthUser, Json(body): Json<SetTopFriendsRequest>) -> Result<StatusCode, ApiError> {
|
pub async fn put_top_friends(State(s): State<AppState>, AuthUser(uid): AuthUser, Json(body): Json<SetTopFriendsRequest>) -> Result<StatusCode, ApiError> {
|
||||||
let ids: Vec<UserId> = body.friend_ids.into_iter().map(UserId::from_uuid).collect();
|
let ids: Vec<UserId> = body.friend_ids.into_iter().map(UserId::from_uuid).collect();
|
||||||
set_top_friends(&*s.top_friends, &uid, ids).await?;
|
set_top_friends(&*s.top_friends, &uid, ids).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
#[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(State(s): State<AppState>, Path(username): Path<String>) -> Result<Json<serde_json::Value>, ApiError> {
|
pub async fn get_top_friends_handler(State(s): State<AppState>, Path(username): Path<String>) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let user = get_user_by_username(&*s.users, &username).await?;
|
let user = get_user_by_username(&*s.users, &username).await?;
|
||||||
let friends = get_top_friends(&*s.top_friends, &user.id).await?;
|
let friends = get_top_friends(&*s.top_friends, &user.id).await?;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, Json};
|
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, Json};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use api_types::requests::{CreateThoughtRequest, EditThoughtRequest};
|
use api_types::{requests::{CreateThoughtRequest, EditThoughtRequest}, responses::ErrorResponse};
|
||||||
use application::use_cases::thoughts::{create_thought, delete_thought, edit_thought, get_thought, get_thread, CreateThoughtInput};
|
use application::use_cases::thoughts::{create_thought, delete_thought, edit_thought, get_thought, get_thread, CreateThoughtInput};
|
||||||
use domain::value_objects::ThoughtId;
|
use domain::value_objects::ThoughtId;
|
||||||
use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState};
|
use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState};
|
||||||
@@ -22,6 +22,16 @@ fn thought_to_json(t: &domain::models::thought::Thought, author: &domain::models
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(State(s): State<AppState>, AuthUser(uid): AuthUser, Json(body): Json<CreateThoughtRequest>) -> Result<impl IntoResponse, ApiError> {
|
pub async fn post_thought(State(s): State<AppState>, AuthUser(uid): AuthUser, Json(body): Json<CreateThoughtRequest>) -> Result<impl IntoResponse, ApiError> {
|
||||||
let in_reply_to = body.in_reply_to_id.map(ThoughtId::from_uuid);
|
let in_reply_to = body.in_reply_to_id.map(ThoughtId::from_uuid);
|
||||||
let out = create_thought(&*s.thoughts, &*s.users, &*s.events, CreateThoughtInput {
|
let out = create_thought(&*s.thoughts, &*s.users, &*s.events, CreateThoughtInput {
|
||||||
@@ -36,22 +46,58 @@ pub async fn post_thought(State(s): State<AppState>, AuthUser(uid): AuthUser, Js
|
|||||||
Ok((StatusCode::CREATED, Json(thought_to_json(&out.thought, &author, 0, 0, 0))))
|
Ok((StatusCode::CREATED, Json(thought_to_json(&out.thought, &author, 0, 0, 0))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/thoughts/{id}",
|
||||||
|
params(("id" = uuid::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(State(s): State<AppState>, Path(id): Path<Uuid>, OptionalAuthUser(_viewer): OptionalAuthUser) -> Result<Json<serde_json::Value>, ApiError> {
|
pub async fn get_thought_handler(State(s): State<AppState>, Path(id): Path<Uuid>, OptionalAuthUser(_viewer): OptionalAuthUser) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let thought = get_thought(&*s.thoughts, &ThoughtId::from_uuid(id)).await?;
|
let thought = get_thought(&*s.thoughts, &ThoughtId::from_uuid(id)).await?;
|
||||||
let author = s.users.find_by_id(&thought.user_id).await?.ok_or(domain::errors::DomainError::NotFound)?;
|
let author = s.users.find_by_id(&thought.user_id).await?.ok_or(domain::errors::DomainError::NotFound)?;
|
||||||
Ok(Json(thought_to_json(&thought, &author, 0, 0, 0)))
|
Ok(Json(thought_to_json(&thought, &author, 0, 0, 0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete, path = "/thoughts/{id}",
|
||||||
|
params(("id" = uuid::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(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
pub async fn delete_thought_handler(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
delete_thought(&*s.thoughts, &*s.events, &ThoughtId::from_uuid(id), &uid).await?;
|
delete_thought(&*s.thoughts, &*s.events, &ThoughtId::from_uuid(id), &uid).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
patch, path = "/thoughts/{id}",
|
||||||
|
params(("id" = uuid::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(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>, Json(body): Json<EditThoughtRequest>) -> Result<StatusCode, ApiError> {
|
pub async fn patch_thought(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>, Json(body): Json<EditThoughtRequest>) -> Result<StatusCode, ApiError> {
|
||||||
edit_thought(&*s.thoughts, &*s.events, &ThoughtId::from_uuid(id), &uid, body.content).await?;
|
edit_thought(&*s.thoughts, &*s.events, &ThoughtId::from_uuid(id), &uid, body.content).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/thoughts/{id}/thread",
|
||||||
|
params(("id" = uuid::Uuid, Path, description = "Root thought ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Thread (root + replies)"),
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn get_thread_handler(State(s): State<AppState>, Path(id): Path<Uuid>) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
|
pub async fn get_thread_handler(State(s): State<AppState>, Path(id): Path<Uuid>) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
|
||||||
let thoughts = get_thread(&*s.thoughts, &ThoughtId::from_uuid(id)).await?;
|
let thoughts = get_thread(&*s.thoughts, &ThoughtId::from_uuid(id)).await?;
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
|
|||||||
@@ -1,19 +1,44 @@
|
|||||||
use axum::{extract::{Path, State}, Json};
|
use axum::{extract::{Path, State}, Json};
|
||||||
use api_types::{requests::UpdateProfileRequest, responses::UserResponse};
|
use api_types::{requests::UpdateProfileRequest, responses::{ErrorResponse, UserResponse}};
|
||||||
use application::use_cases::profile::{get_user_by_username, update_profile};
|
use application::use_cases::profile::{get_user_by_username, update_profile};
|
||||||
use crate::{errors::ApiError, extractors::AuthUser, handlers::auth::to_user_response, state::AppState};
|
use crate::{errors::ApiError, extractors::AuthUser, handlers::auth::to_user_response, state::AppState};
|
||||||
|
|
||||||
|
#[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(State(s): State<AppState>, Path(username): Path<String>) -> Result<Json<UserResponse>, ApiError> {
|
pub async fn get_user(State(s): State<AppState>, Path(username): Path<String>) -> Result<Json<UserResponse>, ApiError> {
|
||||||
let user = get_user_by_username(&*s.users, &username).await?;
|
let user = get_user_by_username(&*s.users, &username).await?;
|
||||||
Ok(Json(to_user_response(&user)))
|
Ok(Json(to_user_response(&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(State(s): State<AppState>, AuthUser(uid): AuthUser, Json(body): Json<UpdateProfileRequest>) -> Result<Json<UserResponse>, ApiError> {
|
pub async fn patch_profile(State(s): State<AppState>, AuthUser(uid): AuthUser, Json(body): Json<UpdateProfileRequest>) -> Result<Json<UserResponse>, ApiError> {
|
||||||
update_profile(&*s.users, &uid, body.display_name, body.bio, body.avatar_url, body.header_url, body.custom_css).await?;
|
update_profile(&*s.users, &uid, body.display_name, body.bio, body.avatar_url, body.header_url, body.custom_css).await?;
|
||||||
let user = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?;
|
let user = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?;
|
||||||
Ok(Json(to_user_response(&user)))
|
Ok(Json(to_user_response(&user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Json<UserResponse>, ApiError> {
|
pub async fn get_me(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Json<UserResponse>, ApiError> {
|
||||||
let user = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?;
|
let user = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?;
|
||||||
Ok(Json(to_user_response(&user)))
|
Ok(Json(to_user_response(&user)))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod errors;
|
pub mod errors;
|
||||||
|
pub mod openapi;
|
||||||
pub mod extractors;
|
pub mod extractors;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|||||||
13
crates/presentation/src/openapi/api_keys.rs
Normal file
13
crates/presentation/src/openapi/api_keys.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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;
|
||||||
9
crates/presentation/src/openapi/auth.rs
Normal file
9
crates/presentation/src/openapi/auth.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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;
|
||||||
13
crates/presentation/src/openapi/feed.rs
Normal file
13
crates/presentation/src/openapi/feed.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
),
|
||||||
|
)]
|
||||||
|
pub struct FeedDoc;
|
||||||
5
crates/presentation/src/openapi/health.rs
Normal file
5
crates/presentation/src/openapi/health.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
|
#[derive(OpenApi)]
|
||||||
|
#[openapi(paths(crate::handlers::health::health_handler))]
|
||||||
|
pub struct HealthDoc;
|
||||||
61
crates/presentation/src/openapi/mod.rs
Normal file
61
crates/presentation/src/openapi/mod.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
9
crates/presentation/src/openapi/notifications.rs
Normal file
9
crates/presentation/src/openapi/notifications.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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;
|
||||||
20
crates/presentation/src/openapi/social.rs
Normal file
20
crates/presentation/src/openapi/social.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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;
|
||||||
15
crates/presentation/src/openapi/thoughts.rs
Normal file
15
crates/presentation/src/openapi/thoughts.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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;
|
||||||
13
crates/presentation/src/openapi/users.rs
Normal file
13
crates/presentation/src/openapi/users.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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;
|
||||||
@@ -12,7 +12,7 @@ use activitypub_base::{
|
|||||||
ApFederationConfig,
|
ApFederationConfig,
|
||||||
};
|
};
|
||||||
use activitypub_federation::config::FederationMiddleware;
|
use activitypub_federation::config::FederationMiddleware;
|
||||||
use crate::{handlers::*, state::AppState};
|
use crate::{handlers::*, openapi, state::AppState};
|
||||||
|
|
||||||
pub fn router(fed_config: &ApFederationConfig) -> Router<AppState> {
|
pub fn router(fed_config: &ApFederationConfig) -> Router<AppState> {
|
||||||
let api_routes = Router::new()
|
let api_routes = Router::new()
|
||||||
@@ -79,8 +79,10 @@ pub fn router(fed_config: &ApFederationConfig) -> Router<AppState> {
|
|||||||
.route("/users/{username}/followers", get(followers_handler))
|
.route("/users/{username}/followers", get(followers_handler))
|
||||||
.route("/users/{username}/following", get(following_handler));
|
.route("/users/{username}/following", get(following_handler));
|
||||||
|
|
||||||
Router::new()
|
let combined = Router::new()
|
||||||
.merge(api_routes)
|
.merge(api_routes)
|
||||||
.merge(ap_routes)
|
.merge(ap_routes)
|
||||||
.layer(FederationMiddleware::new(fed_config.0.clone()))
|
.layer(FederationMiddleware::new(fed_config.0.clone()));
|
||||||
|
|
||||||
|
openapi::serve(combined)
|
||||||
}
|
}
|
||||||
|
|||||||
822
docs/superpowers/plans/2026-05-14-openapi-docs.md
Normal file
822
docs/superpowers/plans/2026-05-14-openapi-docs.md
Normal file
@@ -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<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:
|
||||||
|
|
||||||
|
```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<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:**
|
||||||
|
|
||||||
|
```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<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`:**
|
||||||
|
|
||||||
|
```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<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()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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:**
|
||||||
|
|
||||||
|
```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<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
|
||||||
Reference in New Issue
Block a user