From b63931813579c4104b040b874418550ef584cdee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Thu, 23 Jul 2026 22:15:39 +0200 Subject: [PATCH] feat: add auto-generated config TOML reference Fixes #479 --- Cargo.lock | 3 + cot-test/Cargo.toml | 16 + cot-test/src/bin/generate_config_docs.rs | 20 + cot-test/src/config_reference.rs | 463 +++++++++++++++++++++++ cot-test/src/lib.rs | 3 + cot-test/tests/config_reference.rs | 25 ++ cot/Cargo.toml | 2 + cot/src/config.rs | 35 +- cot/src/email/transport/smtp.rs | 1 + docs/configuration.md | 222 +++++++++++ docs/site/src/main.rs | 1 + justfile | 4 + 12 files changed, 793 insertions(+), 2 deletions(-) create mode 100644 cot-test/src/bin/generate_config_docs.rs create mode 100644 cot-test/src/config_reference.rs create mode 100644 cot-test/tests/config_reference.rs create mode 100644 docs/configuration.md diff --git a/Cargo.lock b/Cargo.lock index 9134f0604..966a4ccb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1015,6 +1015,8 @@ dependencies = [ "cot-cli", "glob", "libtest-mimic", + "schemars", + "serde_json", "thiserror 2.0.18", ] @@ -3749,6 +3751,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap", "itoa", "memchr", "serde", diff --git a/cot-test/Cargo.toml b/cot-test/Cargo.toml index e8ceebcbf..67a03fef2 100644 --- a/cot-test/Cargo.toml +++ b/cot-test/Cargo.toml @@ -18,8 +18,14 @@ cot-cli = { workspace = true, features = ["test_utils"] } cot.workspace = true glob.workspace = true libtest-mimic.workspace = true +schemars = { workspace = true, features = ["std"] } +serde_json.workspace = true thiserror.workspace = true +[features] +# Enables generating the configuration file reference (docs/configuration.md). +config-docs = ["cot/config-docs"] + [lints] workspace = true @@ -27,3 +33,13 @@ workspace = true name = "doc_code_blocks" path = "tests/doc_code_blocks.rs" harness = false + +[[test]] +name = "config_reference" +path = "tests/config_reference.rs" +required-features = ["config-docs"] + +[[bin]] +name = "generate_config_docs" +path = "src/bin/generate_config_docs.rs" +required-features = ["config-docs"] diff --git a/cot-test/src/bin/generate_config_docs.rs b/cot-test/src/bin/generate_config_docs.rs new file mode 100644 index 000000000..66b120006 --- /dev/null +++ b/cot-test/src/bin/generate_config_docs.rs @@ -0,0 +1,20 @@ +//! Regenerates `docs/configuration.md` from `cot::config::ProjectConfig`'s type +//! definition. +//! +//! Run via `just generate-config-docs`. + +use std::path::PathBuf; + +fn main() { + let content = cot_test::config_reference::generate_config_reference(); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let docs_path = manifest_dir + .parent() + .expect("failed to get workspace path") + .join("docs") + .join("configuration.md"); + + std::fs::write(&docs_path, content).expect("failed to write docs/configuration.md"); + println!("Wrote {}", docs_path.display()); +} diff --git a/cot-test/src/config_reference.rs b/cot-test/src/config_reference.rs new file mode 100644 index 000000000..2bfcf3532 --- /dev/null +++ b/cot-test/src/config_reference.rs @@ -0,0 +1,463 @@ +//! Generates the configuration file reference (`docs/configuration.md`) from +//! `cot::config::ProjectConfig`'s JSON schema (via `schemars`), so the +//! reference can never drift from the actual set of tables/keys the type +//! accepts: every field is picked up automatically because the schema is +//! derived directly from the struct/enum definitions, not hand-copied. + +use std::fmt::Write as _; + +use serde_json::{Map, Value}; + +/// Generates the Markdown configuration reference. +/// +/// # Panics +/// +/// Panics if `cot::config::ProjectConfig`'s JSON schema doesn't have the shape +/// this generator expects (e.g. missing `properties`) - this would indicate a +/// schemars version change or a fundamentally different config type shape that +/// the generator needs to be updated for. +#[must_use] +pub fn generate_config_reference() -> String { + let schema = schemars::schema_for!(cot::config::ProjectConfig); + let root = schema + .as_value() + .as_object() + .expect("root schema must be a JSON object"); + let defs: Map = root + .get("$defs") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let properties = root + .get("properties") + .and_then(Value::as_object) + .expect("ProjectConfig schema must declare properties"); + + let mut md = String::new(); + md.push_str("---\ntitle: Configuration\n---\n\n"); + md.push_str( + "\n\n", + ); + if let Some(desc) = root.get("description").and_then(Value::as_str) { + md.push_str(&first_paragraph(desc)); + md.push_str("\n\n"); + } + md.push_str( + "Cot projects are configured via a TOML file (typically `config/dev.toml` and \ + `config/prod.toml`, loaded with\n\ + [`ProjectConfig::from_toml`](https://docs.rs/cot/latest/cot/config/struct.ProjectConfig.html#method.from_toml)).\n\ + This page lists every table and key that `ProjectConfig` understands.\n\n", + ); + md.push_str( + "Any top-level table not listed below is preserved as-is and made available to your \ + application through `ProjectConfig::extra`, for app-specific configuration.\n\n", + ); + + md.push_str("## Top-level keys\n\n"); + let mut default_toml = String::new(); + render_fields(&[], 1, properties, &defs, &mut md, &mut default_toml); + + md.push_str("## Full default configuration\n\n"); + md.push_str( + "This is a complete example with every key set explicitly to its default value \ + (fields without a well-defined default, like `secret_key`, are omitted):\n\n", + ); + md.push_str("```toml\n"); + md.push_str(default_toml.trim()); + md.push('\n'); + md.push_str("```\n"); + + md +} + +enum PendingChild<'a> { + Table(&'a Map), + Tagged(&'a Vec, Option<&'a Value>), +} + +/// Renders a field table (`| Key | Type | Default | Description |`) for the +/// given `properties` into `md`, followed by a section (and, recursively, its +/// own field table) for every property that turns out to be a nested table. +/// Each table's own scalar defaults are written to `default_toml` under a +/// `[path]` header - but only if it actually has any, so a table that only +/// contains further nested tables (e.g. `middlewares`) doesn't get an empty, +/// redundant header. +fn render_fields( + path: &[String], + level: usize, + properties: &Map, + defs: &Map, + md: &mut String, + default_toml: &mut String, +) { + md.push_str("| Key | Type | Default | Description |\n|---|---|---|---|\n"); + let mut own_toml = String::new(); + let mut pending: Vec<(String, PendingChild<'_>)> = Vec::new(); + + for (key, prop_value) in properties { + let prop = prop_value + .as_object() + .expect("property schema must be an object"); + let description = escape_table_cell(&first_paragraph( + prop.get("description") + .and_then(Value::as_str) + .unwrap_or(""), + )); + let default_val = prop.get("default"); + let resolved = deref(prop_value, defs); + + match classify(resolved) { + Kind::Table(props) => { + let _ = writeln!(md, "| `{key}` | table | *(see below)* | {description} |"); + pending.push((key.clone(), PendingChild::Table(props))); + } + Kind::TaggedTable(variants) => { + let _ = writeln!(md, "| `{key}` | table | *(see below)* | {description} |"); + pending.push((key.clone(), PendingChild::Tagged(variants, default_val))); + } + Kind::LeafEnum(variants) => { + let ty = leaf_enum_type_name(variants); + let _ = writeln!( + md, + "| `{key}` | {ty} | {} | {description} |", + default_cell(default_val, ScalarKind::String) + ); + if let Some(line) = default_line(key, default_val, ScalarKind::String) { + own_toml.push_str(&line); + } + } + Kind::Scalar(kind) => { + let ty = scalar_type_name(kind); + let _ = writeln!( + md, + "| `{key}` | {ty} | {} | {description} |", + default_cell(default_val, kind) + ); + if let Some(line) = default_line(key, default_val, kind) { + own_toml.push_str(&line); + } + } + } + } + md.push('\n'); + + if !own_toml.is_empty() { + if !path.is_empty() { + let _ = writeln!(default_toml, "\n[{}]", path.join(".")); + } + default_toml.push_str(&own_toml); + } + + for (key, child) in pending { + let child_path = extend(path, &key); + match child { + PendingChild::Table(props) => { + render_object(&child_path, level + 1, props, defs, md, default_toml); + } + PendingChild::Tagged(variants, default_val) => { + render_tagged( + &child_path, + level + 1, + variants, + default_val, + defs, + md, + default_toml, + ); + } + } + } +} + +/// Renders a `## [path]` (or deeper) section for a plain nested table. +fn render_object( + path: &[String], + level: usize, + properties: &Map, + defs: &Map, + md: &mut String, + default_toml: &mut String, +) { + let _ = writeln!(md, "{} `[{}]`\n", heading_hashes(level), path.join(".")); + render_fields(path, level, properties, defs, md, default_toml); +} + +/// Renders a `## [path]` section for an internally-tagged enum (selected via a +/// `type` key), with one sub-section per variant. +fn render_tagged( + path: &[String], + level: usize, + variants: &[Value], + default_val: Option<&Value>, + defs: &Map, + md: &mut String, + default_toml: &mut String, +) { + let _ = writeln!(md, "{} `[{}]`\n", heading_hashes(level), path.join(".")); + md.push_str("Select the variant with the `type` key:\n\n"); + + for variant in variants { + let variant = variant + .as_object() + .expect("tagged enum variant schema must be an object"); + let type_const = variant + .get("properties") + .and_then(|p| p.get("type")) + .and_then(|t| t.get("const")) + .and_then(Value::as_str) + .expect("tagged enum variant must declare a `type` const"); + let _ = writeln!( + md, + "{} `type = \"{type_const}\"`\n", + heading_hashes(level + 1) + ); + if let Some(desc) = variant.get("description").and_then(Value::as_str) { + md.push_str(&first_paragraph(desc)); + md.push_str("\n\n"); + } + + let other_props: Vec<(&String, &Value)> = variant + .get("properties") + .and_then(Value::as_object) + .into_iter() + .flatten() + .filter(|(k, _)| k.as_str() != "type") + .collect(); + if other_props.is_empty() { + md.push_str("No additional keys.\n\n"); + } else { + md.push_str("| Key | Type | Default | Description |\n|---|---|---|---|\n"); + for (key, prop_value) in other_props { + let prop = prop_value + .as_object() + .expect("property schema must be an object"); + let description = escape_table_cell(&first_paragraph( + prop.get("description") + .and_then(Value::as_str) + .unwrap_or(""), + )); + let default_val = prop.get("default"); + let resolved = deref(prop_value, defs); + match classify(resolved) { + Kind::Scalar(kind) => { + let ty = scalar_type_name(kind); + let _ = writeln!( + md, + "| `{key}` | {ty} | {} | {description} |", + default_cell(default_val, kind) + ); + } + Kind::LeafEnum(variants) => { + let ty = leaf_enum_type_name(variants); + let _ = writeln!( + md, + "| `{key}` | {ty} | {} | {description} |", + default_cell(default_val, ScalarKind::String) + ); + } + // Not exercised by the current config tree (no tagged-enum variant has a + // nested-table field), but handled so the generator degrades gracefully + // rather than panicking if one is ever added. + Kind::Table(_) | Kind::TaggedTable(_) => { + let _ = writeln!( + md, + "| `{key}` | table | *(see type documentation)* | {description} |" + ); + } + } + } + md.push('\n'); + } + } + + let mut own_toml = String::new(); + if let Some(Value::Object(default_obj)) = default_val { + for (key, value) in default_obj { + if let Some(line) = json_scalar_to_toml_line(key, value) { + own_toml.push_str(&line); + } + } + } + if !own_toml.is_empty() { + let _ = writeln!(default_toml, "\n[{}]", path.join(".")); + default_toml.push_str(&own_toml); + } +} + +enum Kind<'a> { + Table(&'a Map), + TaggedTable(&'a Vec), + LeafEnum(&'a Vec), + Scalar(ScalarKind), +} + +#[derive(Clone, Copy)] +enum ScalarKind { + Bool, + Integer, + String, + Array, +} + +/// Classifies an already-dereferenced schema node. +fn classify(resolved: &Value) -> Kind<'_> { + let Some(obj) = resolved.as_object() else { + return Kind::Scalar(ScalarKind::String); + }; + if let Some(Value::Array(one_of)) = obj.get("oneOf") { + let tagged = !one_of.is_empty() && one_of.iter().all(|v| v.get("properties").is_some()); + return if tagged { + Kind::TaggedTable(one_of) + } else { + Kind::LeafEnum(one_of) + }; + } + if let Some(Value::Object(props)) = obj.get("properties") { + return Kind::Table(props); + } + let ty_str = match obj.get("type") { + Some(Value::String(s)) => s.as_str(), + Some(Value::Array(arr)) => arr + .iter() + .filter_map(Value::as_str) + .find(|s| *s != "null") + .unwrap_or("string"), + _ => "string", + }; + Kind::Scalar(match ty_str { + "boolean" => ScalarKind::Bool, + "integer" | "number" => ScalarKind::Integer, + "array" => ScalarKind::Array, + _ => ScalarKind::String, + }) +} + +/// Follows a `$ref` (one level - this schema never nests them further) or picks +/// the non-null branch of an `anyOf` (produced by `Option` fields), +/// returning the schema node that actually describes the field's shape. +fn deref<'a>(prop: &'a Value, defs: &'a Map) -> &'a Value { + let Some(obj) = prop.as_object() else { + return prop; + }; + if let Some(Value::String(r)) = obj.get("$ref") { + let name = r.rsplit('/').next().unwrap_or(r); + if let Some(target) = defs.get(name) { + return target; + } + } + if let Some(Value::Array(any_of)) = obj.get("anyOf") { + for branch in any_of { + if branch.get("type").and_then(Value::as_str) != Some("null") { + return deref(branch, defs); + } + } + } + prop +} + +fn scalar_type_name(kind: ScalarKind) -> &'static str { + match kind { + ScalarKind::Bool => "boolean", + ScalarKind::Integer => "integer", + ScalarKind::String => "string", + ScalarKind::Array => "array of strings", + } +} + +fn leaf_enum_type_name(variants: &[Value]) -> String { + let values: Vec = variants + .iter() + .filter_map(|v| v.get("const").and_then(Value::as_str)) + .map(|s| format!("`\"{s}\"`")) + .collect(); + format!("string (one of: {})", values.join(", ")) +} + +fn default_cell(default_val: Option<&Value>, kind: ScalarKind) -> String { + match json_scalar_repr(default_val, kind) { + Some(s) => format!("`{s}`"), + None => "—".to_string(), + } +} + +fn default_line(key: &str, default_val: Option<&Value>, kind: ScalarKind) -> Option { + json_scalar_repr(default_val, kind).map(|v| format!("{key} = {v}\n")) +} + +/// Renders a schema `default` value as a TOML-literal string, but only when it +/// actually matches the field's declared scalar type. A couple of fields +/// (`secret_key`, `fallback_secret_keys`) wrap [`cot::config::SecretKey`], +/// whose real `Serialize` impl emits a byte array rather than the string this +/// schema declares (see the `schemars(with = "String")` override on that type), +/// so schemars' computed default for them doesn't match the declared type. +/// Rather than print a misleading example, such mismatches are treated as "no +/// representable default" and omitted. +fn json_scalar_repr(default_val: Option<&Value>, kind: ScalarKind) -> Option { + match (kind, default_val?) { + (ScalarKind::Bool, Value::Bool(b)) => Some(b.to_string()), + (ScalarKind::Integer, Value::Number(n)) => Some(n.to_string()), + (ScalarKind::String, Value::String(s)) => Some(toml_quote(s)), + (ScalarKind::Array, Value::Array(items)) => { + let mut rendered = Vec::with_capacity(items.len()); + for item in items { + match item { + Value::String(s) => rendered.push(toml_quote(s)), + _ => return None, + } + } + Some(format!("[{}]", rendered.join(", "))) + } + _ => None, + } +} + +fn json_scalar_to_toml_line(key: &str, value: &Value) -> Option { + match value { + Value::Bool(b) => Some(format!("{key} = {b}\n")), + Value::Number(n) => Some(format!("{key} = {n}\n")), + Value::String(s) => Some(format!("{key} = {}\n", toml_quote(s))), + _ => None, + } +} + +fn toml_quote(s: &str) -> String { + format!("{s:?}") +} + +fn heading_hashes(level: usize) -> String { + "#".repeat(level.clamp(2, 6)) +} + +fn extend(path: &[String], key: &str) -> Vec { + let mut v = path.to_vec(); + v.push(key.to_string()); + v +} + +fn escape_table_cell(s: &str) -> String { + s.replace('|', "\\|") +} + +/// Extracts the first paragraph of a rustdoc description, stopping at the first +/// blank line, heading (`#`), or code fence (some doc comments in `config.rs` +/// are missing the blank line before `# Examples`, so a heading/fence also ends +/// the paragraph). Multiple lines within the paragraph are joined with spaces +/// so they read as flowing prose in a table cell. +fn first_paragraph(desc: &str) -> String { + let mut lines = Vec::new(); + for line in desc.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') || trimmed.starts_with("```") { + break; + } + if trimmed.is_empty() { + if lines.is_empty() { + continue; + } + break; + } + lines.push(trimmed); + } + lines.join(" ") +} diff --git a/cot-test/src/lib.rs b/cot-test/src/lib.rs index 7224b2405..d09783aa3 100644 --- a/cot-test/src/lib.rs +++ b/cot-test/src/lib.rs @@ -6,6 +6,9 @@ use cot_cli::new_project::{CotSource, new_project}; use libtest_mimic::Failed; use thiserror::Error; +#[cfg(feature = "config-docs")] +pub mod config_reference; + pub const COMMON_IMPORTS: &[&str] = &[ "cot::db::*", "cot::request::extractors::*", diff --git a/cot-test/tests/config_reference.rs b/cot-test/tests/config_reference.rs new file mode 100644 index 000000000..5eb821843 --- /dev/null +++ b/cot-test/tests/config_reference.rs @@ -0,0 +1,25 @@ +//! Verifies that `docs/configuration.md` is up to date with +//! `cot::config::ProjectConfig`'s current type definition. If this fails, run +//! `just generate-config-docs` and commit the result. + +use std::path::PathBuf; + +#[test] +fn config_reference_is_up_to_date() { + let generated = cot_test::config_reference::generate_config_reference(); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let docs_path = manifest_dir + .parent() + .expect("failed to get workspace path") + .join("docs") + .join("configuration.md"); + let committed = std::fs::read_to_string(&docs_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", docs_path.display())); + + assert_eq!( + generated, committed, + "docs/configuration.md is out of date with cot::config::ProjectConfig; \ + run `just generate-config-docs` and commit the result" + ); +} diff --git a/cot/Cargo.toml b/cot/Cargo.toml index 8f50d1584..fedd0e8c0 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -117,6 +117,8 @@ swagger-ui = ["openapi", "dep:swagger-ui-redist"] live-reload = ["dep:tower-livereload"] cache = ["json"] test = [] +# Internal feature used to generate the configuration file reference (docs/configuration.md). +config-docs = ["dep:schemars", "schemars/std", "schemars/preserve_order", "full"] [lib] bench = false diff --git a/cot/src/config.rs b/cot/src/config.rs index 24260d124..cb75a8320 100644 --- a/cot/src/config.rs +++ b/cot/src/config.rs @@ -35,6 +35,7 @@ use crate::utils::chrono::DateTimeWithOffsetAdapter; /// This is all the project-specific configuration data that can (and makes /// sense to) be expressed in a TOML configuration file. #[derive(Debug, Clone, Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] #[non_exhaustive] @@ -304,6 +305,7 @@ pub struct ProjectConfig { /// # Ok::<(), Box>(()) /// ``` #[serde(flatten)] + #[cfg_attr(feature = "config-docs", schemars(skip))] pub extra: toml::Table, } @@ -430,6 +432,7 @@ impl ProjectConfigBuilder { /// let config = AuthBackendConfig::Database; /// ``` #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[serde(tag = "type", rename_all = "snake_case")] #[non_exhaustive] pub enum AuthBackendConfig { @@ -461,6 +464,7 @@ pub enum AuthBackendConfig { /// ``` #[cfg(feature = "db")] #[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] #[non_exhaustive] @@ -605,6 +609,7 @@ const MAX_RETRIES_DEFAULT: u32 = 3; #[cfg(feature = "cache")] #[derive(Debug, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] #[non_exhaustive] @@ -674,6 +679,7 @@ pub struct CacheConfig { /// timeout = "2h" /// ``` #[serde(with = "crate::serializers::cache_timeout")] + #[cfg_attr(feature = "config-docs", schemars(with = "Option"))] pub timeout: Timeout, /// Prefix for cache keys. @@ -792,6 +798,7 @@ impl CacheConfig { #[cfg(feature = "cache")] #[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] /// Configuration for the cache store backend. @@ -903,6 +910,7 @@ const fn is_default_redis_pool_size(size: &usize) -> bool { /// assert_eq!(mem, CacheStoreTypeConfig::Memory); /// ``` #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[serde(tag = "type", rename_all = "snake_case")] #[non_exhaustive] #[cfg(feature = "cache")] @@ -927,6 +935,8 @@ pub enum CacheStoreTypeConfig { /// must be specified, and additional Redis-specific options can be /// configured. Redis { + /// The URL of the Redis server. + /// /// # Examples /// /// ``` @@ -937,7 +947,6 @@ pub enum CacheStoreTypeConfig { /// pool_size: 20, /// }; /// ``` - /// The URL of the Redis server. url: CacheUrl, /// Connection pool size for Redis connections. @@ -954,6 +963,8 @@ pub enum CacheStoreTypeConfig { /// This stores cache data in files on the local filesystem. The path to /// the directory where the cache files will be stored must be specified. File { + /// The path to the directory where cache files will be stored. + /// /// # Examples /// /// ``` @@ -965,7 +976,6 @@ pub enum CacheStoreTypeConfig { /// path: PathBuf::from("/tmp/cache"), /// }; /// ``` - /// The path to the directory where cache files will be stored. path: PathBuf, }, } @@ -1013,6 +1023,7 @@ pub enum CacheStoreTypeConfig { /// .build(); /// ``` #[derive(Debug, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] #[non_exhaustive] @@ -1104,6 +1115,7 @@ pub struct StaticFilesConfig { /// # Ok::<(), cot::Error>(()) /// ``` #[serde(with = "crate::serializers::humantime")] + #[cfg_attr(feature = "config-docs", schemars(with = "Option"))] #[builder(setter(strip_option), default)] pub cache_timeout: Option, } @@ -1112,6 +1124,7 @@ pub struct StaticFilesConfig { /// /// This is used as part of the [`StaticFilesConfig`] struct. #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum StaticFilesPathRewriteMode { @@ -1193,6 +1206,7 @@ impl StaticFilesConfig { /// .build(); /// ``` #[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] #[non_exhaustive] @@ -1254,6 +1268,7 @@ impl MiddlewareConfigBuilder { /// let config = LiveReloadMiddlewareConfig::builder().enabled(true).build(); /// ``` #[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] #[non_exhaustive] @@ -1333,6 +1348,7 @@ impl LiveReloadMiddlewareConfigBuilder { /// }; /// ``` #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case", tag = "type")] pub enum SessionStoreTypeConfig { /// In-memory session storage. @@ -1413,6 +1429,7 @@ pub enum SessionStoreTypeConfig { /// ``` #[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] pub struct SessionStoreConfig { @@ -1489,6 +1506,7 @@ impl SessionStoreConfigBuilder { /// /// [`SameSite`]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#controlling_third-party_cookies_with_samesite #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] #[non_exhaustive] pub enum SameSite { @@ -1599,6 +1617,7 @@ impl From for tower_sessions::Expiry { /// let config = SessionMiddlewareConfig::builder().secure(false).build(); /// ``` #[derive(Debug, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] #[non_exhaustive] @@ -1777,6 +1796,7 @@ pub struct SessionMiddlewareConfig { /// ); /// ``` #[serde(with = "crate::serializers::session_expiry_time")] + #[cfg_attr(feature = "config-docs", schemars(with = "Option"))] pub expiry: Expiry, /// What session store to use. @@ -1863,6 +1883,7 @@ impl Default for SessionMiddlewareConfig { /// The default backend if not specified is `console`. #[cfg(feature = "email")] #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[serde(tag = "type", rename_all = "snake_case")] #[non_exhaustive] pub enum EmailTransportTypeConfig { @@ -1940,6 +1961,7 @@ pub enum EmailTransportTypeConfig { /// configuration. #[cfg(feature = "email")] #[derive(Debug, Default, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] pub struct EmailTransportConfig { @@ -2018,6 +2040,7 @@ impl EmailTransportConfigBuilder { /// ``` #[cfg(feature = "email")] #[derive(Debug, Clone, PartialEq, Eq, Builder, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[builder(build_fn(skip, error = std::convert::Infallible))] #[serde(default)] pub struct EmailConfig { @@ -2132,7 +2155,9 @@ impl Default for EmailConfig { /// ``` #[repr(transparent)] #[derive(Clone, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[serde(from = "String")] +#[cfg_attr(feature = "config-docs", schemars(with = "String"))] pub struct SecretKey(SecureBytes); impl Serialize for SecretKey { @@ -2239,7 +2264,9 @@ impl From<&str> for SecretKey { /// let url = DatabaseUrl::from("postgres://user:password@localhost:5432/database"); /// ``` #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[serde(transparent)] +#[cfg_attr(feature = "config-docs", schemars(with = "String"))] #[cfg(feature = "db")] pub struct DatabaseUrl(url::Url); @@ -2348,7 +2375,9 @@ impl std::str::FromStr for CacheType { /// let url = CacheUrl::from("redis://user:password@localhost:6379/0"); /// ``` #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[serde(transparent)] +#[cfg_attr(feature = "config-docs", schemars(with = "String"))] #[cfg(feature = "cache")] pub struct CacheUrl(url::Url); @@ -2457,7 +2486,9 @@ impl std::fmt::Display for CacheUrl { /// let url = EmailUrl::from("smtp://user:pass@hostname:587"); /// ``` #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[serde(transparent)] +#[cfg_attr(feature = "config-docs", schemars(with = "String"))] #[cfg(feature = "email")] pub struct EmailUrl(url::Url); diff --git a/cot/src/email/transport/smtp.rs b/cot/src/email/transport/smtp.rs index 9fadbd454..6d8fa2611 100644 --- a/cot/src/email/transport/smtp.rs +++ b/cot/src/email/transport/smtp.rs @@ -70,6 +70,7 @@ impl From for TransportError { /// /// The default is `Plain`. #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "config-docs", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] #[non_exhaustive] pub enum Mechanism { diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 000000000..2f9efedb9 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,222 @@ +--- +title: Configuration +--- + + + +The configuration for a project. + +Cot projects are configured via a TOML file (typically `config/dev.toml` and `config/prod.toml`, loaded with +[`ProjectConfig::from_toml`](https://docs.rs/cot/latest/cot/config/struct.ProjectConfig.html#method.from_toml)). +This page lists every table and key that `ProjectConfig` understands. + +Any top-level table not listed below is preserved as-is and made available to your application through `ProjectConfig::extra`, for app-specific configuration. + +## Top-level keys + +| Key | Type | Default | Description | +|---|---|---|---| +| `debug` | boolean | `true` | Debug mode flag. | +| `register_panic_hook` | boolean | `true` | Whether to register a panic hook. | +| `secret_key` | string | — | The secret key used for signing cookies and other sensitive data. This is a cryptographic key, should be kept secret, and should be set to a random and unique value for each project. | +| `fallback_secret_keys` | array of strings | `[]` | Fallback secret keys that can be used to verify old sessions. | +| `auth_backend` | table | *(see below)* | The authentication backend to use. | +| `database` | table | *(see below)* | Configuration related to the database. | +| `cache` | table | *(see below)* | Configuration related to the cache. | +| `static_files` | table | *(see below)* | Configuration related to the static files. | +| `middlewares` | table | *(see below)* | Configuration related to the middlewares. | +| `email` | table | *(see below)* | Configuration related to the email backend. | + +## `[auth_backend]` + +Select the variant with the `type` key: + +### `type = "none"` + +No authentication backend. + +No additional keys. + +### `type = "database"` + +Database authentication backend. + +No additional keys. + +## `[database]` + +| Key | Type | Default | Description | +|---|---|---|---| +| `url` | string | — | The URL of the database, possibly with username, password, and other options. | + +## `[cache]` + +| Key | Type | Default | Description | +|---|---|---|---| +| `max_retries` | integer | `3` | Maximum number of retries for cache operations. | +| `timeout` | string | `"5m"` | Timeout for cache operations. | +| `prefix` | string | — | Prefix for cache keys. | +| `store` | table | *(see below)* | The cache store configuration. | + +### `[cache.store]` + +Select the variant with the `type` key: + +#### `type = "memory"` + +In-memory cache store. + +No additional keys. + +#### `type = "redis"` + +Redis cache store. This stores cache data in a Redis instance. The URL to the Redis server must be specified, and additional Redis-specific options can be configured. + +| Key | Type | Default | Description | +|---|---|---|---| +| `url` | string | — | The URL of the Redis server. | +| `pool_size` | integer | — | Connection pool size for Redis connections. This controls how many connections to maintain in the connection pool. When not specified, a default pool size of `10` is used. | + +#### `type = "file"` + +File-based cache store. This stores cache data in files on the local filesystem. The path to the directory where the cache files will be stored must be specified. + +| Key | Type | Default | Description | +|---|---|---|---| +| `path` | string | — | The path to the directory where cache files will be stored. | + +## `[static_files]` + +| Key | Type | Default | Description | +|---|---|---|---| +| `url` | string | `"/static/"` | The URL prefix for the static files to be served at (which should typically end with a slash). The default is `/static/`. | +| `rewrite` | string (one of: `"none"`, `"query_param"`) | `"none"` | The URL rewriting mode for the static files. This is useful to allow long-lived caching of static files, while still allowing to invalidate the cache when the file changes. | +| `cache_timeout` | string | — | The duration for which static files should be cached by browsers. | + +## `[middlewares]` + +| Key | Type | Default | Description | +|---|---|---|---| +| `live_reload` | table | *(see below)* | The configuration for the live reload middleware. | +| `session` | table | *(see below)* | The configuration for the session middleware. | + +### `[middlewares.live_reload]` + +| Key | Type | Default | Description | +|---|---|---|---| +| `enabled` | boolean | `false` | Whether the live reload middleware is enabled. | + +### `[middlewares.session]` + +| Key | Type | Default | Description | +|---|---|---|---| +| `secure` | boolean | `true` | The [`Secure`] of the cookie determines whether the session middleware is secure. | +| `http_only` | boolean | `true` | The [`HttpOnly`] of the cookie used for the session. It is set to `true` by default. | +| `same_site` | string (one of: `"strict"`, `"lax"`, `"none"`) | `"strict"` | The [`SameSite`] attribute of the cookie used for the session. The default value is [`SameSite::Strict`] | +| `domain` | string | — | The [`Domain`] attribute of the cookie used for the session. When not explicitly configured, it is set to `None` by default. | +| `path` | string | `"/"` | The [`Path`] attribute of the cookie used for the session. It is set to `/` by default. | +| `name` | string | `"id"` | The name of the cookie used for the session. It is set to "id" by default. | +| `always_save` | boolean | `false` | Whether the unmodified session should be saved on read or not. If set to `true`, the session will be saved even if it was not modified. It is set to `false` by default. | +| `expiry` | string | — | The [`Expiry`] behavior for session cookies. | +| `store` | table | *(see below)* | What session store to use. | + +#### `[middlewares.session.store]` + +Select the variant with the `type` key: + +##### `type = "memory"` + +In-memory session storage. + +No additional keys. + +##### `type = "database"` + +Database-backed session storage. + +No additional keys. + +##### `type = "file"` + +File-based session storage. + +| Key | Type | Default | Description | +|---|---|---|---| +| `path` | string | — | The path to the directory where session files will be stored. | + +##### `type = "cache"` + +Cache-based session storage. + +| Key | Type | Default | Description | +|---|---|---|---| +| `uri` | string | — | The URI to the cache service. | + +## `[email]` + +| Key | Type | Default | Description | +|---|---|---|---| +| `transport` | table | *(see below)* | The type of email transport backend to use. | + +### `[email.transport]` + +Select the variant with the `type` key: + +#### `type = "console"` + +Console email transport backend. + +No additional keys. + +#### `type = "smtp"` + +SMTP email transport backend. + +| Key | Type | Default | Description | +|---|---|---|---| +| `url` | string | — | The SMTP connection URL. | +| `mechanism` | string (one of: `"plain"`, `"login"`, `"xoauth2"`) | — | The authentication mechanism to use. Supported mechanisms are `plain`, `login`, and `xoauth2`. | + +## Full default configuration + +This is a complete example with every key set explicitly to its default value (fields without a well-defined default, like `secret_key`, are omitted): + +```toml +debug = true +register_panic_hook = true +fallback_secret_keys = [] + +[auth_backend] +type = "none" + +[cache] +max_retries = 3 +timeout = "5m" + +[cache.store] +type = "memory" + +[static_files] +url = "/static/" +rewrite = "none" + +[middlewares.live_reload] +enabled = false + +[middlewares.session] +secure = true +http_only = true +same_site = "strict" +path = "/" +name = "id" +always_save = false + +[middlewares.session.store] +type = "memory" + +[email.transport] +type = "console" +``` diff --git a/docs/site/src/main.rs b/docs/site/src/main.rs index 3a93106a0..e8107cc33 100644 --- a/docs/site/src/main.rs +++ b/docs/site/src/main.rs @@ -36,6 +36,7 @@ impl Project for CotSiteProject { "Getting started", vec![ GuideItem::Page(md_page!("introduction")), + GuideItem::Page(md_page!("configuration")), GuideItem::Page(md_page!("templates")), GuideItem::Page(md_page!("forms")), GuideItem::SubCategory { diff --git a/justfile b/justfile index ab5239fa5..80e7c9c81 100644 --- a/justfile +++ b/justfile @@ -78,3 +78,7 @@ alias td := test-docs test-docs: cargo nextest run -p cot-test + +generate-config-docs: + # regenerates docs/configuration.md from `cot::config::ProjectConfig` + cargo run -p cot-test --features config-docs --bin generate_config_docs