diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..d4a5d71 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,20 @@ +use crate::handlers::AppConfig; + +/// Load config from path +pub fn load_config(path: &str) -> anyhow::Result { + let raw = std::fs::read_to_string(path)?; + let cfg: AppConfig = toml::from_str(&raw)?; + + // field names must be unique and non-empty + let mut seen = std::collections::HashSet::new(); + for f in &cfg.fields { + if f.name.trim().is_empty() { + anyhow::bail!("field with empty name in config"); + } + if !seen.insert(f.name.clone()) { + anyhow::bail!("duplicate field name in config: {}", f.name); + } + } + + Ok(cfg) +} diff --git a/src/handlers.rs b/src/handlers.rs new file mode 100644 index 0000000..04a3b46 --- /dev/null +++ b/src/handlers.rs @@ -0,0 +1,109 @@ +use askama::Template; +use axum::{ + extract::{Form, State}, + http::StatusCode, + response::{Html, IntoResponse}, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::Mutex; + +#[derive(Debug, Deserialize)] +pub struct FieldDef { + pub name: String, + title: String, + description: String, + answer_type: String, + html_before: Option, + html_after: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct ResponseEntry { + timestamp: chrono::DateTime, + answers: HashMap, +} + +#[derive(Debug, Deserialize)] +pub struct AppConfig { + submit_button: String, + pub fields: Vec, +} + +#[derive(Clone)] +pub struct AppState { + pub cfg: Arc, + pub file_lock: Arc>, + pub output_file: String, +} + +pub async fn render_form(State(state): State) -> impl IntoResponse { + #[derive(Template)] + #[template(path = "form.html")] + struct FormTemplate<'a> { + fields: &'a [FieldDef], + lang: &'a str, + submit_button: &'a str, + } + + let tmpl = FormTemplate { + fields: &state.cfg.fields, + lang: "en", + submit_button: &state.cfg.submit_button, + }; + match tmpl.render() { + Ok(html) => Html(html).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Template render error").into_response(), + } +} + +pub async fn submit( + State(state): State, + Form(form): Form>, +) -> impl IntoResponse { + let entry = ResponseEntry { + timestamp: Utc::now(), + answers: form, + }; + + let _guard = state.file_lock.lock().await; + let path = &state.output_file; + + let mut existing: Vec = match std::fs::read_to_string(path) { + Ok(raw) => serde_json::from_str(&raw).unwrap_or(Vec::new()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(e) => { + tracing::error!("Failed to read {}: {}", path, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to read storage file", + ) + .into_response(); + } + }; + + existing.push(entry.clone()); + + if let Err(e) = std::fs::write(path, serde_json::to_string_pretty(&existing).unwrap()) { + tracing::error!("Failed to write {}: {}", path, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to write storage file", + ) + .into_response(); + } + + // tracing::info!("Entry submitted: {:?}", entry.answers); + + Html( + r#" + + +

Saved. Back

+ + + "#, + ) + .into_response() +} diff --git a/src/main.rs b/src/main.rs index 428b75a..f93b0ab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,22 +1,22 @@ -use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration}; +use std::{net::SocketAddr, sync::Arc, time::Duration}; use anyhow::Context; -use askama::Template; use axum::{ Router, - extract::{Form, State}, - http::{Method, StatusCode, header}, - response::{Html, IntoResponse}, + http::{Method, header}, routing::{get, post}, }; -use chrono::Utc; -use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder}; use tower_http::cors::{Any, CorsLayer}; use clap::Parser; +mod handlers; +use handlers::{AppState, render_form, submit}; +mod config; +use config::load_config; + #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] pub struct Cli { @@ -33,35 +33,6 @@ pub struct Cli { verbose: bool, } -#[derive(Debug, Deserialize)] -struct FieldDef { - name: String, - title: String, - description: String, - answer_type: String, - html_before: Option, - html_after: Option, -} - -#[derive(Debug, Deserialize)] -struct AppConfig { - submit_button: String, - fields: Vec, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct ResponseEntry { - timestamp: chrono::DateTime, - answers: HashMap, -} - -#[derive(Clone)] -struct AppState { - cfg: Arc, - file_lock: Arc>, - output_file: String, -} - #[tokio::main] async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -130,92 +101,3 @@ async fn main() -> anyhow::Result<()> { Ok(()) } - -async fn render_form(State(state): State) -> impl IntoResponse { - #[derive(Template)] - #[template(path = "form.html")] - struct FormTemplate<'a> { - fields: &'a [FieldDef], - lang: &'a str, - submit_button: &'a str, - } - - let tmpl = FormTemplate { - fields: &state.cfg.fields, - lang: "en", - submit_button: &state.cfg.submit_button, - }; - match tmpl.render() { - Ok(html) => Html(html).into_response(), - Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Template render error").into_response(), - } -} - -async fn submit( - State(state): State, - Form(form): Form>, -) -> impl IntoResponse { - let entry = ResponseEntry { - timestamp: Utc::now(), - answers: form, - }; - - let _guard = state.file_lock.lock().await; - let path = &state.output_file; - - let mut existing: Vec = match std::fs::read_to_string(path) { - Ok(raw) => serde_json::from_str(&raw).unwrap_or(Vec::new()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), - Err(e) => { - tracing::error!("Failed to read {}: {}", path, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to read storage file", - ) - .into_response(); - } - }; - - existing.push(entry.clone()); - - if let Err(e) = std::fs::write(path, serde_json::to_string_pretty(&existing).unwrap()) { - tracing::error!("Failed to write {}: {}", path, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to write storage file", - ) - .into_response(); - } - - // tracing::info!("Entry submitted: {:?}", entry.answers); - - Html( - r#" - - -

Saved. Back

- - - "#, - ) - .into_response() -} - -/// Load config from path -fn load_config(path: &str) -> anyhow::Result { - let raw = std::fs::read_to_string(path)?; - let cfg: AppConfig = toml::from_str(&raw)?; - - // field names must be unique and non-empty - let mut seen = std::collections::HashSet::new(); - for f in &cfg.fields { - if f.name.trim().is_empty() { - anyhow::bail!("field with empty name in config"); - } - if !seen.insert(f.name.clone()) { - anyhow::bail!("duplicate field name in config: {}", f.name); - } - } - - Ok(cfg) -}