base generator with askama templating, toml config and json writing

This commit is contained in:
2025-12-01 18:19:15 +01:00
commit bdfa359702
8 changed files with 1918 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+1574
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "polar-pigeons-form"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.7"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.43"
tracing-subscriber = "0.3.22"
tower-http = { version = "0.6.6", features = ["cors", "limit"] }
tower_governor = "0.8.0"
anyhow = "1.0.100"
toml = "0.9.8"
chrono = { version = "0.4.42", features = ["serde"] }
askama = "0.14.0"
+41
View File
@@ -0,0 +1,41 @@
json_output = "answers.json"
[[fields]]
name = "full_name"
description = "Your full name"
answer_type = "text"
[[fields]]
name = "age"
description = "Your age"
answer_type = "number"
[[fields]]
name = "email"
description = "Your email address"
answer_type = "email"
[[fields]]
name = "password"
description = "Set a password"
answer_type = "password"
[[fields]]
name = "website"
description = "Personal website (optional)"
answer_type = "url"
[[fields]]
name = "about_you"
description = "Tell us about yourself"
answer_type = "textarea"
[[fields]]
name = "contact_number"
description = "Phone number"
answer_type = "tel"
[[fields]]
name = "favorite_color"
description = "Favorite color (text)"
answer_type = "text"
+193
View File
@@ -0,0 +1,193 @@
use std::{collections::HashMap, 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},
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};
#[derive(Debug, Deserialize)]
struct FieldDef {
name: String,
description: String,
answer_type: String,
}
#[derive(Debug, Deserialize)]
struct AppConfig {
json_output: 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<()>>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let subscriber = tracing_subscriber::FmtSubscriber::new();
tracing::subscriber::set_global_default(subscriber).ok();
// load config
let cfg = load_config("config.toml").context("loading config.toml")?;
tracing::info!(
"Loaded config: json_output='{}' with {} fields",
cfg.json_output,
cfg.fields.len()
);
// CORS
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]);
// rate limiter
let governor_conf = GovernorConfigBuilder::default()
.per_second(3)
.burst_size(10)
.finish()
.unwrap();
// a separate background task to clean up
let governor_limiter = governor_conf.limiter().clone();
let interval = Duration::from_secs(60);
std::thread::spawn(move || {
loop {
std::thread::sleep(interval);
// tracing::info!("rate limiting storage size: {}", governor_limiter.len());
governor_limiter.retain_recent();
}
});
let state = AppState {
cfg: Arc::new(cfg),
file_lock: Arc::new(Mutex::new(())),
};
let app = Router::new()
.route("/", get(render_form))
.route("/submit", post(submit))
.with_state(state)
.layer(cors)
.layer(GovernorLayer::new(governor_conf));
let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "8081".to_string());
let addr = format!("127.0.0.1:{port}");
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
tracing::info!("Listening on {addr}");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
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,
}
let tmpl = FormTemplate {
fields: &state.cfg.fields,
lang: "en",
};
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.cfg.json_output;
let mut existing: Vec<ResponseEntry> = match std::fs::read_to_string(path) {
Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|_| 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)
}
+51
View File
@@ -0,0 +1,51 @@
body {
font-family: system-ui, -apple-system, Roboto, "Segoe UI", Arial;
padding: 2rem;
max-width: 800px;
margin: auto;
}
h1 {
color: #333;
}
label {
font-weight: 600;
display: block;
margin-bottom: 0.2rem;
}
.field {
margin-bottom: 1.5rem;
}
.desc {
color: #666;
font-size: 0.9rem;
margin-bottom: 0.4rem;
}
input,
textarea,
select {
width: 100%;
padding: 0.4rem;
margin-top: 0.2rem;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 0.6rem 1rem;
font-size: 1rem;
background-color: #007acc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #005fa3;
}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8"/>
<title>{% block title %}Dynamic Form{% endblock %}</title>
<style>
/*<![CDATA[*/
{%~ include "_layout.css" ~%}
/*]]>*/
</style>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{% extends "_layout.html" %}
{% block title %}Form{% endblock %}
{% block content %}
<h1>Dynamic Form</h1>
<form action="/submit" method="POST" autocomplete="off">
{% for f in fields %}
<div class="field">
<label for="{{ f.name }}">{{ f.description }}</label>
<div class="desc">{{ f.description }}</div>
{% if f.answer_type == "textarea" %}
<textarea id="{{ f.name }}" name="{{ f.name }}" rows="5"></textarea>
{% elif f.answer_type == "select" %}
<select id="{{ f.name }}" name="{{ f.name }}"><option value="">(no options)</option></select>
{% else %}
<input id="{{ f.name }}" name="{{ f.name }}" type="{{ f.answer_type }}">
{% endif %}
</div>
{% endfor %}
<div><button type="submit">Submit</button></div>
</form>
{% endblock %}