From 41d90f42c37df06dabfd717d19f3dc72b5ba2d11 Mon Sep 17 00:00:00 2001 From: rtkay123 Date: Fri, 3 Apr 2026 14:43:54 +0200 Subject: feat: openapi --- crates/api-base/Cargo.toml | 17 +++++++ crates/api-base/src/health/apidoc.rs | 12 +++++ crates/api-base/src/health/mod.rs | 27 +++++++++++ crates/api-base/src/lib.rs | 3 ++ crates/api-base/src/version.rs | 45 ++++++++++++++++++ crates/sellershut/Cargo.toml | 16 +++++++ crates/sellershut/src/config/mod.rs | 2 +- crates/sellershut/src/config/server.rs | 10 ++-- crates/sellershut/src/main.rs | 48 ++++++++++++++----- crates/sellershut/src/server/api/mod.rs | 55 ++++++++++++++++++++++ .../sellershut/src/server/api/routes/logs/mod.rs | 54 +++++++++++++++++++++ crates/sellershut/src/server/api/routes/mod.rs | 38 +++++++++++++++ crates/sellershut/src/server/logs.rs | 36 ++++++++++++++ crates/sellershut/src/server/mod.rs | 2 + crates/sellershut/src/state/mod.rs | 12 +++++ 15 files changed, 360 insertions(+), 17 deletions(-) create mode 100644 crates/api-base/Cargo.toml create mode 100644 crates/api-base/src/health/apidoc.rs create mode 100644 crates/api-base/src/health/mod.rs create mode 100644 crates/api-base/src/lib.rs create mode 100644 crates/api-base/src/version.rs create mode 100644 crates/sellershut/src/server/api/mod.rs create mode 100644 crates/sellershut/src/server/api/routes/logs/mod.rs create mode 100644 crates/sellershut/src/server/api/routes/mod.rs create mode 100644 crates/sellershut/src/server/logs.rs create mode 100644 crates/sellershut/src/server/mod.rs create mode 100644 crates/sellershut/src/state/mod.rs (limited to 'crates') diff --git a/crates/api-base/Cargo.toml b/crates/api-base/Cargo.toml new file mode 100644 index 0000000..e15c19b --- /dev/null +++ b/crates/api-base/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "api-base" +version = "0.0.0" +edition = "2024" +license.workspace = true +readme.workspace = true +documentation.workspace = true +homepage.workspace = true + +[dependencies] +axum = { workspace = true, optional = true } +serde.workspace = true +utoipa = { workspace = true, optional = true } + +[features] +axum = ["dep:axum"] +utoipa = ["dep:utoipa", "serde/derive", "axum"] diff --git a/crates/api-base/src/health/apidoc.rs b/crates/api-base/src/health/apidoc.rs new file mode 100644 index 0000000..45b8754 --- /dev/null +++ b/crates/api-base/src/health/apidoc.rs @@ -0,0 +1,12 @@ +use utoipa::OpenApi; + +use crate::Version; + +#[derive(OpenApi)] +#[openapi( + tags( + (name = "sellershut", description = "API health check"), + ), + components(schemas(Version)) +)] +pub struct ApiDocBase; diff --git a/crates/api-base/src/health/mod.rs b/crates/api-base/src/health/mod.rs new file mode 100644 index 0000000..a84dc85 --- /dev/null +++ b/crates/api-base/src/health/mod.rs @@ -0,0 +1,27 @@ +#[cfg(feature = "utoipa")] +mod apidoc; + +#[cfg(feature = "utoipa")] +pub use apidoc::*; + +#[derive(Default)] +pub struct BaseService; + +impl HealthDriver for BaseService {} + +pub trait HealthDriver: Send + Sync { + fn health(&self, app: &str, version: &str) -> String { + format!("{app} v{version} is live") + } +} + +#[cfg(test)] +mod tests { + use crate::health::{BaseService, HealthDriver}; + + #[test] + fn health() { + let app = BaseService.health(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")); + assert!(app.contains("is live")); + } +} diff --git a/crates/api-base/src/lib.rs b/crates/api-base/src/lib.rs new file mode 100644 index 0000000..9c632e0 --- /dev/null +++ b/crates/api-base/src/lib.rs @@ -0,0 +1,3 @@ +pub mod health; +mod version; +pub use version::*; diff --git a/crates/api-base/src/version.rs b/crates/api-base/src/version.rs new file mode 100644 index 0000000..0652c6e --- /dev/null +++ b/crates/api-base/src/version.rs @@ -0,0 +1,45 @@ +#[derive(Debug)] +#[cfg_attr( + feature = "utoipa", + derive(utoipa::ToSchema, serde::Deserialize, serde::Serialize), + schema(example = "v0"), + serde(rename_all = "lowercase") +)] +pub enum Version { + V0, +} + +#[cfg(feature = "axum")] +mod request { + use super::*; + use axum::RequestPartsExt; + use axum::extract::{FromRequestParts, Path}; + use axum::http::StatusCode; + use axum::http::request::Parts; + use axum::response::{IntoResponse, Response}; + use std::collections::HashMap; + + impl FromRequestParts for Version + where + S: Send + Sync, + { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + _state: &S, + ) -> Result { + let params: Path> = + parts.extract().await.map_err(IntoResponse::into_response)?; + + let version = params + .get("apiVersion") + .ok_or_else(|| (StatusCode::NOT_FOUND, "version param missing").into_response())?; + + match version.as_str() { + "v0" => Ok(Version::V0), + _ => Err((StatusCode::NOT_FOUND, "unknown version").into_response()), + } + } + } +} diff --git a/crates/sellershut/Cargo.toml b/crates/sellershut/Cargo.toml index 9ded17b..f7cd15a 100644 --- a/crates/sellershut/Cargo.toml +++ b/crates/sellershut/Cargo.toml @@ -6,13 +6,29 @@ license.workspace = true readme.workspace = true documentation.workspace = true homepage.workspace = true +description = "A federated marketplace platform" [dependencies] anyhow = "1.0.102" +api-base = { workspace = true, features = ["utoipa"] } axum = { version = "0.8.8", features = ["macros"] } +bon = "3.9.1" clap = { version = "4.6.0", features = ["derive", "env"] } serde = { workspace = true, features = ["derive"] } tokio = { version = "1.51.0", features = ["macros", "rt", "rt-multi-thread"] } toml = "1.1.2" tracing.workspace = true +tracing-appender = "0.2.4" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +utoipa = { workspace = true, features = ["axum_extras"] } +utoipa-axum = "0.2.0" +utoipa-rapidoc = { version = "6.0.0", features = ["axum"], optional = true } +utoipa-redoc = { version = "6.0.0", features = ["axum"], optional = true } +utoipa-scalar = { version = "0.3.0", features = ["axum"], optional = true } +utoipa-swagger-ui = { version = "9.0.2", features = ["axum"], optional = true } + +[features] +swagger = ["dep:utoipa-swagger-ui"] +redoc = ["dep:utoipa-redoc"] +rapidoc = ["dep:utoipa-rapidoc"] +scalar = ["dep:utoipa-scalar"] diff --git a/crates/sellershut/src/config/mod.rs b/crates/sellershut/src/config/mod.rs index b7a6ba3..d35ba1e 100644 --- a/crates/sellershut/src/config/mod.rs +++ b/crates/sellershut/src/config/mod.rs @@ -16,7 +16,7 @@ pub struct Config { config: Option, /// Server configuration. #[command(flatten)] - server: server::ServerConfig, + pub server: server::ServerConfig, } impl Config { pub fn load(cli: Self) -> Result { diff --git a/crates/sellershut/src/config/server.rs b/crates/sellershut/src/config/server.rs index 08b7828..3680c16 100644 --- a/crates/sellershut/src/config/server.rs +++ b/crates/sellershut/src/config/server.rs @@ -8,18 +8,18 @@ use serde::{Deserialize, Serialize}; pub struct ServerConfig { /// Port the application server listens on. #[arg(short, long, env = "HUT_SERVER_PORT")] - port: Option, + pub port: Option, /// Request timeout duration #[arg(long, env = "HUT_SERVER_TIMEOUT_SECS")] - timeout_duration: Option, + pub timeout_duration: Option, /// Log level for the application server. - #[arg(long, env = "HUT_SERVER_LOG_LEVEL")] - log_level: Option, + #[arg(long, env = "HUT_LOG")] + pub log_level: Option, /// Directory where log files should be written. #[arg(long, env = "HUT_SERVER_LOG_FILE_DIRECTORY")] - log_directory: Option, + pub log_directory: Option, } impl ServerConfig { diff --git a/crates/sellershut/src/main.rs b/crates/sellershut/src/main.rs index 900d554..cb7be07 100644 --- a/crates/sellershut/src/main.rs +++ b/crates/sellershut/src/main.rs @@ -1,24 +1,50 @@ mod config; +mod server; +mod state; -use anyhow::Result; +use std::{ + net::{Ipv6Addr, SocketAddr}, + sync::Arc, +}; + +use anyhow::{Context, Result}; +use api_base::health::BaseService; use clap::Parser; +use tokio::net::TcpListener; +use tracing::info; -use crate::config::cli; +use crate::{config::cli, state::AppState}; #[tokio::main] async fn main() -> Result<()> { let cli = cli::Cli::parse(); - match cli.command { - Some(cli::Commands::GenerateConfig { dir, file_name }) => { - let path = config::generate_config_file(&dir, &file_name)?; - println!("Wrote {}", path.display()); - } - None => { - let cfg = config::Config::load(cli.config)?; - println!("{cfg:#?}"); - } + if let Some(cli::Commands::GenerateConfig { dir, file_name }) = cli.command { + let path = config::generate_config_file(&dir, &file_name)?; + println!("Wrote {}", path.display()); + return Ok(()); } + let cfg = config::Config::load(cli.config)?; + let (log_handle, _log_guard) = server::logs::initialise_logging( + cfg.server.log_level.as_deref(), + cfg.server.log_directory.as_ref(), + )?; + + let state = AppState::builder() + .log_handle(log_handle) + .base_service(Arc::new(BaseService)) + .build(); + let addr = SocketAddr::from(( + Ipv6Addr::UNSPECIFIED, + cfg.server.port.context("missing port")?, + )); + + let app = server::api::router(state, cfg).await; + + let listener = TcpListener::bind(addr).await?; + info!(addr = ?listener.local_addr().unwrap(), "starting server"); + + axum::serve(listener, app).await?; Ok(()) } diff --git a/crates/sellershut/src/server/api/mod.rs b/crates/sellershut/src/server/api/mod.rs new file mode 100644 index 0000000..0fd48c6 --- /dev/null +++ b/crates/sellershut/src/server/api/mod.rs @@ -0,0 +1,55 @@ +use api_base::health::ApiDocBase; +use axum::Router; +use utoipa::OpenApi; +use utoipa_axum::router::OpenApiRouter; + +use crate::{config::Config, server::api::routes::ServerConfigDoc, state::AppState}; + +pub mod routes; + +#[derive(OpenApi)] +#[openapi( + tags( + (name = "sellershut", description = env!("CARGO_PKG_DESCRIPTION")), + ), +)] +pub struct ApiDoc; + +pub async fn router(state: AppState, config: Config) -> Router<()> { + let mut doc = ApiDoc::openapi(); + + doc.merge(ApiDocBase::openapi()); + doc.merge(ServerConfigDoc::openapi()); + + let stubs = OpenApiRouter::with_openapi(doc) + .routes(utoipa_axum::routes!(routes::health)) + .nest("/api", routes::router(state.clone())) + .with_state(state); + + let (router, _api) = stubs.split_for_parts(); + + #[cfg(feature = "swagger")] + let router = router.merge( + utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") + .url("/api-docs/swaggerdoc.json", _api.clone()), + ); + + #[cfg(feature = "redoc")] + let router = { + use utoipa_redoc::Servable as _; + router.merge(utoipa_redoc::Redoc::with_url("/redoc", _api.clone())) + }; + + #[cfg(feature = "scalar")] + let router = { + use utoipa_scalar::Servable as _; + router.merge(utoipa_scalar::Scalar::with_url("/scalar", _api.clone())) + }; + + #[cfg(feature = "rapidoc")] + let router = router.merge( + utoipa_rapidoc::RapiDoc::with_openapi("/api-docs/rapidoc.json", _api).path("/rapidoc"), + ); + + router +} diff --git a/crates/sellershut/src/server/api/routes/logs/mod.rs b/crates/sellershut/src/server/api/routes/logs/mod.rs new file mode 100644 index 0000000..8718d86 --- /dev/null +++ b/crates/sellershut/src/server/api/routes/logs/mod.rs @@ -0,0 +1,54 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde::Deserialize; +use tracing::warn; +use utoipa::ToSchema; + +use crate::state::AppState; + +#[derive(Deserialize, Debug, Clone, ToSchema)] +/// Log level +#[serde(rename_all = "camelCase")] +pub struct LogLevel { + #[schema(examples("info", "trace", "warden=debug,tower_http=debug,axum::rejection=trace"))] + log_level: String, +} + +/// Update log level +#[utoipa::path( + patch, + responses( + ( + status = 200, + description = "Server's log level has been updated", + headers( + ("x-request-id", description = "Request identifier") + ) + ), + ( + status = 400, + description = "Invalid log level", + headers( + ("x-request-id", description = "Request identifier") + ) + ), + ), + operation_id = "log_update", // https://github.com/juhaku/utoipa/issues/1170 + path = "/logging", + tag = super::CONFIG, + request_body( + content = LogLevel + ) +)] +pub async fn reload(State(state): State, Json(body): Json) -> StatusCode { + if let Ok(value) = body.log_level.parse::() { + match state.log_handle.reload(value) { + Ok(_) => StatusCode::OK, + Err(e) => { + warn!("{e:?}"); + StatusCode::INTERNAL_SERVER_ERROR + } + } + } else { + StatusCode::BAD_REQUEST + } +} diff --git a/crates/sellershut/src/server/api/routes/mod.rs b/crates/sellershut/src/server/api/routes/mod.rs new file mode 100644 index 0000000..f343742 --- /dev/null +++ b/crates/sellershut/src/server/api/routes/mod.rs @@ -0,0 +1,38 @@ +mod logs; + +use axum::{extract::State, response::IntoResponse}; + +use crate::state::AppState; + +use utoipa::OpenApi; +use utoipa_axum::router::OpenApiRouter; + +const CONFIG: &str = "Server configuration"; + +#[derive(OpenApi)] +#[openapi(tags((name = CONFIG, description = "Configuration endpoints")))] +pub struct ServerConfigDoc; + +pub fn router(state: AppState) -> OpenApiRouter { + OpenApiRouter::new() + .routes(utoipa_axum::routes!(logs::reload)) + .with_state(state) +} + +/// Health +#[utoipa::path( + method(get, head), + path = "/api/health", + responses( + ( + status = OK, description = "API is live", + body = Option, content_type = "text/plain", + ) + ), + tag = "sellershut" +)] +pub async fn health(State(state): State) -> impl IntoResponse { + state + .base_service + .health(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")) +} diff --git a/crates/sellershut/src/server/logs.rs b/crates/sellershut/src/server/logs.rs new file mode 100644 index 0000000..edb698b --- /dev/null +++ b/crates/sellershut/src/server/logs.rs @@ -0,0 +1,36 @@ +use anyhow::{Context, Result}; +use std::{env, path::PathBuf}; +use tracing_appender::rolling::{RollingFileAppender, Rotation}; +use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; + +pub type LogHandle = tracing_subscriber::reload::Handle; + +pub fn initialise_logging( + level: Option<&str>, + log_dir: Option<&PathBuf>, +) -> Result<(LogHandle, tracing_appender::non_blocking::WorkerGuard)> { + let level = level.context("missing log level")?; + let log_dir = log_dir.context("missing log dir")?; + + let file_appender = RollingFileAppender::new(Rotation::DAILY, log_dir, env!("CARGO_PKG_NAME")); + + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + + let env_filter = + tracing_subscriber::EnvFilter::try_from_env("HUT_LOG").unwrap_or_else(|_| level.into()); + + let (filter_layer, reload_handle) = tracing_subscriber::reload::Layer::new(env_filter); + + let file_layer = tracing_subscriber::fmt::layer() + .with_writer(non_blocking) + .with_ansi(false) + .with_target(true); + + tracing_subscriber::registry() + .with(filter_layer) + .with(tracing_subscriber::fmt::layer()) + .with(file_layer) + .init(); + + Ok((reload_handle, guard)) +} diff --git a/crates/sellershut/src/server/mod.rs b/crates/sellershut/src/server/mod.rs new file mode 100644 index 0000000..f669af9 --- /dev/null +++ b/crates/sellershut/src/server/mod.rs @@ -0,0 +1,2 @@ +pub mod api; +pub mod logs; diff --git a/crates/sellershut/src/state/mod.rs b/crates/sellershut/src/state/mod.rs new file mode 100644 index 0000000..067cc62 --- /dev/null +++ b/crates/sellershut/src/state/mod.rs @@ -0,0 +1,12 @@ +use std::sync::Arc; + +use api_base::health::HealthDriver; +use bon::Builder; + +use crate::server::logs::LogHandle; + +#[derive(Clone, Builder)] +pub struct AppState { + pub base_service: Arc, + pub log_handle: LogHandle, +} -- cgit v1.2.3