modularized

This commit is contained in:
2025-12-03 16:33:40 +01:00
parent 98a7df2931
commit 4e596d0699
3 changed files with 136 additions and 125 deletions
+20
View File
@@ -0,0 +1,20 @@
use crate::handlers::AppConfig;
/// Load config from path
pub fn load_config(path: &str) -> anyhow::Result<AppConfig> {
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)
}
+109
View File
@@ -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<String>,
html_after: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct ResponseEntry {
timestamp: chrono::DateTime<chrono::Utc>,
answers: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
pub struct AppConfig {
submit_button: String,
pub fields: Vec<FieldDef>,
}
#[derive(Clone)]
pub struct AppState {
pub cfg: Arc<AppConfig>,
pub file_lock: Arc<Mutex<()>>,
pub output_file: String,
}
pub async fn render_form(State(state): State<AppState>) -> 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<AppState>,
Form(form): Form<HashMap<String, String>>,
) -> 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<ResponseEntry> = 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#"
<html>
<body>
<p>Saved. <a href="/">Back</a></p>
</body>
</html>
"#,
)
.into_response()
}
+7 -125
View File
@@ -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 anyhow::Context;
use askama::Template;
use axum::{ use axum::{
Router, Router,
extract::{Form, State}, http::{Method, header},
http::{Method, StatusCode, header},
response::{Html, IntoResponse},
routing::{get, post}, routing::{get, post},
}; };
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder}; use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder};
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
use clap::Parser; use clap::Parser;
mod handlers;
use handlers::{AppState, render_form, submit};
mod config;
use config::load_config;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
pub struct Cli { pub struct Cli {
@@ -33,35 +33,6 @@ pub struct Cli {
verbose: bool, verbose: bool,
} }
#[derive(Debug, Deserialize)]
struct FieldDef {
name: String,
title: String,
description: String,
answer_type: String,
html_before: Option<String>,
html_after: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AppConfig {
submit_button: String,
fields: Vec<FieldDef>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct ResponseEntry {
timestamp: chrono::DateTime<chrono::Utc>,
answers: HashMap<String, String>,
}
#[derive(Clone)]
struct AppState {
cfg: Arc<AppConfig>,
file_lock: Arc<Mutex<()>>,
output_file: String,
}
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
@@ -130,92 +101,3 @@ async fn main() -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn render_form(State(state): State<AppState>) -> 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<AppState>,
Form(form): Form<HashMap<String, String>>,
) -> 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<ResponseEntry> = 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#"
<html>
<body>
<p>Saved. <a href="/">Back</a></p>
</body>
</html>
"#,
)
.into_response()
}
/// Load config from path
fn load_config(path: &str) -> anyhow::Result<AppConfig> {
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)
}