From 776406684bdc591a4c97b58b8d28f689881c285e Mon Sep 17 00:00:00 2001 From: Joel Klinghed Date: Sun, 29 Dec 2024 22:40:12 +0100 Subject: Add openapi generation using utoipa --- server/src/api_model.rs | 47 ++++++++++++++++++++++------- server/src/auth.rs | 80 ++++++++++++++++++++++++++++++++++++++----------- server/src/main.rs | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 27 deletions(-) (limited to 'server/src') 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, pub watchers: Vec, + #[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, } -#[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, } -#[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, } -#[derive(Serialize)] +#[derive(Serialize, ToSchema)] pub struct StatusResponse { pub ok: bool, #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, + 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, 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 = "")] fn login( auth_config: &State, @@ -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 { - Json(api_model::StatusResponse { - ok: true, - error: None, - }) + Json(STATUS_OK) } #[catch(401)] fn unauthorized() -> Json { - 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 for api_model::ReviewState { } } +#[utoipa::path( + responses( + (status = 200, description = "Get all projects", body = api_model::Projects), + ), + security( + ("session" = []), + ), +)] #[get("/projects?&")] async fn projects<'r>( mut db: Connection, @@ -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/")] async fn project<'r>( mut db: Connection, @@ -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//reviews?&")] async fn reviews<'r>( mut db: Connection, @@ -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/")] async fn review<'r>( mut db: Connection, @@ -258,10 +303,20 @@ async fn run_migrations(rocket: Rocket) -> 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?; -- cgit v1.2.3-70-g09d2