From 9a46e399094c22a601777b2aa83660c9865a7347 Mon Sep 17 00:00:00 2001 From: hitalin Date: Thu, 14 May 2026 16:43:30 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(http):=20OpenAPI=20=E3=82=A2=E3=83=8E?= =?UTF-8?q?=E3=83=86=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=A8=20utoipa?= =?UTF-8?q?-axum=20=E7=B5=B1=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_core_routes を OpenApiRouter 化し、ルート登録と OpenAPI spec 生成を routes! マクロで一体化。ルートを追加すれば必ず spec に載るため、 アノテーション付け忘れによるドリフトが構造的に発生しなくなる。 - 15 ハンドラに #[utoipa::path] を付与(path/method/status/description/security) - レスポンスモデル 15 struct に ToSchema を derive(リッチなスキーマ) - Query/Body struct に IntoParams / ToSchema を derive - /api index の手書き endpoints 配列を廃止し、spec から導出(endpoints_from_spec) - 標準サーバー用に ApiDoc / SecurityAddon を追加(埋め込み時はホストが merge) Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 44 +++++ Cargo.toml | 2 + src/http_server.rs | 456 ++++++++++++++++++++++++++++++++++++++++----- src/models.rs | 32 ++-- 4 files changed, 469 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1f7f4d1..b871c69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1131,6 +1131,8 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "utoipa", + "utoipa-axum", "uuid", "windows-native-keyring-store", "wiremock", @@ -1186,6 +1188,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2260,6 +2268,42 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-axum" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0" +dependencies = [ + "axum", + "paste", + "tower-layer", + "tower-service", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "uuid" version = "1.21.0" diff --git a/Cargo.toml b/Cargo.toml index c35d435..8893604 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,8 @@ subtle = "2" rand = "0.9" keyring-core = { version = "0.7", optional = true } axum = "0.8" +utoipa = "5" +utoipa-axum = "0.2" tower-http = { version = "0.6", features = ["cors"] } dirs = "6" clap = { version = "4", features = ["derive"] } diff --git a/src/http_server.rs b/src/http_server.rs index bc1203c..0f14b1d 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -6,7 +6,7 @@ use axum::{ sse::{Event, KeepAlive, Sse}, IntoResponse, Response, }, - routing::{delete, get, post}, + routing::get, Json, Router, }; use subtle::ConstantTimeEq; @@ -18,14 +18,59 @@ use std::sync::Arc; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::StreamExt; use tower_http::cors::CorsLayer; +use utoipa::{IntoParams, OpenApi, ToSchema}; +use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::MisskeyClient; use crate::db::Database; use crate::event_bus::EventBus; -use crate::models::{AccountPublic, CreateNoteParams, TimelineType}; +use crate::models::{ + AccountPublic, CreateNoteParams, NormalizedNote, NormalizedNoteReaction, + NormalizedNotification, NormalizedUserDetail, TimelineType, +}; pub const DEFAULT_PORT: u16 = 19820; +// --- OpenAPI --- + +/// OpenAPI metadata for the standalone notecli server. +/// When notecli routes are embedded in a larger app (e.g. NoteDeck), the host +/// app provides its own `info`/`tags` and merges the `OpenApiRouter` returned +/// by [`build_core_routes`]. +#[derive(OpenApi)] +#[openapi( + info( + title = "notecli API", + description = "Headless Misskey client — localhost HTTP API", + license(name = "MIT"), + ), + tags( + (name = "accounts", description = "Logged-in accounts"), + (name = "timeline", description = "Timelines and user notes"), + (name = "notes", description = "Note read / create / delete / reactions"), + (name = "users", description = "User profiles"), + (name = "search", description = "Note search"), + (name = "events", description = "Server-sent event stream"), + ), + modifiers(&SecurityAddon), +)] +pub struct ApiDoc; + +/// Registers the `bearer_auth` security scheme. Apply once on the final spec. +pub struct SecurityAddon; + +impl utoipa::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", + utoipa::openapi::security::SecurityScheme::Http(utoipa::openapi::security::Http::new( + utoipa::openapi::security::HttpAuthScheme::Bearer, + )), + ); + } +} + #[derive(Clone)] pub struct AppState { db: Arc, @@ -62,6 +107,15 @@ impl AppState { // --- Error type --- +/// Error response body returned by all endpoints on failure. +#[derive(serde::Serialize, ToSchema)] +pub struct ApiErrorResponse { + /// Error code (e.g. "NOT_FOUND", "UNAUTHORIZED") + error: String, + /// Human-readable error message + message: String, +} + struct ApiError { status: StatusCode, code: String, @@ -153,41 +207,79 @@ pub async fn start_on_port( /// Full router with `/api` index, auth middleware, and CORS. /// Use this for standalone notecli server. pub fn build_router(state: AppState) -> Router { + let token_path = state.token_path.clone(); + + let (core_router, openapi) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + .merge(build_core_routes(state)) + .split_for_parts(); + let index_route = Router::new() .route("/api", get(index)) .layer(CorsLayer::permissive()) - .with_state(state.clone()); + .with_state(IndexState { + openapi: Arc::new(openapi), + token_path, + }); - Router::new() - .merge(index_route) - .merge(build_core_routes(state)) + Router::new().merge(index_route).merge(core_router) } /// Core API routes with auth middleware and CORS, without the `/api` index. +/// +/// Returns an [`OpenApiRouter`] so route registration and OpenAPI generation +/// stay in lockstep — a route cannot be added without appearing in the spec. /// Use this when embedding notecli routes in a larger application that -/// provides its own index endpoint. -pub fn build_core_routes(state: AppState) -> Router { - Router::new() - .route("/api/accounts", get(list_accounts)) - .route("/api/{host}/timeline/{tl_type}", get(get_timeline)) - .route("/api/{host}/notifications", get(get_notifications)) - .route("/api/{host}/note", post(create_note)) - .route("/api/{host}/notes/{note_id}", get(get_note)) - .route("/api/{host}/notes/{note_id}", delete(delete_note)) - .route("/api/{host}/notes/{note_id}/children", get(get_note_children)) - .route("/api/{host}/notes/{note_id}/conversation", get(get_note_conversation)) - .route("/api/{host}/notes/{note_id}/reactions", get(get_note_reactions)) - .route("/api/{host}/notes/{note_id}/reactions", post(create_reaction)) - .route("/api/{host}/notes/{note_id}/reactions", delete(delete_reaction)) - .route("/api/{host}/users/{user_id}", get(get_user)) - .route("/api/{host}/users/{user_id}/notes", get(get_user_notes)) - .route("/api/{host}/search", get(search_notes)) - .route("/api/events", get(sse_events)) +/// provides its own index endpoint and merges this into its own spec. +pub fn build_core_routes(state: AppState) -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(list_accounts)) + .routes(routes!(get_timeline)) + .routes(routes!(get_notifications)) + .routes(routes!(create_note)) + .routes(routes!(get_note, delete_note)) + .routes(routes!(get_note_children)) + .routes(routes!(get_note_conversation)) + .routes(routes!(get_note_reactions, create_reaction, delete_reaction)) + .routes(routes!(get_user)) + .routes(routes!(get_user_notes)) + .routes(routes!(search_notes)) + .routes(routes!(sse_events)) .layer(middleware::from_fn_with_state(state.clone(), auth_middleware)) .layer(CorsLayer::permissive()) .with_state(state) } +/// Derive a flat endpoint list from an OpenAPI spec. +/// The spec is the single source of truth — the `/api` index never maintains +/// its own hand-written list. +pub fn endpoints_from_spec(openapi: &utoipa::openapi::OpenApi) -> Vec { + let mut out = Vec::new(); + for (path, item) in &openapi.paths.paths { + for (method, op) in [ + ("GET", &item.get), + ("POST", &item.post), + ("PUT", &item.put), + ("DELETE", &item.delete), + ("PATCH", &item.patch), + ] { + if let Some(op) = op { + let description = op + .description + .clone() + .or_else(|| op.summary.clone()) + .unwrap_or_default(); + out.push(json!({ "method": method, "path": path, "description": description })); + } + } + } + out.sort_by(|a, b| { + let ka = (a["path"].as_str().unwrap_or(""), a["method"].as_str().unwrap_or("")); + let kb = (b["path"].as_str().unwrap_or(""), b["method"].as_str().unwrap_or("")); + ka.cmp(&kb) + }); + out +} + // --- Auth middleware --- async fn auth_middleware( @@ -214,33 +306,33 @@ async fn auth_middleware( // --- Handlers --- -async fn index(State(state): State) -> Json { +/// State for the `/api` index route — carries the generated spec so the +/// endpoint list is always derived, never hand-maintained. +#[derive(Clone)] +struct IndexState { + openapi: Arc, + token_path: String, +} + +async fn index(State(state): State) -> Json { Json(json!({ "name": "notecli", "version": env!("CARGO_PKG_VERSION"), "auth": "Bearer token required. Read token from the file at tokenPath.", "tokenPath": state.token_path, - "endpoints": [ - { "method": "GET", "path": "/api", "description": "This endpoint list" }, - { "method": "GET", "path": "/api/accounts", "description": "List accounts (no tokens)" }, - { "method": "GET", "path": "/api/{host}/timeline/{type}", "description": "Get timeline notes" }, - { "method": "GET", "path": "/api/{host}/notifications", "description": "Get notifications" }, - { "method": "POST", "path": "/api/{host}/note", "description": "Create a note" }, - { "method": "GET", "path": "/api/{host}/search?q=...", "description": "Search notes" }, - { "method": "GET", "path": "/api/{host}/notes/{id}", "description": "Get a note" }, - { "method": "DELETE", "path": "/api/{host}/notes/{id}", "description": "Delete a note" }, - { "method": "GET", "path": "/api/{host}/notes/{id}/children", "description": "Get note replies" }, - { "method": "GET", "path": "/api/{host}/notes/{id}/conversation", "description": "Get note conversation" }, - { "method": "GET", "path": "/api/{host}/notes/{id}/reactions", "description": "Get note reactions" }, - { "method": "POST", "path": "/api/{host}/notes/{id}/reactions", "description": "Add reaction" }, - { "method": "DELETE", "path": "/api/{host}/notes/{id}/reactions", "description": "Remove reaction" }, - { "method": "GET", "path": "/api/{host}/users/{id}", "description": "Get user detail" }, - { "method": "GET", "path": "/api/{host}/users/{id}/notes", "description": "Get user notes" }, - { "method": "GET", "path": "/api/events", "description": "SSE event stream" }, - ] + "docs": "See /api/openapi.json when embedded in NoteDeck.", + "endpoints": endpoints_from_spec(&state.openapi), })) } +#[utoipa::path( + get, path = "/api/accounts", tag = "accounts", + security(("bearer_auth" = [])), + responses( + (status = 200, description = "Logged-in accounts (tokens stripped)", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + ) +)] async fn list_accounts( State(state): State, ) -> Result>, ApiError> { @@ -248,6 +340,20 @@ async fn list_accounts( Ok(Json(accounts.iter().map(AccountPublic::from).collect())) } +#[utoipa::path( + get, path = "/api/{host}/timeline/{tl_type}", tag = "timeline", + security(("bearer_auth" = [])), + params( + ("host" = String, Path, description = "Account host (e.g. misskey.io)"), + ("tl_type" = String, Path, description = "Timeline type: home | local | social | global"), + TimelineQueryParams, + ), + responses( + (status = 200, description = "Timeline notes", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "No account for host", body = ApiErrorResponse), + ) +)] async fn get_timeline( State(state): State, Path((host, tl_type)): Path<(String, String)>, @@ -264,6 +370,19 @@ async fn get_timeline( Ok(Json(notes)) } +#[utoipa::path( + get, path = "/api/{host}/notifications", tag = "timeline", + security(("bearer_auth" = [])), + params( + ("host" = String, Path, description = "Account host"), + TimelineQueryParams, + ), + responses( + (status = 200, description = "Notifications", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "No account for host", body = ApiErrorResponse), + ) +)] async fn get_notifications( State(state): State, Path(host): Path, @@ -279,6 +398,17 @@ async fn get_notifications( Ok(Json(notifications)) } +#[utoipa::path( + post, path = "/api/{host}/note", tag = "notes", + security(("bearer_auth" = [])), + params(("host" = String, Path, description = "Account host")), + request_body = CreateNoteBody, + responses( + (status = 200, description = "Created note", body = NormalizedNote), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "No account for host", body = ApiErrorResponse), + ) +)] async fn create_note( State(state): State, Path(host): Path, @@ -305,6 +435,20 @@ async fn create_note( Ok(Json(note)) } +#[utoipa::path( + get, path = "/api/{host}/search", tag = "search", + security(("bearer_auth" = [])), + params( + ("host" = String, Path, description = "Account host"), + SearchQueryParams, + ), + responses( + (status = 200, description = "Matching notes", body = Vec), + (status = 400, description = "Missing query parameter: q", body = ApiErrorResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "No account for host", body = ApiErrorResponse), + ) +)] async fn search_notes( State(state): State, Path(host): Path, @@ -327,6 +471,19 @@ async fn search_notes( Ok(Json(notes)) } +#[utoipa::path( + get, path = "/api/{host}/notes/{note_id}", tag = "notes", + security(("bearer_auth" = [])), + params( + ("host" = String, Path, description = "Account host"), + ("note_id" = String, Path, description = "Note ID"), + ), + responses( + (status = 200, description = "Note", body = NormalizedNote), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Note or account not found", body = ApiErrorResponse), + ) +)] async fn get_note( State(state): State, Path((host, note_id)): Path<(String, String)>, @@ -340,6 +497,19 @@ async fn get_note( Ok(Json(note)) } +#[utoipa::path( + delete, path = "/api/{host}/notes/{note_id}", tag = "notes", + security(("bearer_auth" = [])), + params( + ("host" = String, Path, description = "Account host"), + ("note_id" = String, Path, description = "Note ID"), + ), + responses( + (status = 204, description = "Note deleted"), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Note or account not found", body = ApiErrorResponse), + ) +)] async fn delete_note( State(state): State, Path((host, note_id)): Path<(String, String)>, @@ -350,6 +520,20 @@ async fn delete_note( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + get, path = "/api/{host}/notes/{note_id}/children", tag = "notes", + security(("bearer_auth" = [])), + params( + ("host" = String, Path, description = "Account host"), + ("note_id" = String, Path, description = "Note ID"), + LimitQueryParams, + ), + responses( + (status = 200, description = "Direct replies to the note", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Note or account not found", body = ApiErrorResponse), + ) +)] async fn get_note_children( State(state): State, Path((host, note_id)): Path<(String, String)>, @@ -365,6 +549,20 @@ async fn get_note_children( Ok(Json(notes)) } +#[utoipa::path( + get, path = "/api/{host}/notes/{note_id}/conversation", tag = "notes", + security(("bearer_auth" = [])), + params( + ("host" = String, Path, description = "Account host"), + ("note_id" = String, Path, description = "Note ID"), + LimitQueryParams, + ), + responses( + (status = 200, description = "Ancestor notes (conversation thread)", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Note or account not found", body = ApiErrorResponse), + ) +)] async fn get_note_conversation( State(state): State, Path((host, note_id)): Path<(String, String)>, @@ -380,6 +578,20 @@ async fn get_note_conversation( Ok(Json(notes)) } +#[utoipa::path( + get, path = "/api/{host}/notes/{note_id}/reactions", tag = "notes", + security(("bearer_auth" = [])), + params( + ("host" = String, Path, description = "Account host"), + ("note_id" = String, Path, description = "Note ID"), + ReactionQueryParams, + ), + responses( + (status = 200, description = "Reactions on the note", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Note or account not found", body = ApiErrorResponse), + ) +)] async fn get_note_reactions( State(state): State, Path((host, note_id)): Path<(String, String)>, @@ -395,6 +607,20 @@ async fn get_note_reactions( Ok(Json(reactions)) } +#[utoipa::path( + post, path = "/api/{host}/notes/{note_id}/reactions", tag = "notes", + security(("bearer_auth" = [])), + params( + ("host" = String, Path, description = "Account host"), + ("note_id" = String, Path, description = "Note ID"), + ), + request_body = ReactionBody, + responses( + (status = 204, description = "Reaction added"), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Note or account not found", body = ApiErrorResponse), + ) +)] async fn create_reaction( State(state): State, Path((host, note_id)): Path<(String, String)>, @@ -409,6 +635,19 @@ async fn create_reaction( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + delete, path = "/api/{host}/notes/{note_id}/reactions", tag = "notes", + security(("bearer_auth" = [])), + params( + ("host" = String, Path, description = "Account host"), + ("note_id" = String, Path, description = "Note ID"), + ), + responses( + (status = 204, description = "Reaction removed"), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "Note or account not found", body = ApiErrorResponse), + ) +)] async fn delete_reaction( State(state): State, Path((host, note_id)): Path<(String, String)>, @@ -422,6 +661,19 @@ async fn delete_reaction( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + get, path = "/api/{host}/users/{user_id}", tag = "users", + security(("bearer_auth" = [])), + params( + ("host" = String, Path, description = "Account host"), + ("user_id" = String, Path, description = "User ID"), + ), + responses( + (status = 200, description = "User profile detail", body = NormalizedUserDetail), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "User or account not found", body = ApiErrorResponse), + ) +)] async fn get_user( State(state): State, Path((host, user_id)): Path<(String, String)>, @@ -435,6 +687,20 @@ async fn get_user( Ok(Json(user)) } +#[utoipa::path( + get, path = "/api/{host}/users/{user_id}/notes", tag = "users", + security(("bearer_auth" = [])), + params( + ("host" = String, Path, description = "Account host"), + ("user_id" = String, Path, description = "User ID"), + TimelineQueryParams, + ), + responses( + (status = 200, description = "Notes authored by the user", body = Vec), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "User or account not found", body = ApiErrorResponse), + ) +)] async fn get_user_notes( State(state): State, Path((host, user_id)): Path<(String, String)>, @@ -450,6 +716,19 @@ async fn get_user_notes( Ok(Json(notes)) } +#[utoipa::path( + get, path = "/api/events", tag = "events", + security(("bearer_auth" = [])), + params(SseQueryParams), + responses( + (status = 200, + description = "Server-sent event stream (`text/event-stream`). Each event \ + carries an `event:` type and a JSON `data:` payload. OpenAPI cannot model \ + a streaming body, so the schema is shown as a string.", + content_type = "text/event-stream", body = String), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + ) +)] async fn sse_events( State(state): State, Query(params): Query, @@ -483,10 +762,14 @@ async fn sse_events( // --- Query / Body types --- -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] struct TimelineQueryParams { + /// Max number of items to return (default 20) limit: Option, + /// Return items newer than this ID since_id: Option, + /// Return items older than this ID until_id: Option, } @@ -500,41 +783,114 @@ impl TimelineQueryParams { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] struct SearchQueryParams { + /// Search query string (required) q: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] struct LimitQueryParams { + /// Max number of items to return (default 20) limit: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] struct ReactionQueryParams { + /// Filter by reaction type r#type: Option, + /// Max number of reactions to return (default 20) limit: Option, + /// Return reactions older than this ID until_id: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] struct ReactionBody { + /// Reaction emoji (e.g. "👍" or ":custom_emoji:") reaction: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] struct SseQueryParams { + /// Comma-separated event type prefixes to filter the stream r#type: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, ToSchema)] struct CreateNoteBody { + /// Note body text text: String, + /// Content warning cw: Option, + /// Visibility: public | home | followers | specified visibility: Option, + /// Federate locally only local_only: Option, + /// Reply target note ID reply_id: Option, + /// Renote target note ID renote_id: Option, + /// Attached drive file IDs file_ids: Option>, + /// Schedule the note for this ISO-8601 timestamp scheduled_at: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + /// Every core route must appear in the generated OpenAPI spec. + /// `routes!` makes this structural — this test guards against the + /// `OpenApiRouter` wiring regressing (e.g. a route dropped from the macro). + #[test] + fn core_routes_are_all_in_the_spec() { + let (_, openapi) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + .routes(routes!(list_accounts)) + .routes(routes!(get_timeline)) + .routes(routes!(get_notifications)) + .routes(routes!(create_note)) + .routes(routes!(get_note, delete_note)) + .routes(routes!(get_note_children)) + .routes(routes!(get_note_conversation)) + .routes(routes!(get_note_reactions, create_reaction, delete_reaction)) + .routes(routes!(get_user)) + .routes(routes!(get_user_notes)) + .routes(routes!(search_notes)) + .routes(routes!(sse_events)) + .split_for_parts(); + + // 12 distinct paths, 15 operations. + assert_eq!(openapi.paths.paths.len(), 12, "unexpected path count"); + let op_count: usize = openapi + .paths + .paths + .values() + .map(|item| { + [&item.get, &item.post, &item.put, &item.delete, &item.patch] + .iter() + .filter(|o| o.is_some()) + .count() + }) + .sum(); + assert_eq!(op_count, 15, "unexpected operation count"); + + // bearer_auth scheme is registered by SecurityAddon. + assert!(openapi + .components + .as_ref() + .is_some_and(|c| c.security_schemes.contains_key("bearer_auth"))); + } + + #[test] + fn endpoints_are_derived_from_spec() { + let openapi = ApiDoc::openapi(); + // ApiDoc alone has no paths; the derived list is empty until routes merge in. + assert!(endpoints_from_spec(&openapi).is_empty()); + } +} diff --git a/src/models.rs b/src/models.rs index 358ffea..e7c7c4c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -35,7 +35,7 @@ impl Drop for Account { } /// Token を含まない、フロントエンド向け Account 構造体 -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct AccountPublic { @@ -101,7 +101,7 @@ pub struct StoredServer { // --- Normalized models (sent to frontend via IPC) --- -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct NormalizedNote { @@ -155,13 +155,15 @@ pub struct NormalizedNote { pub mode_flags: HashMap, #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "specta", specta(type = Option>))] + #[schema(no_recursion)] pub reply: Option>, #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "specta", specta(type = Option>))] + #[schema(no_recursion)] pub renote: Option>, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct AvatarDecoration { @@ -177,7 +179,7 @@ pub struct AvatarDecoration { pub offset_y: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct UserInstance { @@ -187,7 +189,7 @@ pub struct UserInstance { pub theme_color: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct NormalizedUser { @@ -208,7 +210,7 @@ pub struct NormalizedUser { pub instance: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct UserRole { @@ -221,14 +223,14 @@ pub struct UserRole { pub display_order: i64, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] pub struct UserField { pub name: String, pub value: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct NormalizedUserDetail { @@ -279,7 +281,7 @@ pub struct NormalizedUserDetail { pub followed_message: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct NormalizedPoll { @@ -289,7 +291,7 @@ pub struct NormalizedPoll { pub expires_at: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct NormalizedPollChoice { @@ -300,7 +302,7 @@ pub struct NormalizedPollChoice { pub is_voted: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct NormalizedDriveFile { @@ -316,7 +318,7 @@ pub struct NormalizedDriveFile { pub is_sensitive: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct ReactionInfo { @@ -324,7 +326,7 @@ pub struct ReactionInfo { pub reaction: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct NormalizedNotification { @@ -855,7 +857,7 @@ pub struct Clip { pub notes_count: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct Channel { @@ -1293,7 +1295,7 @@ pub struct RawNoteReaction { pub reaction_type: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[cfg_attr(feature = "specta", derive(specta::Type))] #[serde(rename_all = "camelCase")] pub struct NormalizedNoteReaction { From 3e8283006710ff6c32efdf350eed2aa2a12fa75e Mon Sep 17 00:00:00 2001 From: hitalin Date: Thu, 14 May 2026 16:45:32 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(http):=20state=20=E9=9D=9E=E4=BE=9D?= =?UTF-8?q?=E5=AD=98=E3=81=AE=20core=5Fopenapi()=20=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ホストアプリ (NoteDeck) が DB/client を構築せずにフル spec を生成できるよう、 ルート登録部を core_openapi_router() に切り出し、state-free な core_openapi() を 公開。/api/openapi.json・Tauri command・コミット済みスナップショットが 同一の spec source を共有できる。 Co-Authored-By: Claude Opus 4.6 --- src/http_server.rs | 49 +++++++++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/src/http_server.rs b/src/http_server.rs index 0f14b1d..48c59c3 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -224,13 +224,13 @@ pub fn build_router(state: AppState) -> Router { Router::new().merge(index_route).merge(core_router) } -/// Core API routes with auth middleware and CORS, without the `/api` index. +/// Route registration for the core API — no state, no layers. /// -/// Returns an [`OpenApiRouter`] so route registration and OpenAPI generation -/// stay in lockstep — a route cannot be added without appearing in the spec. -/// Use this when embedding notecli routes in a larger application that -/// provides its own index endpoint and merges this into its own spec. -pub fn build_core_routes(state: AppState) -> OpenApiRouter { +/// This is the single list of core routes. `routes!` ties each route to its +/// `#[utoipa::path]` annotation, so a route cannot be registered without +/// appearing in the OpenAPI spec. Both [`build_core_routes`] (runtime router) +/// and [`core_openapi`] (state-free spec) are derived from this. +fn core_openapi_router() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(list_accounts)) .routes(routes!(get_timeline)) @@ -244,11 +244,31 @@ pub fn build_core_routes(state: AppState) -> OpenApiRouter { .routes(routes!(get_user_notes)) .routes(routes!(search_notes)) .routes(routes!(sse_events)) +} + +/// Core API routes with auth middleware and CORS, without the `/api` index. +/// +/// Returns an [`OpenApiRouter`] so route registration and OpenAPI generation +/// stay in lockstep. Use this when embedding notecli routes in a larger +/// application that provides its own index endpoint and merges this into its +/// own spec. +pub fn build_core_routes(state: AppState) -> OpenApiRouter { + core_openapi_router() .layer(middleware::from_fn_with_state(state.clone(), auth_middleware)) .layer(CorsLayer::permissive()) .with_state(state) } +/// The core API OpenAPI spec, built without any runtime state. +/// +/// Lets a host application (e.g. NoteDeck) generate a full merged spec — for +/// a `/api/openapi.json` endpoint, a Tauri command, or a committed snapshot — +/// without constructing a database/client. Does **not** include `info`/`tags`; +/// the host merges this into its own [`ApiDoc`]-based base spec. +pub fn core_openapi() -> utoipa::openapi::OpenApi { + core_openapi_router().split_for_parts().1 +} + /// Derive a flat endpoint list from an OpenAPI spec. /// The spec is the single source of truth — the `/api` index never maintains /// its own hand-written list. @@ -844,26 +864,15 @@ struct CreateNoteBody { #[cfg(test)] mod tests { use super::*; + use utoipa::Modify; /// Every core route must appear in the generated OpenAPI spec. /// `routes!` makes this structural — this test guards against the /// `OpenApiRouter` wiring regressing (e.g. a route dropped from the macro). #[test] fn core_routes_are_all_in_the_spec() { - let (_, openapi) = OpenApiRouter::with_openapi(ApiDoc::openapi()) - .routes(routes!(list_accounts)) - .routes(routes!(get_timeline)) - .routes(routes!(get_notifications)) - .routes(routes!(create_note)) - .routes(routes!(get_note, delete_note)) - .routes(routes!(get_note_children)) - .routes(routes!(get_note_conversation)) - .routes(routes!(get_note_reactions, create_reaction, delete_reaction)) - .routes(routes!(get_user)) - .routes(routes!(get_user_notes)) - .routes(routes!(search_notes)) - .routes(routes!(sse_events)) - .split_for_parts(); + let mut openapi = core_openapi(); + SecurityAddon.modify(&mut openapi); // 12 distinct paths, 15 operations. assert_eq!(openapi.paths.paths.len(), 12, "unexpected path count");