diff options
| author | Joel Klinghed <the_jk@spawned.biz> | 2024-12-29 22:40:12 +0100 |
|---|---|---|
| committer | Joel Klinghed <the_jk@spawned.biz> | 2024-12-29 22:40:12 +0100 |
| commit | 776406684bdc591a4c97b58b8d28f689881c285e (patch) | |
| tree | 7551fcc2d10eb4d75314b2ad93bce6c328481413 | |
| parent | 7bc8e8b7262a3f3abe3222b3b434838e85cdb2bb (diff) | |
Add openapi generation using utoipa
| -rw-r--r-- | server/Cargo.lock | 244 | ||||
| -rw-r--r-- | server/Cargo.toml | 2 | ||||
| -rw-r--r-- | server/src/api_model.rs | 47 | ||||
| -rw-r--r-- | server/src/auth.rs | 80 | ||||
| -rw-r--r-- | server/src/main.rs | 55 |
5 files changed, 397 insertions, 31 deletions
diff --git a/server/Cargo.lock b/server/Cargo.lock index 865b62d..68acb20 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -81,6 +81,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + +[[package]] name = "async-stream" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -201,6 +210,12 @@ dependencies = [ ] [[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] name = "bytemuck" version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -291,6 +306,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] name = "crossbeam-queue" version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -346,6 +370,17 @@ dependencies = [ ] [[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] name = "devise" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -468,6 +503,8 @@ dependencies = [ "serde", "sqlx", "time", + "utoipa", + "utoipa-swagger-ui", ] [[package]] @@ -491,6 +528,16 @@ dependencies = [ ] [[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] name = "flume" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1076,6 +1123,12 @@ dependencies = [ ] [[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + +[[package]] name = "log" version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1128,6 +1181,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1689,6 +1752,40 @@ dependencies = [ ] [[package]] +name = "rust-embed" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa66af4a4fdd5e7ebc276f115e895611a34739a9c1c01028383d612d550953c0" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.90", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5347777e9aacb56039b0e1f28785929a8a3b709e87482e7442c72e7c12529d" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1750,6 +1847,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] name = "scoped-tls" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1869,6 +1975,12 @@ dependencies = [ ] [[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] name = "slab" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1969,7 +2081,7 @@ dependencies = [ "sha2", "smallvec", "sqlformat", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-stream", "tracing", @@ -2051,7 +2163,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "whoami", ] @@ -2089,7 +2201,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "whoami", ] @@ -2209,7 +2321,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc" +dependencies = [ + "thiserror-impl 2.0.9", ] [[package]] @@ -2224,6 +2345,17 @@ dependencies = [ ] [[package]] +name = "thiserror-impl" +version = "2.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] name = "thread_local" version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2475,6 +2607,12 @@ dependencies = [ ] [[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] name = "unicode-bidi" version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2565,6 +2703,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] +name = "utoipa" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68e76d357bc95c7d0939c92c04c9269871a8470eea39cb1f0231eeadb0c47d0f" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "564b03f8044ad6806bdc0d635e88be24967e785eef096df6b2636d2cc1e05d4b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.90", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "8.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db4b5ac679cc6dfc5ea3f2823b0291c777750ffd5e13b21137e0f7ac0e8f9617" +dependencies = [ + "base64 0.22.1", + "mime_guess", + "regex", + "rocket", + "rust-embed", + "serde", + "serde_json", + "utoipa", + "utoipa-swagger-ui-vendored", + "zip", +] + +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + +[[package]] name = "valuable" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2583,6 +2769,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] name = "want" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2636,6 +2832,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2921,3 +3126,34 @@ dependencies = [ "quote", "syn 2.0.90", ] + +[[package]] +name = "zip" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae9c1ea7b3a5e1f4b922ff856a129881167511563dc219869afe3787fc0c1a45" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.9", + "zopfli", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] diff --git a/server/Cargo.toml b/server/Cargo.toml index fc8cb6f..9348d86 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,3 +10,5 @@ rocket_db_pools = { version = "0.2.0", features = ["sqlx_mysql"] } serde = { version = "1.0", features = ["derive"] } sqlx = { version = "0.7.0", default-features = false, features = ["macros", "migrate"] } time = "0.3.34" +utoipa = { version = "5", features = ["rocket_extras"] } +utoipa-swagger-ui = { version = "8", features = ["rocket", "vendored"], default-features = false } diff --git a/server/src/api_model.rs b/server/src/api_model.rs index 286e11f..a7c8e88 100644 --- a/server/src/api_model.rs +++ b/server/src/api_model.rs @@ -1,6 +1,7 @@ use rocket::serde::Serialize; +use utoipa::ToSchema; -#[derive(Serialize, Copy, Clone)] +#[derive(Serialize, Copy, Clone, ToSchema)] pub enum ReviewState { Draft, Open, @@ -8,70 +9,96 @@ pub enum ReviewState { Closed, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct User { + #[schema(example = 1337u64)] pub id: u64, + #[schema(example = "jsmith")] pub username: String, + #[schema(example = "John Smith")] pub name: String, + #[schema(example = true)] pub active: bool, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct Review { + #[schema(example = 1000u64)] pub id: u64, + #[schema(example = "FAKE-512: Add more features")] pub title: String, + #[schema(example = "We're adding more features because features are what we want.")] pub description: String, pub owner: User, pub reviewers: Vec<User>, pub watchers: Vec<User>, + #[schema(example = ReviewState::Open)] pub state: ReviewState, + #[schema(example = 37.5)] pub progress: f32, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct ReviewEntry { + #[schema(example = 1000u64)] pub id: u64, + #[schema(example = "FAKE-512: Add more features")] pub title: String, pub owner: User, + #[schema(example = ReviewState::Open)] pub state: ReviewState, + #[schema(example = 37.5)] pub progress: f32, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct Reviews { + #[schema(example = 0u32)] pub offset: u32, + #[schema(example = 10u32)] pub limit: u32, + #[schema(example = 42u32)] pub total_count: u32, + #[schema(example = true)] pub more: bool, pub reviews: Vec<ReviewEntry>, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct Project { + #[schema(example = 1u64)] pub id: u64, + #[schema(example = "FAKE: Features All Kids Erase")] pub title: String, + #[schema(example = "Example project")] pub description: String, pub members: Vec<User>, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct ProjectEntry { + #[schema(example = 1u64)] pub id: u64, + #[schema(example = "FAKE: Features All Kids Erase")] pub title: String, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct Projects { + #[schema(example = 0u32)] pub offset: u32, + #[schema(example = 10u32)] pub limit: u32, + #[schema(example = 1u32)] pub total_count: u32, + #[schema(example = false)] pub more: bool, pub projects: Vec<ProjectEntry>, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct StatusResponse { pub ok: bool, #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option<String>, + pub error: Option<&'static str>, } diff --git a/server/src/auth.rs b/server/src/auth.rs index 31e18a0..4e66448 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -12,10 +12,31 @@ use std::collections::BTreeMap; use std::sync::Mutex; use std::time::Instant; use time::Duration; +use utoipa::openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}; +use utoipa::{Modify, OpenApi, ToSchema}; use crate::api_model; -#[derive(FromForm)] +#[derive(OpenApi)] +#[openapi( + paths(login, logout, status,), + modifiers(&AuthApiAddon), +)] +pub struct AuthApi; + +pub struct AuthApiAddon; + +impl Modify for AuthApiAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.as_mut().unwrap(); + components.add_security_scheme( + "session", + SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new(SESSION_COOKIE))), + ) + } +} + +#[derive(FromForm, ToSchema)] struct Login<'r> { username: &'r str, password: &'r str, @@ -49,6 +70,14 @@ pub enum SessionError { } const SESSION_COOKIE: &str = "s"; +const STATUS_OK: api_model::StatusResponse = api_model::StatusResponse { + ok: true, + error: None, +}; +const STATUS_UNAUTHORIZED: api_model::StatusResponse = api_model::StatusResponse { + ok: false, + error: Some("Unauthorized"), +}; fn validate(sessions: &State<Sessions>, session: &Session, request: &Request<'_>) -> bool { match request.client_ip() { @@ -123,6 +152,17 @@ fn new_session( } } +#[utoipa::path( + responses( + (status = 200, description = "Login successful", body = api_model::StatusResponse, + example = json!(STATUS_OK)), + (status = 401, description = "Login failed", body = api_model::StatusResponse, + example = json!(STATUS_UNAUTHORIZED)), + ), + security( + (), + ), +)] #[post("/login", data = "<login>")] fn login( auth_config: &State<AuthConfig>, @@ -142,15 +182,20 @@ fn login( .build(); cookies.add_private(cookie); - Ok(Json(api_model::StatusResponse { - ok: true, - error: None, - })) + Ok(Json(STATUS_OK)) } else { Err(Unauthorized("Unknown username or password")) } } +#[utoipa::path( + responses( + (status = 200, description = "Logout successful", body = api_model::StatusResponse, example = json!(STATUS_OK)), + ), + security( + ("session" = []), + ), +)] #[get("/logout")] fn logout( session: Session, @@ -169,26 +214,27 @@ fn logout( cookies.remove_private(cookie); - Json(api_model::StatusResponse { - ok: true, - error: None, - }) + Json(STATUS_OK) } +#[utoipa::path( + responses( + (status = 200, description = "Current status", body = api_model::StatusResponse, example = json!(STATUS_OK)), + (status = 401, description = "Not authorized", body = api_model::StatusResponse, example = json!(STATUS_UNAUTHORIZED)), + ), + security( + (), + ("session" = []), + ), +)] #[get("/status")] fn status(_session: Session) -> Json<api_model::StatusResponse> { - Json(api_model::StatusResponse { - ok: true, - error: None, - }) + Json(STATUS_OK) } #[catch(401)] fn unauthorized() -> Json<api_model::StatusResponse> { - Json(api_model::StatusResponse { - ok: false, - error: Some("Unauthorized".to_string()), - }) + Json(STATUS_UNAUTHORIZED) } pub fn stage(basepath: String) -> AdHoc { diff --git a/server/src/main.rs b/server/src/main.rs index 223d861..124d914 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -8,14 +8,25 @@ use rocket::response::status::NotFound; use rocket::serde::json::Json; use rocket::{futures, Build, Rocket}; use rocket_db_pools::{sqlx, Connection, Database}; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; mod api_model; mod auth; +use auth::AuthApiAddon; + #[derive(Database)] #[database("eyeballs")] struct Db(sqlx::MySqlPool); +#[derive(OpenApi)] +#[openapi( + paths(projects, project, reviews, review,), + modifiers(&AuthApiAddon), +)] +pub struct MainApi; + enum Role { Reviewer, Watcher, @@ -52,6 +63,14 @@ impl TryFrom<u8> for api_model::ReviewState { } } +#[utoipa::path( + responses( + (status = 200, description = "Get all projects", body = api_model::Projects), + ), + security( + ("session" = []), + ), +)] #[get("/projects?<limit>&<offset>")] async fn projects<'r>( mut db: Connection<Db>, @@ -93,6 +112,15 @@ async fn projects<'r>( }) } +#[utoipa::path( + responses( + (status = 200, description = "Get project", body = api_model::Project), + (status = 404, description = "No such project"), + ), + security( + ("session" = []), + ), +)] #[get("/project/<projectid>")] async fn project<'r>( mut db: Connection<Db>, @@ -130,6 +158,14 @@ async fn project<'r>( Ok(Json(project)) } +#[utoipa::path( + responses( + (status = 200, description = "Get all reviews for project", body = api_model::Reviews), + ), + security( + ("session" = []), + ), +)] #[get("/project/<projectid>/reviews?<limit>&<offset>")] async fn reviews<'r>( mut db: Connection<Db>, @@ -180,6 +216,15 @@ async fn reviews<'r>( }) } +#[utoipa::path( + responses( + (status = 200, description = "Get review", body = api_model::Review), + (status = 404, description = "No such review"), + ), + security( + ("session" = []), + ), +)] #[get("/review/<reviewid>")] async fn review<'r>( mut db: Connection<Db>, @@ -258,10 +303,20 @@ async fn run_migrations(rocket: Rocket<Build>) -> fairing::Result { async fn main() -> Result<(), rocket::Error> { let basepath = "/api/v1"; + let mut api = MainApi::openapi(); + api.merge(auth::AuthApi::openapi()); + api.servers = Some(vec![utoipa::openapi::ServerBuilder::new() + .url(basepath) + .build()]); + let _rocket = rocket::build() .attach(Db::init()) .attach(AdHoc::try_on_ignite("Database Migrations", run_migrations)) .mount(basepath, routes![projects, project, reviews, review]) + .mount( + "/", + SwaggerUi::new("/openapi/ui/<_..>").url("/openapi/openapi.json", api), + ) .attach(auth::stage(basepath.to_string())) .launch() .await?; |
