feat: initialization migration to Nuxt + Backend

This commit initializes both the Nuxt frontend and the Rust backend of
the new version of phundrak.com
This commit is contained in:
2025-11-04 09:17:18 +01:00
parent cc62d0bb95
commit 007c3d1c18
134 changed files with 17247 additions and 8335 deletions

64
backend/src/lib.rs Normal file
View File

@@ -0,0 +1,64 @@
#![deny(clippy::all)]
#![deny(clippy::pedantic)]
#![deny(clippy::nursery)]
#![allow(clippy::missing_panics_doc)]
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unused_async)]
pub mod route;
pub mod settings;
pub mod startup;
pub mod telemetry;
type MaybeListener = Option<poem::listener::TcpListener<String>>;
fn prepare(listener: MaybeListener) -> startup::Application {
dotenvy::dotenv().ok();
let settings = settings::Settings::new().expect("Failed to read settings");
if !cfg!(test) {
let subscriber = telemetry::get_subscriber(settings.debug);
telemetry::init_subscriber(subscriber);
}
tracing::event!(
target: "backend",
tracing::Level::DEBUG,
"Using these settings: {:?}",
settings
);
let application = startup::Application::build(settings, listener);
tracing::event!(
target: "backend",
tracing::Level::INFO,
"Listening on http://{}:{}/",
application.host(),
application.port()
);
tracing::event!(
target: "backend",
tracing::Level::INFO,
"Documentation available at http://{}:{}/docs",
application.host(),
application.port()
);
application
}
#[cfg(not(tarpaulin_include))]
pub async fn run(listener: MaybeListener) -> Result<(), std::io::Error> {
let application = prepare(listener);
application.make_app().run().await
}
#[cfg(test)]
fn make_random_tcp_listener() -> poem::listener::TcpListener<String> {
let tcp_listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind a random TCP listener");
let port = tcp_listener.local_addr().unwrap().port();
poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"))
}
#[cfg(test)]
fn get_test_app() -> startup::App {
let tcp_listener = make_random_tcp_listener();
prepare(Some(tcp_listener)).make_app().into()
}

5
backend/src/main.rs Normal file
View File

@@ -0,0 +1,5 @@
#[cfg(not(tarpaulin_include))]
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
phundrak_dot_com_backend::run(None).await
}

View File

@@ -0,0 +1,29 @@
use poem_openapi::{ApiResponse, OpenApi};
use super::ApiCategory;
#[derive(ApiResponse)]
enum HealthResponse {
#[oai(status = 200)]
Ok,
}
pub struct HealthApi;
#[OpenApi(prefix_path = "/v1/health-check", tag = "ApiCategory::Health")]
impl HealthApi {
#[oai(path = "/", method = "get")]
async fn ping(&self) -> HealthResponse {
tracing::event!(target: "backend", tracing::Level::DEBUG, "Accessing health-check endpoint");
HealthResponse::Ok
}
}
#[tokio::test]
async fn health_check_works() {
let app = crate::get_test_app();
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/v1/health-check").send().await;
resp.assert_status_is_ok();
resp.assert_text("").await;
}

86
backend/src/route/meta.rs Normal file
View File

@@ -0,0 +1,86 @@
use poem::Result;
use poem_openapi::{ApiResponse, Object, OpenApi, payload::Json};
use super::ApiCategory;
use crate::settings::Settings;
#[derive(Object, Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Meta {
version: String,
name: String,
}
impl From<poem::web::Data<&Settings>> for Meta {
fn from(value: poem::web::Data<&Settings>) -> Self {
let version = value.application.version.clone();
let name = value.application.name.clone();
Self { version, name }
}
}
#[derive(ApiResponse)]
enum MetaResponse {
#[oai(status = 200)]
Meta(Json<Meta>),
}
pub struct MetaApi;
#[OpenApi(prefix_path = "/v1/meta", tag = "ApiCategory::Meta")]
impl MetaApi {
#[oai(path = "/", method = "get")]
async fn meta(&self, settings: poem::web::Data<&Settings>) -> Result<MetaResponse> {
tracing::event!(target: "backend", tracing::Level::DEBUG, "Accessing meta endpoint");
Ok(MetaResponse::Meta(Json(settings.into())))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::ApplicationSettings;
#[tokio::test]
async fn meta_endpoint_returns_correct_data() {
let app = crate::get_test_app();
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/v1/meta").send().await;
resp.assert_status_is_ok();
// let json = resp.0.into_json().await;
// assert!(json.is_ok(), "Response should be valid JSON");
// let json_value: serde_json::Value = json.unwrap();
// assert!(json_value.get("version").is_some(), "Response should have version field");
// assert!(json_value.get("name").is_some(), "Response should have name field");
}
#[tokio::test]
async fn meta_endpoint_returns_200_status() {
let app = crate::get_test_app();
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/v1/meta").send().await;
resp.assert_status_is_ok();
}
#[test]
fn meta_from_settings_conversion() {
let settings = Settings {
application: ApplicationSettings {
name: "test-app".to_string(),
version: "1.0.0".to_string(),
port: 8080,
host: "127.0.0.1".to_string(),
base_url: "http://localhost:8080".to_string(),
protocol: "http".to_string(),
},
debug: false,
email: crate::settings::EmailSettings::default(),
frontend_url: "http://localhost:3000".to_string(),
};
let meta: Meta = poem::web::Data(&settings).into();
assert_eq!(meta.name, "test-app");
assert_eq!(meta.version, "1.0.0");
}
}

18
backend/src/route/mod.rs Normal file
View File

@@ -0,0 +1,18 @@
use poem_openapi::{OpenApi, Tags};
mod health;
pub use health::HealthApi;
mod meta;
pub use meta::MetaApi;
#[derive(Tags)]
enum ApiCategory {
Health,
Meta
}
pub(crate) struct Api;
#[OpenApi]
impl Api {}

157
backend/src/settings.rs Normal file
View File

@@ -0,0 +1,157 @@
#[derive(Debug, serde::Deserialize, Clone, Default)]
pub struct Settings {
pub application: ApplicationSettings,
pub debug: bool,
pub email: EmailSettings,
pub frontend_url: String,
}
impl Settings {
pub fn new() -> Result<Self, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
let settings_directory = base_path.join("settings");
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "dev".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT");
let environment_filename = format!("{environment}.yaml");
// Lower = takes precedence
let settings = config::Config::builder()
.add_source(config::File::from(settings_directory.join("base.yaml")))
.add_source(config::File::from(
settings_directory.join(environment_filename),
))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("__")
.separator("__"),
)
.build()?;
settings.try_deserialize()
}
}
#[derive(Debug, serde::Deserialize, Clone, Default)]
pub struct ApplicationSettings {
pub name: String,
pub version: String,
pub port: u16,
pub host: String,
pub base_url: String,
pub protocol: String,
}
#[derive(Debug, PartialEq, Eq, Default)]
pub enum Environment {
#[default]
Development,
Production,
}
impl std::fmt::Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let self_str = match self {
Self::Development => "development",
Self::Production => "production",
};
write!(f, "{self_str}")
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl TryFrom<&str> for Environment {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() {
"development" | "dev" => Ok(Self::Development),
"production" | "prod" => Ok(Self::Production),
other => Err(format!(
"{other} is not a supported environment. Use either `development` or `production`"
)),
}
}
}
#[derive(Debug, serde::Deserialize, Clone, Default)]
pub struct EmailSettings {
pub host: String,
pub user: String,
pub password: String,
pub from: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn environment_display_development() {
let env = Environment::Development;
assert_eq!(env.to_string(), "development");
}
#[test]
fn environment_display_production() {
let env = Environment::Production;
assert_eq!(env.to_string(), "production");
}
#[test]
fn environment_from_str_development() {
assert_eq!(Environment::try_from("development").unwrap(), Environment::Development);
assert_eq!(Environment::try_from("dev").unwrap(), Environment::Development);
assert_eq!(Environment::try_from("Development").unwrap(), Environment::Development);
assert_eq!(Environment::try_from("DEV").unwrap(), Environment::Development);
}
#[test]
fn environment_from_str_production() {
assert_eq!(Environment::try_from("production").unwrap(), Environment::Production);
assert_eq!(Environment::try_from("prod").unwrap(), Environment::Production);
assert_eq!(Environment::try_from("Production").unwrap(), Environment::Production);
assert_eq!(Environment::try_from("PROD").unwrap(), Environment::Production);
}
#[test]
fn environment_from_str_invalid() {
let result = Environment::try_from("invalid");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not a supported environment"));
}
#[test]
fn environment_from_string_development() {
assert_eq!(
Environment::try_from("development".to_string()).unwrap(),
Environment::Development
);
}
#[test]
fn environment_from_string_production() {
assert_eq!(
Environment::try_from("production".to_string()).unwrap(),
Environment::Production
);
}
#[test]
fn environment_from_string_invalid() {
let result = Environment::try_from("invalid".to_string());
assert!(result.is_err());
}
#[test]
fn environment_default_is_development() {
let env = Environment::default();
assert_eq!(env, Environment::Development);
}
}

162
backend/src/startup.rs Normal file
View File

@@ -0,0 +1,162 @@
use poem::middleware::{AddDataEndpoint, Cors, CorsEndpoint};
use poem::{EndpointExt, Route};
use poem_openapi::OpenApiService;
use crate::{settings::Settings, route::{Api, HealthApi, MetaApi}};
type Server = poem::Server<poem::listener::TcpListener<String>, std::convert::Infallible>;
pub type App = AddDataEndpoint<CorsEndpoint<Route>, Settings>;
pub struct Application {
server: Server,
app: poem::Route,
host: String,
port: u16,
settings: Settings,
}
pub struct RunnableApplication {
server: Server,
app: App,
}
impl RunnableApplication {
pub async fn run(self) -> Result<(), std::io::Error> {
self.server.run(self.app).await
}
}
impl From<RunnableApplication> for App {
fn from(value: RunnableApplication) -> Self {
value.app
}
}
impl From<Application> for RunnableApplication {
fn from(value: Application) -> Self {
let app = value.app.with(Cors::new()).data(value.settings);
let server = value.server;
Self { server, app }
}
}
impl Application {
fn setup_app(settings: &Settings) -> poem::Route {
let api_service = OpenApiService::new(
(Api, HealthApi, MetaApi),
settings.application.clone().name,
settings.application.clone().version,
);
let ui = api_service.swagger_ui();
poem::Route::new().nest("/", api_service).nest("/docs", ui)
}
fn setup_server(
settings: &Settings,
tcp_listener: Option<poem::listener::TcpListener<String>>,
) -> Server {
let tcp_listener = tcp_listener.unwrap_or_else(|| {
let address = format!(
"{}:{}",
settings.application.host, settings.application.port
);
poem::listener::TcpListener::bind(address)
});
poem::Server::new(tcp_listener)
}
#[must_use]
pub fn build(
settings: Settings,
tcp_listener: Option<poem::listener::TcpListener<String>>,
) -> Self {
let port = settings.application.port;
let host = settings.application.clone().host;
let app = Self::setup_app(&settings);
let server = Self::setup_server(&settings, tcp_listener);
Self {
server,
app,
host,
port,
settings,
}
}
#[must_use]
pub fn make_app(self) -> RunnableApplication {
self.into()
}
#[must_use]
pub fn host(&self) -> String {
self.host.clone()
}
#[must_use]
pub const fn port(&self) -> u16 {
self.port
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_settings() -> Settings {
Settings {
application: crate::settings::ApplicationSettings {
name: "test-app".to_string(),
version: "1.0.0".to_string(),
port: 8080,
host: "127.0.0.1".to_string(),
base_url: "http://localhost:8080".to_string(),
protocol: "http".to_string(),
},
debug: false,
email: crate::settings::EmailSettings::default(),
frontend_url: "http://localhost:3000".to_string(),
}
}
#[test]
fn application_build_and_host() {
let settings = create_test_settings();
let app = Application::build(settings.clone(), None);
assert_eq!(app.host(), settings.application.host);
}
#[test]
fn application_build_and_port() {
let settings = create_test_settings();
let app = Application::build(settings, None);
assert_eq!(app.port(), 8080);
}
#[test]
fn application_host_returns_correct_value() {
let settings = create_test_settings();
let app = Application::build(settings, None);
assert_eq!(app.host(), "127.0.0.1");
}
#[test]
fn application_port_returns_correct_value() {
let settings = create_test_settings();
let app = Application::build(settings, None);
assert_eq!(app.port(), 8080);
}
#[test]
fn application_with_custom_listener() {
let settings = create_test_settings();
let tcp_listener = std::net::TcpListener::bind("127.0.0.1:0")
.expect("Failed to bind random port");
let port = tcp_listener.local_addr().unwrap().port();
let listener = poem::listener::TcpListener::bind(format!("127.0.0.1:{port}"));
let app = Application::build(settings, Some(listener));
assert_eq!(app.host(), "127.0.0.1");
assert_eq!(app.port(), 8080);
}
}

53
backend/src/telemetry.rs Normal file
View File

@@ -0,0 +1,53 @@
use tracing_subscriber::layer::SubscriberExt;
#[must_use]
pub fn get_subscriber(debug: bool) -> impl tracing::Subscriber + Send + Sync {
let env_filter = if debug { "debug" } else { "info" }.to_string();
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(env_filter));
let stdout_log = tracing_subscriber::fmt::layer().pretty();
let subscriber = tracing_subscriber::Registry::default()
.with(env_filter)
.with(stdout_log);
let json_log = if debug {
None
} else {
Some(tracing_subscriber::fmt::layer().json())
};
subscriber.with(json_log)
}
pub fn init_subscriber(subscriber: impl tracing::Subscriber + Send + Sync) {
tracing::subscriber::set_global_default(subscriber).expect("Failed to set subscriber");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_subscriber_debug_mode() {
let subscriber = get_subscriber(true);
// If we can create the subscriber without panicking, the test passes
// We can't easily inspect the subscriber's internals, but we can verify it's created
let _ = subscriber;
}
#[test]
fn get_subscriber_production_mode() {
let subscriber = get_subscriber(false);
// If we can create the subscriber without panicking, the test passes
let _ = subscriber;
}
#[test]
fn get_subscriber_creates_valid_subscriber() {
// Test both debug and non-debug modes create valid subscribers
let debug_subscriber = get_subscriber(true);
let prod_subscriber = get_subscriber(false);
// Basic smoke test - if these are created without panicking, they're valid
let _ = debug_subscriber;
let _ = prod_subscriber;
}
}