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 /server/src | |
| parent | 7bc8e8b7262a3f3abe3222b3b434838e85cdb2bb (diff) | |
Add openapi generation using utoipa
Diffstat (limited to 'server/src')
| -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 |
3 files changed, 155 insertions, 27 deletions
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?; |
