4 Commits

11 changed files with 386 additions and 130 deletions

2
Cargo.lock generated
View File

@@ -4353,11 +4353,13 @@ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"chrono", "chrono",
"clap",
"config", "config",
"database", "database",
"include_dir", "include_dir",
"migration", "migration",
"mime_guess", "mime_guess",
"once_cell",
"sea-orm", "sea-orm",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -21,3 +21,5 @@ sea-orm = { workspace = true }
include_dir = { version = "0.7.4" } include_dir = { version = "0.7.4" }
mime_guess = { version = "2.0.5" } mime_guess = { version = "2.0.5" }
utoipa = { version = "5.4.0", features = ["macros", "axum_extras", "chrono", "decimal", "uuid", "time", "openapi_extensions"] } utoipa = { version = "5.4.0", features = ["macros", "axum_extras", "chrono", "decimal", "uuid", "time", "openapi_extensions"] }
clap = { version = "4.5.53" }
once_cell = { version = "1.21.3" }

49
apps/api/src/cmd.rs Normal file
View File

@@ -0,0 +1,49 @@
mod generate_openapi;
mod start_server;
pub use start_server::start_server;
use std::pin::Pin;
use std::{future::Future, process::exit};
use clap::{ArgMatches, Command};
pub struct CliCommand {
pub command: Command,
pub action: fn(&clap::ArgMatches) -> Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
}
static CLI_COMMANDS: once_cell::sync::Lazy<
[CliCommand; 2 /* Update this count when adding new commands */],
> =
once_cell::sync::Lazy::new(|| {
[
// Add new commands here
generate_openapi::get_cli_command(),
start_server::get_cli_command(),
]
});
pub fn get_command() -> Command {
let mut c = Command::new("cmd");
for cmd in CLI_COMMANDS.iter() {
c = c.subcommand(cmd.command.clone());
}
c
}
pub fn execute(matches: &ArgMatches, help_msg: &str) -> Pin<Box<dyn Future<Output = ()> + Send>> {
if let Some((subcommand_name, subcommand_matches)) = matches.subcommand() {
for cmd in CLI_COMMANDS.iter() {
if cmd.command.get_name() == subcommand_name {
return (cmd.action)(subcommand_matches);
}
}
}
eprintln!("Error: No valid subcommand provided.");
eprintln!("{}", help_msg);
exit(1);
}

View File

@@ -0,0 +1,44 @@
use clap::{Arg, Command};
use tracing::info;
use utoipa::OpenApi;
use crate::{cmd::CliCommand, log, routes::ApiDoc};
pub fn get_cli_command() -> CliCommand {
CliCommand {
command: command(),
action,
}
}
fn command() -> Command {
Command::new("generate:openapi")
.arg(
Arg::new("output_path")
.short('o')
.long("output-path")
.value_name("PATH")
.help("Path to output the generated OpenAPI documentation")
.required(true),
)
.about("Generate OpenAPI documentation")
}
fn action(
_matches: &clap::ArgMatches,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
let output_path = _matches.get_one::<String>("output_path");
let output_path = output_path.unwrap().to_string();
Box::pin(async move {
tracing::subscriber::with_default(log::make_temporary_subscriber(), || {
info!("Generating OpenAPI documentation...");
let doc = ApiDoc::openapi();
let json = doc
.to_pretty_json()
.expect("Failed to serialize OpenAPI doc to JSON");
std::fs::write(&output_path, json).expect("Failed to write OpenAPI doc to file");
info!("OpenAPI documentation generated at {}", output_path);
})
})
}

View File

@@ -0,0 +1,142 @@
use std::sync::Arc;
use axum::Router;
use clap::Command;
use database::get_connection;
use sea_orm::ConnectOptions;
use tracing::{debug, info};
use tracing_subscriber::fmt::format::{DefaultFields, Format};
use crate::{
cmd::CliCommand,
configs::{ProgramSettings, get_program_settings, logging::LoggingSettings},
log,
routes::{self, AppService, AppState},
services::settings::SettingsService,
tasks,
};
pub fn get_cli_command() -> CliCommand {
CliCommand {
command: command(),
action,
}
}
fn command() -> Command {
Command::new("start").about("Start the server")
}
fn action(
_matches: &clap::ArgMatches,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
Box::pin(async move {})
}
pub async fn start_server() {
let settings = tracing::subscriber::with_default(
log::make_temporary_subscriber(),
|| -> ProgramSettings {
debug!("Temporary subscriber installed.");
info!("Reading configuration...");
let settings = get_program_settings();
info!("Configuration read successfully.");
debug!("Resetting global subscriber...");
let subscriber = get_global_tracing_subscriber_builder(&settings.logging).finish();
tracing::subscriber::set_global_default(subscriber)
.expect("Failed to set global default subscriber");
debug!(
"Global subscriber set with logging level: {:?}",
settings.logging.level
);
settings
},
);
tasks::startup::run_startup_tasks(&settings)
.await
.expect("Failed to run startup tasks");
// setup database connection pool
info!("Establishing database connection...");
debug!("Database URL: {}", settings.database.url);
let db_options = |options: &mut ConnectOptions| {
options.max_connections(settings.database.max_connections);
};
let db_connection = Arc::new(
get_connection(&settings.database.url, Some(db_options))
.await
.expect("Failed to establish database connection"),
);
info!("Database connection established.");
// build the axum app and run the server...
info!("Starting application...");
let app: Router = routes::get_root_router(Arc::new(get_app_state(&db_connection)));
let address = format!("{}:{}", settings.server.address, settings.server.port);
info!("Starting server at http://{}", address);
let listener = tokio::net::TcpListener::bind(address)
.await
.expect("Failed to bind to address");
axum::serve(listener, app)
.await
.expect("Failed to run the server");
}
fn get_global_tracing_subscriber_builder(
settings: &LoggingSettings,
) -> tracing_subscriber::fmt::SubscriberBuilder<
DefaultFields,
Format<tracing_subscriber::fmt::format::Full, BoxedTimer>,
> {
// After configuration is read, install the global subscriber
let builder = tracing_subscriber::fmt()
.with_max_level(settings.level)
.with_target(false)
.with_level(true);
if settings.utc {
builder.with_timer(BoxedTimer(Box::new(
tracing_subscriber::fmt::time::UtcTime::rfc_3339(),
)))
} else {
builder.with_timer(BoxedTimer(Box::new(
tracing_subscriber::fmt::time::ChronoLocal::rfc_3339(),
)))
}
}
fn get_app_state(db_connection: &Arc<sea_orm::DatabaseConnection>) -> AppState {
AppState {
database_connection: db_connection.clone(),
service: Arc::new(AppService {
settings: Arc::new(SettingsService::new(db_connection.clone())),
}),
}
}
// A small wrapper that holds a boxed `FormatTime` trait object and itself
// implements `FormatTime`, allowing us to use it as a concrete type with
// `builder.with_timer` while still picking the concrete timer implementation
// at runtime.
// wrapper type to hold boxed timers and implement the `FormatTime` trait for
// a concrete type so `with_timer` may be called once outside the conditional.
struct BoxedTimer(Box<dyn tracing_subscriber::fmt::time::FormatTime + Send + Sync + 'static>);
impl tracing_subscriber::fmt::time::FormatTime for BoxedTimer {
fn format_time(
&self,
w: &mut tracing_subscriber::fmt::format::Writer<'_>,
) -> std::result::Result<(), std::fmt::Error> {
self.0.format_time(w)
}
}

7
apps/api/src/log.rs Normal file
View File

@@ -0,0 +1,7 @@
pub fn make_temporary_subscriber() -> tracing_subscriber::fmt::Subscriber {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_target(false)
.with_level(true)
.finish()
}

View File

@@ -1,136 +1,39 @@
mod cmd;
mod configs; mod configs;
mod errors; mod errors;
mod log;
mod middlewares; mod middlewares;
mod routes; mod routes;
mod services; mod services;
mod tasks; mod tasks;
use std::sync::Arc;
use axum::Router;
use database::get_connection;
use sea_orm::ConnectOptions;
use tracing::{debug, info};
use tracing_subscriber::fmt::format::{DefaultFields, Format};
use crate::{
configs::{ProgramSettings, get_program_settings, logging::LoggingSettings},
routes::{AppService, AppState},
services::settings::SettingsService,
};
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Temporary subscriber for initial logging during configuration reading // If there are command-line arguments, treat it as a CLI command
let make_temporary_subscriber = || { if std::env::args().len() > 1 {
tracing_subscriber::fmt() tracing::subscriber::with_default(log::make_temporary_subscriber(), || {
.with_max_level(tracing::Level::DEBUG) use clap::error::ErrorKind;
.with_target(false) //
.with_level(true) let mut command = cmd::get_command();
.finish() let help_output = format!("{}", command.render_help());
}; let matches = command
.try_get_matches()
let settings = .unwrap_or_else(|err| match err.kind() {
tracing::subscriber::with_default(make_temporary_subscriber(), || -> ProgramSettings { ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
debug!("Temporary subscriber installed."); err.print().expect("Error writing Error");
info!("Reading configuration..."); std::process::exit(0);
let settings = get_program_settings(); }
info!("Configuration read successfully."); _ => {
debug!("Resetting global subscriber..."); err.print().expect("Error writing Error");
std::process::exit(1);
let subscriber = get_global_tracing_subscriber_builder(&settings.logging).finish(); }
tracing::subscriber::set_global_default(subscriber) });
.expect("Failed to set global default subscriber"); cmd::execute(&matches, &help_output)
})
debug!( .await;
"Global subscriber set with logging level: {:?}", return;
settings.logging.level
);
settings
});
tasks::startup::run_startup_tasks(&settings)
.await
.expect("Failed to run startup tasks");
// setup database connection pool
info!("Establishing database connection...");
debug!("Database URL: {}", settings.database.url);
let db_options = |options: &mut ConnectOptions| {
options.max_connections(settings.database.max_connections);
};
let db_connection = Arc::new(
get_connection(&settings.database.url, Some(db_options))
.await
.expect("Failed to establish database connection"),
);
info!("Database connection established.");
// build the axum app and run the server...
info!("Starting application...");
let app: Router = routes::get_root_router(Arc::new(get_app_state(&db_connection)));
let address = format!("{}:{}", settings.server.address, settings.server.port);
info!("Starting server at http://{}", address);
let listener = tokio::net::TcpListener::bind(address)
.await
.expect("Failed to bind to address");
axum::serve(listener, app)
.await
.expect("Failed to run the server");
}
fn get_global_tracing_subscriber_builder(
settings: &LoggingSettings,
) -> tracing_subscriber::fmt::SubscriberBuilder<
DefaultFields,
Format<tracing_subscriber::fmt::format::Full, BoxedTimer>,
> {
// After configuration is read, install the global subscriber
let builder = tracing_subscriber::fmt()
.with_max_level(settings.level)
.with_target(false)
.with_level(true);
if settings.utc {
builder.with_timer(BoxedTimer(Box::new(
tracing_subscriber::fmt::time::UtcTime::rfc_3339(),
)))
} else {
builder.with_timer(BoxedTimer(Box::new(
tracing_subscriber::fmt::time::ChronoLocal::rfc_3339(),
)))
} }
}
fn get_app_state(db_connection: &Arc<sea_orm::DatabaseConnection>) -> AppState { // No command-line arguments, start the server normally
AppState { cmd::start_server().await;
database_connection: db_connection.clone(),
service: Arc::new(AppService {
settings: Arc::new(SettingsService::new(db_connection.clone())),
}),
}
}
// A small wrapper that holds a boxed `FormatTime` trait object and itself
// implements `FormatTime`, allowing us to use it as a concrete type with
// `builder.with_timer` while still picking the concrete timer implementation
// at runtime.
// wrapper type to hold boxed timers and implement the `FormatTime` trait for
// a concrete type so `with_timer` may be called once outside the conditional.
struct BoxedTimer(Box<dyn tracing_subscriber::fmt::time::FormatTime + Send + Sync + 'static>);
impl tracing_subscriber::fmt::time::FormatTime for BoxedTimer {
fn format_time(
&self,
w: &mut tracing_subscriber::fmt::format::Writer<'_>,
) -> std::result::Result<(), std::fmt::Error> {
self.0.format_time(w)
}
} }

View File

@@ -4,20 +4,27 @@ use axum::{Json, extract::State, http::StatusCode};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::routes::api::health::state::HealthState; use crate::routes::api::{health::state::HealthState, openapi::tag::HEALTH_TAG};
const STATUS_HEALTHY: &str = "healthy"; const STATUS_HEALTHY: &str = "healthy";
const STATUS_UNHEALTHY: &str = "unhealthy"; const STATUS_UNHEALTHY: &str = "unhealthy";
/// System health information
#[derive(Serialize, Deserialize, utoipa::ToSchema)] #[derive(Serialize, Deserialize, utoipa::ToSchema)]
pub struct HealthInfo { pub struct HealthInfo {
/// Health status: "healthy" or "unhealthy"
pub status: String, pub status: String,
/// Application version
pub version: String, pub version: String,
// RFC 3339 formatted timestamp /// RFC 3339 formatted timestamp
pub up_since: DateTime<Utc>, pub up_since: DateTime<Utc>,
/// List of error messages if unhealthy
pub errors: Option<Vec<String>>, pub errors: Option<Vec<String>>,
} }
/// Health check endpoint
///
/// Returns the health status, version, uptime, and any errors if unhealthy.
#[utoipa::path( #[utoipa::path(
get, get,
path = "/api/health/info", path = "/api/health/info",
@@ -25,9 +32,7 @@ pub struct HealthInfo {
(status = 200, description = "Health information retrieved successfully", body = HealthInfo), (status = 200, description = "Health information retrieved successfully", body = HealthInfo),
(status = NOT_FOUND, description = "Health information not found") (status = NOT_FOUND, description = "Health information not found")
), ),
params( tag = HEALTH_TAG,
("id" = u64, Path, description = "Pet database id to get Pet for"),
)
)] )]
pub async fn get_health_info( pub async fn get_health_info(
State(state): State<Arc<HealthState>>, State(state): State<Arc<HealthState>>,

View File

@@ -1,3 +1,18 @@
pub mod tag {
/// Health tag constant
pub const HEALTH_TAG: &str = "Health";
}
#[derive(utoipa::OpenApi)] #[derive(utoipa::OpenApi)]
#[openapi(paths(crate::routes::api::health::info::get_health_info))] #[openapi(
paths(
crate::routes::api::health::info::get_health_info
),
components(
schemas(crate::routes::api::health::info::HealthInfo) // Register any schemas used in your paths
),
tags(
(name = tag::HEALTH_TAG, description = "Health information API")
)
)]
pub struct ApiDoc; pub struct ApiDoc;

82
apps/api/swagger.json Normal file
View File

@@ -0,0 +1,82 @@
{
"openapi": "3.1.0",
"info": {
"title": "yet-another-nginx-proxy-manager",
"description": "",
"license": {
"name": ""
},
"version": "0.1.0"
},
"paths": {
"/api/health/info": {
"get": {
"tags": [
"Health"
],
"summary": "Health check endpoint",
"description": "Returns the health status, version, uptime, and any errors if unhealthy.",
"operationId": "get_health_info",
"responses": {
"200": {
"description": "Health information retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HealthInfo"
}
}
}
},
"404": {
"description": "Health information not found"
}
}
}
}
},
"components": {
"schemas": {
"HealthInfo": {
"type": "object",
"description": "System health information",
"required": [
"status",
"version",
"up_since"
],
"properties": {
"errors": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
},
"description": "List of error messages if unhealthy"
},
"status": {
"type": "string",
"description": "Health status: \"healthy\" or \"unhealthy\""
},
"up_since": {
"type": "string",
"format": "date-time",
"description": "RFC 3339 formatted timestamp"
},
"version": {
"type": "string",
"description": "Application version"
}
}
}
}
},
"tags": [
{
"name": "Health",
"description": "Health information API"
}
]
}

View File

@@ -41,6 +41,11 @@ generate-entity:
# delegate to cli # delegate to cli
just cli db:migrate_and_generate --output-path ../../public/database/src/generated/entities just cli db:migrate_and_generate --output-path ../../public/database/src/generated/entities
generate-openapi:
# delegate to cli
cd apps/api && \
cargo run -- generate:openapi --output-path ./swagger.json
build-frontend: build-frontend:
# build frontend assets # build frontend assets
cd apps/frontend && \ cd apps/frontend && \