aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
authorrtkay123 <dev@kanjala.com>2026-04-03 14:43:54 +0200
committerrtkay123 <dev@kanjala.com>2026-04-03 14:43:54 +0200
commit41d90f42c37df06dabfd717d19f3dc72b5ba2d11 (patch)
tree2da259062cbdb459a2958d0be46eed20642b355a /crates
parent898a2966975c7397e35d8df6b72df42147bf18bd (diff)
downloadsellershut-41d90f42c37df06dabfd717d19f3dc72b5ba2d11.tar.bz2
sellershut-41d90f42c37df06dabfd717d19f3dc72b5ba2d11.zip
feat: openapi
Diffstat (limited to 'crates')
-rw-r--r--crates/api-base/Cargo.toml17
-rw-r--r--crates/api-base/src/health/apidoc.rs12
-rw-r--r--crates/api-base/src/health/mod.rs27
-rw-r--r--crates/api-base/src/lib.rs3
-rw-r--r--crates/api-base/src/version.rs45
-rw-r--r--crates/sellershut/Cargo.toml16
-rw-r--r--crates/sellershut/src/config/mod.rs2
-rw-r--r--crates/sellershut/src/config/server.rs10
-rw-r--r--crates/sellershut/src/main.rs48
-rw-r--r--crates/sellershut/src/server/api/mod.rs55
-rw-r--r--crates/sellershut/src/server/api/routes/logs/mod.rs54
-rw-r--r--crates/sellershut/src/server/api/routes/mod.rs38
-rw-r--r--crates/sellershut/src/server/logs.rs36
-rw-r--r--crates/sellershut/src/server/mod.rs2
-rw-r--r--crates/sellershut/src/state/mod.rs12
15 files changed, 360 insertions, 17 deletions
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<S> FromRequestParts<S> for Version
+ where
+ S: Send + Sync,
+ {
+ type Rejection = Response;
+
+ async fn from_request_parts(
+ parts: &mut Parts,
+ _state: &S,
+ ) -> Result<Self, Self::Rejection> {
+ let params: Path<HashMap<String, String>> =
+ 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<PathBuf>,
/// Server configuration.
#[command(flatten)]
- server: server::ServerConfig,
+ pub server: server::ServerConfig,
}
impl Config {
pub fn load(cli: Self) -> Result<Self> {
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<u16>,
+ pub port: Option<u16>,
/// Request timeout duration
#[arg(long, env = "HUT_SERVER_TIMEOUT_SECS")]
- timeout_duration: Option<u64>,
+ pub timeout_duration: Option<u64>,
/// Log level for the application server.
- #[arg(long, env = "HUT_SERVER_LOG_LEVEL")]
- log_level: Option<String>,
+ #[arg(long, env = "HUT_LOG")]
+ pub log_level: Option<String>,
/// Directory where log files should be written.
#[arg(long, env = "HUT_SERVER_LOG_FILE_DIRECTORY")]
- log_directory: Option<PathBuf>,
+ pub log_directory: Option<PathBuf>,
}
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<AppState>, Json(body): Json<LogLevel>) -> StatusCode {
+ if let Ok(value) = body.log_level.parse::<tracing_subscriber::EnvFilter>() {
+ 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<AppState> {
+ 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<str>, content_type = "text/plain",
+ )
+ ),
+ tag = "sellershut"
+)]
+pub async fn health(State(state): State<AppState>) -> 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<EnvFilter, tracing_subscriber::Registry>;
+
+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<dyn HealthDriver>,
+ pub log_handle: LogHandle,
+}