diff --git a/Cargo.lock b/Cargo.lock index 7bb9cf2..9a33bd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "aide" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6966317188cdfe54c58c0900a195d021294afb3ece9b7073d09e4018dbb1e3a2" +dependencies = [ + "cfg-if", + "indexmap 2.14.0", + "schemars 0.9.0", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -509,6 +524,7 @@ name = "cot" version = "0.6.0" dependencies = [ "ahash", + "aide", "askama", "async-trait", "axum", @@ -536,12 +552,14 @@ dependencies = [ "multer", "password-auth", "pin-project-lite", + "schemars 0.9.0", "sea-query", "sea-query-binder", "serde", "serde_json", "sqlx", "subtle", + "swagger-ui-redist", "thiserror 2.0.18", "time", "tokio", @@ -580,6 +598,7 @@ dependencies = [ "http-body", "http-body-util", "indexmap 2.14.0", + "schemars 0.9.0", "serde", "serde_html_form", "serde_json", @@ -1066,6 +1085,7 @@ dependencies = [ "cot", "openidconnect", "reqwest", + "schemars 0.9.0", "serde", "serde_json", "tokio", @@ -2524,7 +2544,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" dependencies = [ "dyn-clone", + "indexmap 2.14.0", "ref-cast", + "schemars_derive", "serde", "serde_json", ] @@ -2541,6 +2563,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5016d94c77c6d32f0b8e08b781f7dc8a90c2007d4e77472cc2807bc10a8438fe" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2628,6 +2662,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_html_form" version = "0.4.0" @@ -3053,6 +3098,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swagger-ui-redist" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfaaf9b731f41f15b1a7d01419e6ff9ff59eef7bc6c04eae04911e3dbe5e17a" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "syn" version = "2.0.117" diff --git a/Cargo.toml b/Cargo.toml index dd69caa..51726eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,8 @@ edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" [dependencies] -cot = { path = "../cot/cot", features = ["postgres", "json"] } +cot = { path = "../cot/cot", features = ["postgres", "json", "openapi", "swagger-ui"] } +schemars = { version = "0.9", features = ["derive"] } serde = { version = "1", features = ["derive"] } openidconnect = "4.0" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } diff --git a/README.md b/README.md index 69ce50e..9caf1a1 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,13 @@ Helpers in `api/mod.rs`: |-------|--------|-------------| | `/api/me` | GET | Current user (id, name, role) or 401 | -To add a new API endpoint: write an async handler returning `cot::Result`, use `json_ok`/`json_error`, add a `Route` in `ApiApp::router()`. +**Swagger UI** is available at `/swagger/` when `FURU_SWAGGER_ENABLED=true`. The OpenAPI spec is auto-generated from handler types. + +To add a new API endpoint: +1. Define request/response structs with `#[derive(Serialize, JsonSchema)]` +2. Write an async handler, return `Json(response).into_response()` +3. Add a `Route::with_api_handler_and_name(…, api_get(handler), …)` in `ApiApp::router()` +4. The endpoint appears automatically in Swagger UI ### Admin panel (`src/admin/`) @@ -191,3 +197,4 @@ All prefixed with `FURU_`. Priority: env var > DB override > compiled default. | `FURU_OIDC_CLIENT_SECRET` | OIDC client secret | *(empty)* | | `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` | | `FURU_OIDC_ADMIN_GROUPS` | Comma-separated OIDC groups that grant admin | *(empty)* | +| `FURU_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` | diff --git a/src/admin/views.rs b/src/admin/views.rs index 245dbdc..1e80a33 100644 --- a/src/admin/views.rs +++ b/src/admin/views.rs @@ -86,6 +86,7 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec, } pub async fn settings_submit( @@ -212,7 +218,8 @@ pub async fn settings_submit( FormResult::Ok(data) => { let pw_enabled = if data.auth_password_enabled.is_some() { "true" } else { "false" }; let sso_enabled = if data.auth_sso_enabled.is_some() { "true" } else { "false" }; - let fields: [(&str, &str); 7] = [ + let swagger = if data.swagger_enabled.is_some() { "true" } else { "false" }; + let fields: [(&str, &str); 8] = [ ("auth_password_enabled", pw_enabled), ("auth_sso_enabled", sso_enabled), ("oidc_button_text", &data.oidc_button_text), @@ -220,6 +227,7 @@ pub async fn settings_submit( ("oidc_client_id", &data.oidc_client_id), ("oidc_client_secret", &data.oidc_client_secret), ("oidc_admin_groups", &data.oidc_admin_groups), + ("swagger_enabled", swagger), ]; for (key, value) in fields { let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned()); diff --git a/src/api/mod.rs b/src/api/mod.rs index 8dc8531..28ea0d2 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,23 +1,19 @@ use cot::db::Database; -use cot::router::method::get; +use cot::json::Json; +use cot::response::IntoResponse; +use cot::router::method::openapi::api_get; use cot::router::{Route, Router}; use cot::session::Session; use cot::{App, Body}; +use schemars::JsonSchema; +use serde::Serialize; use crate::auth; // --------------------------------------------------------------------------- -// JSON response helpers +// JSON error helper // --------------------------------------------------------------------------- -fn json_ok(value: &serde_json::Value) -> cot::response::Response { - cot::http::Response::builder() - .status(cot::http::StatusCode::OK) - .header(cot::http::header::CONTENT_TYPE, "application/json") - .body(Body::fixed(value.to_string())) - .expect("valid response") -} - fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response { let body = serde_json::json!({ "error": message }); cot::http::Response::builder() @@ -31,6 +27,13 @@ fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Re // GET /api/me // --------------------------------------------------------------------------- +#[derive(Debug, Serialize, JsonSchema)] +struct MeResponse { + id: i64, + name: String, + role: String, +} + async fn me_handler( session: Session, db: Database, @@ -42,11 +45,12 @@ async fn me_handler( )); }; - Ok(json_ok(&serde_json::json!({ - "id": user.id, - "name": user.name, - "role": user.role.code(), - }))) + Json(MeResponse { + id: user.id, + name: user.name, + role: user.role.code().to_owned(), + }) + .into_response() } // --------------------------------------------------------------------------- @@ -62,7 +66,7 @@ impl App for ApiApp { fn router(&self) -> Router { Router::with_urls([ - Route::with_handler_and_name("/me", get(me_handler), "api_me"), + Route::with_api_handler_and_name("/me", api_get(me_handler), "api_me"), ]) } } diff --git a/src/config.rs b/src/config.rs index 2e6f087..a7a2a84 100644 --- a/src/config.rs +++ b/src/config.rs @@ -127,6 +127,7 @@ pub struct ConfigSources { pub auth_sso_enabled: ConfigSource, pub oidc_button_text: ConfigSource, pub oidc_admin_groups: ConfigSource, + pub swagger_enabled: ConfigSource, } impl Default for ConfigSources { @@ -141,6 +142,7 @@ impl Default for ConfigSources { auth_sso_enabled: ConfigSource::Default, oidc_button_text: ConfigSource::Default, oidc_admin_groups: ConfigSource::Default, + swagger_enabled: ConfigSource::Default, } } } @@ -223,6 +225,8 @@ pub struct AppConfig { pub oidc_button_text: String, /// Comma-separated list of OIDC group names that grant admin role. pub oidc_admin_groups: String, + /// Whether the Swagger UI is served at /swagger/. + pub swagger_enabled: bool, } impl Default for AppConfig { @@ -237,6 +241,7 @@ impl Default for AppConfig { auth_sso_enabled: false, oidc_button_text: "Sign in with SSO".into(), oidc_admin_groups: String::new(), + swagger_enabled: false, } } } @@ -252,6 +257,7 @@ impl_env_overrides!( auth_sso_enabled, oidc_button_text, oidc_admin_groups, + swagger_enabled, ); impl AppConfig { @@ -317,6 +323,7 @@ impl AppConfig { apply_db_field!(auth_sso_enabled); apply_db_field!(oidc_button_text); apply_db_field!(oidc_admin_groups); + apply_db_field!(swagger_enabled); } } diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 3562b6b..ee782f9 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -89,6 +89,11 @@ translations! { users_password_hint: "Leave blank to keep current" , "Оставьте пустым, чтобы не менять"; users_saved: "User saved." , "Пользователь сохранён."; + // API settings + settings_api: "API" , "API"; + settings_swagger: "Swagger UI" , "Swagger UI"; + settings_swagger_help: "Serves interactive API docs at /swagger/ (requires restart)" , "Интерактивная документация API на /swagger/ (требуется перезапуск)"; + // OIDC login errors login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз."; login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен."; diff --git a/src/main.rs b/src/main.rs index b379a84..00b8a0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ use cot::db::Database; use cot::form::{Form, FormResult}; use cot::html::Html; use cot::middleware::SessionMiddleware; +use cot::static_files::StaticFilesMiddleware; use cot::project::RegisterAppsContext; use cot::request::extractors::{RequestForm, UrlQuery}; use cot::response::IntoResponse; @@ -148,6 +149,11 @@ impl App for FuruApp { get(|| async { Ok::<_, cot::Error>(auth::redirect("/admin/")) }), "admin_redirect", ), + Route::with_handler_and_name( + "/swagger", + get(|| async { Ok::<_, cot::Error>(auth::redirect("/swagger/")) }), + "swagger_redirect", + ), Route::with_handler_and_name("/", index, "index"), Route::with_handler_and_name( "/login", @@ -258,6 +264,9 @@ impl Project for FuruProject { " FURU_OIDC_BUTTON_TEXT SSO button label (default: Sign in with SSO)\n", " FURU_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n", "\n", + " API:\n", + " FURU_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n", + "\n", "QUICK START\n", " export FURU_DATABASE_URL=postgres://user:pass@localhost/furumusic\n", " furumusic run", @@ -300,6 +309,7 @@ impl Project for FuruProject { context: &cot::project::MiddlewareContext, ) -> cot::project::RootHandler { handler + .middleware(StaticFilesMiddleware::from_context(context)) .middleware( SessionMiddleware::from_context(context) .same_site(cot::config::SameSite::Lax), @@ -320,6 +330,12 @@ impl Project for FuruProject { "/admin", ); apps.register_with_views(api::ApiApp, "/api"); + if self.app_config.swagger_enabled { + apps.register_with_views( + cot::openapi::swagger_ui::SwaggerUi::new(), + "/swagger", + ); + } } } diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 39fa674..579e612 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -68,6 +68,20 @@ {{ oidc_admin_groups_source }} +

{{ t.settings_api }}

+ + + + + + + + + + + +
{{ t.debug_field }}{{ t.debug_value }}{{ t.debug_source }}

{{ t.settings_swagger_help }}
{{ swagger_enabled_source }}
+ {% endblock content %}