From f45fbe06d56f8b385789d482f9858748d74610db Mon Sep 17 00:00:00 2001 From: Guillaume Leroy Date: Sat, 27 Jul 2024 17:21:36 +0200 Subject: [PATCH] refactor(api): improve doc (#26) --- .config/orbstack.yaml | 9 +- Cargo.toml | 1 + charts/simpaas-stack/README.md | 7 +- .../templates/swagger-ui/deployment.yaml | 6 +- charts/simpaas-stack/values.yaml | 9 +- get-simpaas | 1 + src/api.rs | 207 ++++++++++++++++-- src/domain.rs | 26 ++- 8 files changed, 219 insertions(+), 47 deletions(-) diff --git a/.config/orbstack.yaml b/.config/orbstack.yaml index f98ee8e..03e1e10 100644 --- a/.config/orbstack.yaml +++ b/.config/orbstack.yaml @@ -12,11 +12,7 @@ smtp: key: password swaggerUi: - enabled: true - - extraEnv: - - name: SWAGGER_JSON_URL - value: https://simpaas.k8s.orb.local/api/_doc + apiUrl: https://simpaas.k8s.orb.local/api/_doc grafana: grafana.ini: @@ -33,6 +29,3 @@ simpaas: ingress: domain: *domain - additionalRules: - swagger-ui: - enabled: true diff --git a/Cargo.toml b/Cargo.toml index faa2445..5e9aaba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ uuid = {version = "1", features = ["serde", "v4"]} validator = {version = "0", features = ["derive"]} [package] +description = "Platform as a Service (PaaS) based on Kubernetes and Helm." edition = "2021" name = "simpaas" version = "0.1.0" diff --git a/charts/simpaas-stack/README.md b/charts/simpaas-stack/README.md index ec670b0..2842ec0 100644 --- a/charts/simpaas-stack/README.md +++ b/charts/simpaas-stack/README.md @@ -12,6 +12,9 @@ helm install simpaas simpaas/simpaas-stack ## Minimal configuration ```yaml +swaggerUi: + apiUrl: https://simpaas.k8s.orb.local/api/_doc + grafana: grafana.ini: server: @@ -21,7 +24,3 @@ simpaas: ingress: domain: *domain ``` - -## SwaggerUI - -If you enable feature `swaggerUi`, you need to set environment variable `SWAGGER_JSON_URL` to OpenAPI endpoint (`/_doc`). diff --git a/charts/simpaas-stack/templates/swagger-ui/deployment.yaml b/charts/simpaas-stack/templates/swagger-ui/deployment.yaml index 7050a69..b63a537 100644 --- a/charts/simpaas-stack/templates/swagger-ui/deployment.yaml +++ b/charts/simpaas-stack/templates/swagger-ui/deployment.yaml @@ -57,8 +57,12 @@ spec: readinessProbe: {{ toYaml . | nindent 10 }} {{- end }} - {{- with $env }} env: + - name: BASE_URL + value: {{ .Values.swaggerUi.baseUrl }} + - name: SWAGGER_JSON_URL + value: {{ .Values.swaggerUi.apiUrl }} + {{- with $env }} {{- toYaml . | nindent 8 }} {{- end }} resources: diff --git a/charts/simpaas-stack/values.yaml b/charts/simpaas-stack/values.yaml index 888d925..b3532fc 100644 --- a/charts/simpaas-stack/values.yaml +++ b/charts/simpaas-stack/values.yaml @@ -53,7 +53,7 @@ smtp: readinessProbe: {} swaggerUi: - enabled: &swaggerUiEnabled false + enabled: &swaggerUiEnabled true replicas: 1 @@ -68,9 +68,7 @@ swaggerUi: podSecurityContext: {} securityContext: {} - env: - - name: BASE_URL - value: &swaggerUiPath /swagger-ui + env: [] extraEnv: [] resoures: {} @@ -89,6 +87,9 @@ swaggerUi: livenessProbe: {} readinessProbe: {} + baseUrl: &swaggerUiPath /swagger-ui + apiUrl: "" + grafana: enabled: &grafanaEnabled true diff --git a/get-simpaas b/get-simpaas index f31626b..4400145 100755 --- a/get-simpaas +++ b/get-simpaas @@ -128,6 +128,7 @@ spec: EOF install_chart $ns_simpaas simpaas $repo_simpaas/simpaas-stack \ + --set "swaggerUi.apiUrl=https://$SIMPAAS_DOMAIN/api/_doc" \ --set-json "grafana={\"grafana.ini\":{\"server\":{\"domain\":\"$SIMPAAS_DOMAIN\"}}}" \ --set "simpaas.common.image.tag=$SIMPAAS_VERSION" \ --set-json "simpaas.ingress.annotations={\"cert-manager.io/cluster-issuer\":\"$issuer\"}" \ diff --git a/src/api.rs b/src/api.rs index 9945f9f..8577a60 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,8 +1,14 @@ -use std::{borrow::Cow, collections::BTreeSet, net::SocketAddr, sync::Arc}; +use std::{ + borrow::Cow, + collections::{BTreeSet, HashMap}, + net::SocketAddr, + sync::Arc, +}; use aide::{ axum::ApiRouter, - openapi::{Info, OpenApi}, + openapi::{ApiKeyLocation, Info, OpenApi, SecurityScheme}, + transform::{TransformOpenApi, TransformOperation, TransformParameter, TransformResponse}, OperationOutput, }; use axum::{ @@ -20,7 +26,7 @@ use kube::api::ObjectMeta; use regex::Regex; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; +use serde_json::{json, Map, Value}; use serde_trim::{btreeset_string_trim, option_string_trim, string_trim}; use tokio::net::TcpListener; use tower_http::trace::TraceLayer; @@ -40,6 +46,9 @@ pub const PATH_JOIN: &str = "/join"; const COOKIE_NAME_JWT: &str = "simpaas-jwt"; +const SECURITY_SCHEME_BEARER: &str = "bearerAuth"; +const SECURITY_SCHEME_COOKIE: &str = "cookieAuth"; + pub struct ApiContext { pub cookie: CookieArgs, pub jwt_encoder: J, @@ -135,13 +144,7 @@ impl OperationOutput for Error { _ctx: &mut aide::gen::GenContext, _operation: &mut aide::openapi::Operation, ) -> Vec<(Option, aide::openapi::Response)> { - vec![( - Some(500), - aide::openapi::Response { - description: "An unexpected error occurred".into(), - ..Default::default() - }, - )] + vec![] } fn operation_response( @@ -275,30 +278,74 @@ fn create_router TransformOpenApi { + api.title("SimPaaS API") + .summary(env!("CARGO_PKG_DESCRIPTION")) + .security_scheme( + SECURITY_SCHEME_BEARER, + SecurityScheme::Http { + bearer_format: Some("Bearer ".into()), + description: None, + extensions: Default::default(), + scheme: "Bearer".into(), + }, + ) + .security_scheme( + SECURITY_SCHEME_COOKIE, + SecurityScheme::ApiKey { + description: None, + extensions: Default::default(), + location: ApiKeyLocation::Cookie, + name: COOKIE_NAME_JWT.into(), + }, + ) +} + #[instrument(skip(jar, ctx, req), fields(auth.name = req.user))] async fn authenticate_with_password( jar: CookieJar, @@ -321,6 +368,13 @@ async fn authenticate_with_password TransformOperation { + op.description("Generate a JWT.") + .response::<200, Json>() + .response_with::<401, (), _>(|op| op.description("Invalid credentials.")) + .response_with::<422, (), _>(unprocessable_entity_doc) +} + async fn create_app( auth_header: Option>>, jar: CookieJar, @@ -366,6 +420,17 @@ async fn create_app( .await } +fn create_app_doc(op: TransformOperation) -> TransformOperation { + op.description("Create a new app.") + .security_requirement_multi([SECURITY_SCHEME_BEARER, SECURITY_SCHEME_COOKIE]) + .response::<201, Json>() + .response_with::<400, Json>, _>(bad_request_doc) + .response_with::<401, (), _>(unauthorized_doc) + .response_with::<403, (), _>(forbidden_doc) + .response_with::<409, (), _>(|op| op.description("App with same name already exists.")) + .response_with::<422, (), _>(unprocessable_entity_doc) +} + async fn create_invitation( auth_header: Option>>, jar: CookieJar, @@ -404,6 +469,15 @@ async fn create_invitation( .await } +fn create_invitation_doc(op: TransformOperation) -> TransformOperation { + op.description("Send an invitation.") + .security_requirement_multi([SECURITY_SCHEME_BEARER, SECURITY_SCHEME_COOKIE]) + .response::<201, Json>() + .response_with::<401, (), _>(unauthorized_doc) + .response_with::<403, (), _>(forbidden_doc) + .response_with::<422, (), _>(unprocessable_entity_doc) +} + async fn delete_app( auth_header: Option>>, jar: CookieJar, @@ -430,6 +504,16 @@ async fn delete_app( .await } +fn delete_app_doc(op: TransformOperation) -> TransformOperation { + op.description("Delete an app.") + .security_requirement_multi([SECURITY_SCHEME_BEARER, SECURITY_SCHEME_COOKIE]) + .parameter("name", param_app_name_doc) + .response::<204, ()>() + .response_with::<401, (), _>(unauthorized_doc) + .response_with::<403, (), _>(forbidden_doc) + .response_with::<404, (), _>(not_found_doc) +} + #[instrument(skip(api))] async fn doc(Extension(api): Extension) -> Json { Json(api) @@ -459,6 +543,18 @@ async fn get_app( .await } +fn get_app_doc(op: TransformOperation) -> TransformOperation { + op.description("Get an app.") + .security_requirement_multi([SECURITY_SCHEME_BEARER, SECURITY_SCHEME_COOKIE]) + .parameter("name", param_app_name_doc) + .response::<200, Json>() + .response_with::<400, Json>, _>(bad_request_doc) + .response_with::<401, (), _>(unauthorized_doc) + .response_with::<403, (), _>(forbidden_doc) + .response_with::<404, (), _>(not_found_doc) + .response_with::<422, (), _>(unprocessable_entity_doc) +} + #[instrument] async fn health( _: State>>, @@ -505,6 +601,18 @@ async fn join( auth_response(&req.user, &user.spec, jar, &ctx) } +fn join_doc(op: TransformOperation) -> TransformOperation { + op.description("Accept a previously sent invitation.") + .parameter("token", |op: TransformParameter| { + op.description("Invitation token.") + }) + .response::<200, Json>() + .response_with::<400, Json>, _>(bad_request_doc) + .response_with::<404, Json>, _>(not_found_doc) + .response_with::<409, (), _>(|op| op.description("User with same name already exists.")) + .response_with::<422, (), _>(unprocessable_entity_doc) +} + async fn list_apps( auth_header: Option>>, jar: CookieJar, @@ -529,6 +637,14 @@ async fn list_apps( .await } +fn list_apps_doc(op: TransformOperation) -> TransformOperation { + op.description("List all apps.") + .security_requirement_multi([SECURITY_SCHEME_BEARER, SECURITY_SCHEME_COOKIE]) + .response::<200, Json>>() + .response_with::<401, (), _>(unauthorized_doc) + .response_with::<422, (), _>(unprocessable_entity_doc) +} + async fn update_app( auth_header: Option>>, jar: CookieJar, @@ -576,6 +692,18 @@ async fn update_app( .await } +fn update_app_doc(op: TransformOperation) -> TransformOperation { + op.description("Update an app.") + .security_requirement_multi([SECURITY_SCHEME_BEARER, SECURITY_SCHEME_COOKIE]) + .parameter("name", param_app_name_doc) + .response::<201, Json>() + .response_with::<400, Json>, _>(bad_request_doc) + .response_with::<401, (), _>(unauthorized_doc) + .response_with::<403, (), _>(forbidden_doc) + .response_with::<412, (), _>(|op| op.description("New owner doesn't exist.")) + .response_with::<422, (), _>(unprocessable_entity_doc) +} + #[instrument(skip(auth_header, jar, encoder, kube))] async fn authenticated_user( auth_header: Option>>, @@ -655,6 +783,45 @@ fn auth_response( )) } +fn param_app_name_doc(op: TransformParameter) -> TransformParameter { + op.description("Name of the app.") +} + +fn bad_request_doc( + op: TransformResponse>, +) -> TransformResponse> { + op.description("The request body is invalid.") + .example(HashMap::from_iter([( + "name".into(), + json!([ + { + "code": "length", + "message": null, + "params": { + "value": "", + "min": 1 + } + } + ]), + )])) +} + +fn forbidden_doc(op: TransformResponse) -> TransformResponse { + op.description("You're not allowed to do this action.") +} + +fn not_found_doc(op: TransformResponse) -> TransformResponse { + op.description("The resource doesn't exist.") +} + +fn unauthorized_doc(op: TransformResponse) -> TransformResponse { + op.description("Invalid JWT.") +} + +fn unprocessable_entity_doc(op: TransformResponse) -> TransformResponse { + op.description("Malformed request.") +} + fn default_filter() -> String { r".*".into() } diff --git a/src/domain.rs b/src/domain.rs index 91e1507..6454792 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -11,6 +11,12 @@ use serde_json::{Map, Value}; use serde_trim::string_trim; use validator::Validate; +const PERM_CREATE_APP: &str = "createApp"; +const PERM_DELETE_APP: &str = "deleteApp"; +const PERM_INVITE_USERS: &str = "inviteUsers"; +const PERM_READ_APP: &str = "readApp"; +const PERM_UPDATE_APP: &str = "updateApp"; + #[derive(Debug, thiserror::Error)] #[error("regex error: {0}")] pub struct PermissionError( @@ -31,11 +37,11 @@ pub enum Action<'a> { impl Display for Action<'_> { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { match self { - Self::CreateApp => write!(f, "create_app"), - Self::DeleteApp(pattern) => write!(f, "delete_app(`{pattern}`)"), - Self::InviteUsers => write!(f, "invite_users"), - Self::ReadApp(pattern) => write!(f, "read_app(`{pattern}`)"), - Self::UpdateApp(pattern) => write!(f, "update_app(`{pattern}`)"), + Self::CreateApp => write!(f, "{PERM_CREATE_APP}"), + Self::DeleteApp(pattern) => write!(f, "{PERM_DELETE_APP}(`{pattern}`)"), + Self::InviteUsers => write!(f, "{PERM_INVITE_USERS}"), + Self::ReadApp(pattern) => write!(f, "{PERM_READ_APP}(`{pattern}`)"), + Self::UpdateApp(pattern) => write!(f, "{PERM_UPDATE_APP}(`{pattern}`)"), } } } @@ -153,11 +159,11 @@ impl Permission { impl Display for Permission { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { match self { - Self::CreateApp {} => write!(f, "create_app"), - Self::DeleteApp { name } => write!(f, "delete_app(`{name}`)"), - Self::InviteUsers {} => write!(f, "invite_users"), - Self::ReadApp { name } => write!(f, "read_app(`{name}`)"), - Self::UpdateApp { name } => write!(f, "update_app(`{name}`)"), + Self::CreateApp {} => write!(f, "{PERM_CREATE_APP}"), + Self::DeleteApp { name } => write!(f, "{PERM_DELETE_APP}(`{name}`)"), + Self::InviteUsers {} => write!(f, "{PERM_INVITE_USERS}"), + Self::ReadApp { name } => write!(f, "{PERM_READ_APP}(`{name}`)"), + Self::UpdateApp { name } => write!(f, "{PERM_UPDATE_APP}(`{name}`)"), } } }