Skip to main content

apiplant_core/
config.rs

1//! `main.toml` — the top-level server configuration.
2//!
3//! Every field is optional: a missing file, or a file missing any given key,
4//! falls back to a safe default. The only piece of configuration that is
5//! *inferred* rather than declared is TLS: if the app directory contains an
6//! `https/` folder with a cert + key, the server serves HTTPS.
7//!
8//! ## Environment variables
9//!
10//! Any string value here — like any string in any of the app's TOML files — may
11//! reference the environment: `url = "$DATABASE_URL"`,
12//! `port = "${PORT:-8080}"`. See [`crate::env`] for the syntax.
13
14use serde::Deserialize;
15use std::path::Path;
16
17/// Fully-resolved server configuration.
18#[derive(Debug, Clone, Default, Deserialize)]
19#[serde(default)]
20pub struct Config {
21    pub app: AppConfig,
22    pub server: ServerConfig,
23    pub database: DatabaseConfig,
24    pub auth: AuthConfig,
25    pub docs: DocsConfig,
26    pub admin: AdminConfig,
27    pub public: PublicConfig,
28    pub email: EmailConfig,
29    pub cache: CacheConfig,
30}
31
32/// What the app calls itself.
33///
34/// The directory an app lives in is a developer's filing decision —
35/// `07-functions`, `api-v2`, `backend` — and the dashboard header is read by
36/// people who never see it. This is where an app says the name they should
37/// read instead.
38#[derive(Debug, Clone, Default, Deserialize)]
39#[serde(default)]
40pub struct AppConfig {
41    /// Display name, used wherever the app is named to a person — the admin
42    /// dashboard's header and title. Unset falls back to the directory name.
43    pub name: Option<String>,
44}
45
46/// Accepts either a bare string or a list of them, so a config that names one
47/// domain doesn't have to be written as a one-element list.
48fn one_or_many<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Vec<String>, D::Error> {
49    #[derive(Deserialize)]
50    #[serde(untagged)]
51    enum OneOrMany {
52        One(String),
53        Many(Vec<String>),
54    }
55    Ok(match OneOrMany::deserialize(de)? {
56        OneOrMany::One(s) => vec![s],
57        OneOrMany::Many(v) => v,
58    })
59}
60
61#[derive(Debug, Clone, Deserialize)]
62#[serde(default)]
63pub struct ServerConfig {
64    /// Interface to bind, e.g. `0.0.0.0`. Empty or `*` means every interface
65    /// and is normalised to `0.0.0.0` on load.
66    pub host: String,
67    /// TCP port.
68    pub port: u16,
69    /// Only answer requests whose `Host:` header is one of these. Written as a
70    /// single string (`domain = "api.example.com"`) or a list
71    /// (`domain = ["api.example.com", "www.example.com"]`). Unset — or the
72    /// catch-all spellings `""`, `*` and `_` (nginx's `server_name _`) —
73    /// answers any host, and all of them normalise to an empty list on load.
74    #[serde(deserialize_with = "one_or_many")]
75    pub domain: Vec<String>,
76    /// Sub-path the API is mounted under, e.g. `/api`. Always starts with `/`
77    /// and never ends with one (normalised on load).
78    pub base_path: String,
79    /// Number of worker threads. `None` = one per CPU.
80    pub workers: Option<usize>,
81}
82
83impl Default for ServerConfig {
84    fn default() -> Self {
85        ServerConfig {
86            host: "0.0.0.0".to_string(),
87            port: 8080,
88            domain: Vec::new(),
89            base_path: "/".to_string(),
90            workers: None,
91        }
92    }
93}
94
95#[derive(Debug, Clone, Deserialize)]
96#[serde(default)]
97pub struct DatabaseConfig {
98    /// Full connection URL. When empty it is assembled from the parts below.
99    pub url: String,
100    pub host: String,
101    pub port: u16,
102    pub name: String,
103    pub user: String,
104    pub password: String,
105    /// Max pool connections.
106    pub max_connections: u32,
107    /// Run pending migrations on boot.
108    pub auto_migrate: bool,
109}
110
111impl Default for DatabaseConfig {
112    fn default() -> Self {
113        DatabaseConfig {
114            url: String::new(),
115            host: "localhost".to_string(),
116            port: 5432,
117            name: "apiplant".to_string(),
118            user: "postgres".to_string(),
119            password: "postgres".to_string(),
120            max_connections: 16,
121            auto_migrate: true,
122        }
123    }
124}
125
126impl DatabaseConfig {
127    /// The connection URL, assembled from parts when `url` was left empty.
128    pub fn resolved_url(&self) -> String {
129        if !self.url.is_empty() {
130            return self.url.clone();
131        }
132        format!(
133            "postgres://{}:{}@{}:{}/{}",
134            self.user, self.password, self.host, self.port, self.name
135        )
136    }
137}
138
139#[derive(Debug, Clone, Deserialize)]
140#[serde(default)]
141pub struct AuthConfig {
142    /// Secret used to sign session JWTs. Auto-generated (and warned about) when
143    /// left empty — set it in production so tokens survive restarts.
144    pub jwt_secret: String,
145    /// Session token lifetime in seconds.
146    pub session_ttl_secs: u64,
147    /// Allow self-service signup on `POST /auth/register`.
148    pub allow_registration: bool,
149}
150
151impl Default for AuthConfig {
152    fn default() -> Self {
153        AuthConfig {
154            jwt_secret: String::new(),
155            session_ttl_secs: 60 * 60 * 24 * 7,
156            allow_registration: true,
157        }
158    }
159}
160
161/// Interactive API documentation (OpenAPI spec + Swagger UI).
162#[derive(Debug, Clone, Deserialize)]
163#[serde(default)]
164pub struct DocsConfig {
165    /// Serve the OpenAPI spec and Swagger UI (default true).
166    pub enabled: bool,
167    /// Path (under `base_path`) the Swagger UI is served at.
168    pub path: String,
169    /// Title shown in the UI and the spec's `info.title`. Unset falls back to
170    /// the app's name — see [`App::docs_title`](crate::App::docs_title) — so an
171    /// app that renames itself renames its docs too.
172    pub title: Option<String>,
173}
174
175impl Default for DocsConfig {
176    fn default() -> Self {
177        DocsConfig {
178            enabled: true,
179            path: "/docs".to_string(),
180            title: None,
181        }
182    }
183}
184
185/// The built-in admin dashboard, served from the binary itself.
186///
187/// Every app gets one, and there is only one: the interface is embedded in
188/// `apiplant` and its manifest is derived from the app on boot. Turn it off for
189/// a deployment that shouldn't expose an operator console at all — an app that
190/// wants its own can serve one from `public/` like any other page.
191#[derive(Debug, Clone, Deserialize)]
192#[serde(default)]
193pub struct AdminConfig {
194    /// Serve the admin dashboard (default true).
195    pub enabled: bool,
196    /// Path the dashboard is served at, outside `base_path`.
197    pub path: String,
198    /// Image shown in place of the apiplant mark, as a URL the browser can
199    /// fetch — usually a file in `public/`. Unset keeps the apiplant mark.
200    pub logo: Option<String>,
201}
202
203impl Default for AdminConfig {
204    fn default() -> Self {
205        AdminConfig {
206            enabled: true,
207            path: "/admin".to_string(),
208            logo: None,
209        }
210    }
211}
212
213/// Static files served from the app's `public/` directory.
214///
215/// When the directory exists its contents are served at the site root, so
216/// `public/index.html` answers `/` and `public/style.css` answers `/style.css`.
217#[derive(Debug, Clone, Deserialize)]
218#[serde(default)]
219pub struct PublicConfig {
220    /// Serve `dir` at the root when it exists (default true).
221    pub enabled: bool,
222    /// Directory (relative to the app root) holding the static site.
223    pub dir: String,
224    /// Page returned for requests that match nothing, relative to `dir`.
225    /// Defaults to `404.html` when that file exists.
226    pub not_found: Option<String>,
227}
228
229impl Default for PublicConfig {
230    fn default() -> Self {
231        PublicConfig {
232            enabled: true,
233            dir: "public".to_string(),
234            not_found: None,
235        }
236    }
237}
238
239/// Outbound email: which provider sends it, and the credentials to do so.
240///
241/// Off by default (`provider = "none"`): an app that never sends mail carries
242/// no configuration and no client. Turning it on is one line plus a key, and
243/// every provider is reached through the same [`send_email`] call from a
244/// function — swapping SendGrid for SES is a config change, not a code change.
245///
246/// [`send_email`]: https://docs.rs/apiplant-function
247#[derive(Debug, Clone, Deserialize)]
248#[serde(default)]
249pub struct EmailConfig {
250    /// `none` (default), `smtp`, `ses`, `sendgrid`, `brevo` (aka `sendinblue`),
251    /// `mailjet`, `mailgun`, `postmark` or `resend`.
252    pub provider: String,
253    /// Envelope sender, e.g. `no-reply@example.com`. Required once enabled; a
254    /// message may override it per-send.
255    pub from: String,
256    /// Display name shown beside `from`.
257    pub from_name: String,
258    /// Default `Reply-To`. Empty = none.
259    pub reply_to: String,
260    /// The provider's API key. For `ses` this is the AWS access key id; for
261    /// `mailjet` the public key; for `smtp` it is unused (see [`SmtpConfig`]).
262    pub api_key: String,
263    /// The second half of a two-part credential: the AWS secret access key for
264    /// `ses`, the private key for `mailjet`. Unused elsewhere.
265    pub api_secret: String,
266    /// AWS region for `ses`, e.g. `eu-west-1`.
267    pub region: String,
268    /// Sending domain for `mailgun`, e.g. `mg.example.com`.
269    pub domain: String,
270    /// How long one send may take before it is abandoned.
271    pub timeout_secs: u64,
272    /// Connection details for `provider = "smtp"`.
273    pub smtp: SmtpConfig,
274}
275
276impl Default for EmailConfig {
277    fn default() -> Self {
278        EmailConfig {
279            provider: "none".to_string(),
280            from: String::new(),
281            from_name: String::new(),
282            reply_to: String::new(),
283            api_key: String::new(),
284            api_secret: String::new(),
285            region: String::new(),
286            domain: String::new(),
287            timeout_secs: 15,
288            smtp: SmtpConfig::default(),
289        }
290    }
291}
292
293impl EmailConfig {
294    /// Whether a provider is configured at all. `none` and the empty string
295    /// both mean "this app doesn't send mail".
296    pub fn enabled(&self) -> bool {
297        !matches!(
298            self.provider.trim().to_ascii_lowercase().as_str(),
299            "" | "none"
300        )
301    }
302}
303
304/// SMTP transport settings, used only when `provider = "smtp"`.
305///
306/// Every provider here also speaks SMTP, so this is the escape hatch for one
307/// that has no first-class entry above — or for a company relay that has no API
308/// at all.
309#[derive(Debug, Clone, Deserialize)]
310#[serde(default)]
311pub struct SmtpConfig {
312    pub host: String,
313    /// `0` (the default) picks the port that matches `encryption`: 465 for
314    /// `tls`, 587 for `starttls`, 25 for `none`.
315    pub port: u16,
316    pub username: String,
317    pub password: String,
318    /// `starttls` (default), `tls` (implicit TLS, usually port 465) or `none`.
319    pub encryption: String,
320}
321
322impl Default for SmtpConfig {
323    fn default() -> Self {
324        SmtpConfig {
325            host: String::new(),
326            port: 0,
327            username: String::new(),
328            password: String::new(),
329            encryption: "starttls".to_string(),
330        }
331    }
332}
333
334/// An optional Redis cache.
335///
336/// Nothing in the framework caches through it: resources, permissions and the
337/// admin manifest all behave exactly the same whether it is configured or not.
338/// It exists so a *function* has somewhere to put a rate-limit counter, a
339/// memoised third-party response or a short-lived token — see the `cache_*`
340/// helpers on a function's `Context`.
341///
342/// Off unless `url` is set, so an app that doesn't want one pays nothing.
343#[derive(Debug, Clone, Deserialize)]
344#[serde(default)]
345pub struct CacheConfig {
346    /// Turn the configured cache off without deleting its settings.
347    pub enabled: bool,
348    /// Connection URL, e.g. `redis://127.0.0.1:6379` or `rediss://…/0`. Empty
349    /// (the default) means no cache.
350    pub url: String,
351    /// Prepended to every key a function uses, so several apps can share one
352    /// Redis without colliding.
353    pub prefix: String,
354    /// Expiry applied to a `set` that doesn't ask for one. `0` = keys persist.
355    pub default_ttl_secs: u64,
356    /// How long one cache operation may take before it is abandoned.
357    pub timeout_secs: u64,
358}
359
360impl Default for CacheConfig {
361    fn default() -> Self {
362        CacheConfig {
363            enabled: true,
364            url: String::new(),
365            prefix: String::new(),
366            default_ttl_secs: 0,
367            timeout_secs: 5,
368        }
369    }
370}
371
372impl CacheConfig {
373    /// Whether a cache should be connected: switched on *and* pointed at a
374    /// server.
375    pub fn is_active(&self) -> bool {
376        self.enabled && !self.url.trim().is_empty()
377    }
378}
379
380impl Config {
381    /// Load `main.toml` from an app directory, applying defaults for anything
382    /// absent. A missing file is not an error.
383    pub fn load(app_dir: &Path) -> crate::Result<Self> {
384        let path = app_dir.join("main.toml");
385        let mut config = if path.exists() {
386            let text = std::fs::read_to_string(&path).map_err(|e| crate::Error::Io {
387                path: path.clone(),
388                source: e,
389            })?;
390            // `$VAR` in any string value is read from the environment here,
391            // which is what keeps credentials out of a committed main.toml.
392            crate::env::parse_toml::<Config>(&text, "main.toml")
393                .map_err(|e| crate::Error::Toml { path, source: e })?
394        } else {
395            tracing::info!("no main.toml found, using defaults");
396            Config::default()
397        };
398        config.normalise();
399        Ok(config)
400    }
401
402    fn normalise(&mut self) {
403        // "bind everywhere" has three spellings people arrive with: leaving it
404        // out, the wildcard, and the address itself. They all mean 0.0.0.0.
405        let host = self.server.host.trim();
406        if host.is_empty() || host == "*" {
407            self.server.host = "0.0.0.0".to_string();
408        } else {
409            self.server.host = host.to_string();
410        }
411
412        // Same idea for the vhost filter: an empty or wildcard `domain` is a
413        // request for no filter at all, not a filter for the empty host. `_` is
414        // there because nginx spells its catch-all `server_name _`. A wildcard
415        // anywhere in the list wins — it already answers every host, so the
416        // named entries beside it can't narrow anything.
417        let domains = std::mem::take(&mut self.server.domain);
418        let mut wildcard = false;
419        for d in domains {
420            match d.trim() {
421                "" | "*" | "_" | "0.0.0.0" => wildcard = true,
422                d => self.server.domain.push(d.to_string()),
423            }
424        }
425        if wildcard {
426            self.server.domain.clear();
427        }
428
429        let bp = self.server.base_path.trim_end_matches('/');
430        self.server.base_path = if bp.is_empty() {
431            String::new()
432        } else if bp.starts_with('/') {
433            bp.to_string()
434        } else {
435            format!("/{bp}")
436        };
437
438        if !self.docs.path.starts_with('/') {
439            self.docs.path = format!("/{}", self.docs.path);
440        }
441
442        let admin = self.admin.path.trim_matches('/');
443        self.admin.path = if admin.is_empty() {
444            AdminConfig::default().path
445        } else {
446            format!("/{admin}")
447        };
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use std::fs;
455    use std::time::{SystemTime, UNIX_EPOCH};
456
457    fn temp_dir(label: &str) -> std::path::PathBuf {
458        let mut dir = std::env::temp_dir();
459        let stamp = SystemTime::now()
460            .duration_since(UNIX_EPOCH)
461            .unwrap()
462            .as_nanos();
463        dir.push(format!(
464            "apiplant-config-{label}-{}-{stamp}",
465            std::process::id()
466        ));
467        fs::create_dir_all(&dir).unwrap();
468        dir
469    }
470
471    #[test]
472    fn missing_main_toml_uses_defaults() {
473        let dir = temp_dir("defaults");
474        let config = Config::load(&dir).unwrap();
475
476        assert_eq!(config.server.host, "0.0.0.0");
477        assert_eq!(config.server.port, 8080);
478        assert_eq!(config.server.base_path, "");
479        assert_eq!(
480            config.database.resolved_url(),
481            "postgres://postgres:postgres@localhost:5432/apiplant"
482        );
483        assert!(config.auth.allow_registration);
484        assert!(config.docs.enabled);
485        assert_eq!(config.docs.path, "/docs");
486        // The dashboard and the public site are on by default; an app opts out.
487        assert!(config.admin.enabled);
488        assert_eq!(config.admin.path, "/admin");
489        assert!(config.public.enabled);
490        assert_eq!(config.public.dir, "public");
491        assert_eq!(config.public.not_found, None);
492        // Email and cache are opt-in: an app that says nothing gets neither.
493        assert!(!config.email.enabled());
494        assert!(!config.cache.is_active());
495
496        fs::remove_dir_all(dir).unwrap();
497    }
498
499    #[test]
500    fn email_and_cache_load_from_their_sections() {
501        let dir = temp_dir("email-cache");
502        fs::write(
503            dir.join("main.toml"),
504            r#"
505[email]
506provider = "sendgrid"
507from = "no-reply@example.com"
508from_name = "Example"
509api_key = "SG.literal"
510
511[cache]
512url = "redis://127.0.0.1:6379"
513prefix = "example:"
514default_ttl_secs = 300
515"#,
516        )
517        .unwrap();
518
519        let config = Config::load(&dir).unwrap();
520
521        assert!(config.email.enabled());
522        assert_eq!(config.email.provider, "sendgrid");
523        assert_eq!(config.email.from, "no-reply@example.com");
524        assert_eq!(config.email.api_key, "SG.literal");
525        // Untouched defaults still apply inside a section that was given.
526        assert_eq!(config.email.timeout_secs, 15);
527        assert_eq!(config.email.smtp.encryption, "starttls");
528
529        assert!(config.cache.is_active());
530        assert_eq!(config.cache.prefix, "example:");
531        assert_eq!(config.cache.default_ttl_secs, 300);
532
533        fs::remove_dir_all(dir).unwrap();
534    }
535
536    /// `enabled = false` has to beat a perfectly good URL, or switching the
537    /// cache off would mean deleting the settings needed to switch it back on.
538    #[test]
539    fn a_disabled_cache_stays_off_even_with_a_url() {
540        let config = CacheConfig {
541            enabled: false,
542            url: "redis://127.0.0.1:6379".into(),
543            ..CacheConfig::default()
544        };
545        assert!(!config.is_active());
546    }
547
548    /// `Config::load` reads its file through the same expansion every other
549    /// app-directory TOML gets — including a URL assembled from several
550    /// variables, which is the case a whole-value substitution can't do.
551    #[test]
552    fn load_expands_environment_references_anywhere_in_the_file() {
553        std::env::set_var("APIPLANT_TEST_JWT", "from-env-jwt");
554        std::env::set_var("APIPLANT_TEST_MAIL", "from-env-key");
555        std::env::set_var("APIPLANT_TEST_DB_USER", "alice");
556        std::env::set_var("APIPLANT_TEST_DB_PASS", "s3cret");
557        let dir = temp_dir("env");
558        fs::write(
559            dir.join("main.toml"),
560            r#"
561[server]
562domain = "${APIPLANT_TEST_DOMAIN:-api.example.com}"
563
564[database]
565url = "postgres://$APIPLANT_TEST_DB_USER:$APIPLANT_TEST_DB_PASS@db:5432/app"
566
567[auth]
568jwt_secret = "$APIPLANT_TEST_JWT"
569
570[email]
571provider = "brevo"
572api_key = "${APIPLANT_TEST_MAIL}"
573from = "no-reply@example.com"
574"#,
575        )
576        .unwrap();
577
578        let config = Config::load(&dir).unwrap();
579        assert_eq!(
580            config.database.resolved_url(),
581            "postgres://alice:s3cret@db:5432/app"
582        );
583        assert_eq!(config.auth.jwt_secret, "from-env-jwt");
584        assert_eq!(config.email.api_key, "from-env-key");
585        // An unset variable falls back to the default written beside it.
586        assert_eq!(config.server.domain, ["api.example.com"]);
587
588        for name in [
589            "APIPLANT_TEST_JWT",
590            "APIPLANT_TEST_MAIL",
591            "APIPLANT_TEST_DB_USER",
592            "APIPLANT_TEST_DB_PASS",
593        ] {
594            std::env::remove_var(name);
595        }
596        fs::remove_dir_all(dir).unwrap();
597    }
598
599    #[test]
600    fn load_treats_wildcard_host_and_domain_as_everything() {
601        for (host, domain) in [
602            ("", "\"\""),
603            ("*", "\"*\""),
604            (" 0.0.0.0 ", "\"_\""),
605            ("*", "[]"),
606            // A wildcard beside named hosts still means "answer any host".
607            ("*", "[\"api.example.com\", \"*\"]"),
608        ] {
609            let dir = temp_dir("wildcards");
610            fs::write(
611                dir.join("main.toml"),
612                format!("[server]\nhost = \"{host}\"\ndomain = {domain}\n"),
613            )
614            .unwrap();
615
616            let config = Config::load(&dir).unwrap();
617
618            assert_eq!(config.server.host, "0.0.0.0", "host {host:?}");
619            assert!(config.server.domain.is_empty(), "domain {domain}");
620            fs::remove_dir_all(&dir).unwrap();
621        }
622    }
623
624    /// `domain` takes a list as readily as a single string, and each entry is
625    /// trimmed the same way.
626    #[test]
627    fn load_accepts_a_list_of_domains() {
628        let dir = temp_dir("domains");
629        fs::write(
630            dir.join("main.toml"),
631            "[server]\ndomain = [\"api.example.com\", \" www.example.com \"]\n",
632        )
633        .unwrap();
634
635        let config = Config::load(&dir).unwrap();
636
637        assert_eq!(config.server.domain, ["api.example.com", "www.example.com"]);
638        fs::remove_dir_all(dir).unwrap();
639    }
640
641    #[test]
642    fn load_normalises_paths_and_prefers_explicit_database_url() {
643        let dir = temp_dir("normalise");
644        fs::write(
645            dir.join("main.toml"),
646            r#"
647[server]
648base_path = "api/"
649workers = 8
650
651[database]
652url = "postgres://db.example/custom"
653host = "ignored"
654port = 9999
655name = "ignored"
656user = "ignored"
657password = "ignored"
658
659[docs]
660path = "swagger"
661
662[admin]
663path = "console/"
664
665[public]
666dir = "site"
667not_found = "oops.html"
668"#,
669        )
670        .unwrap();
671
672        let config = Config::load(&dir).unwrap();
673
674        assert_eq!(config.server.base_path, "/api");
675        assert_eq!(config.server.workers, Some(8));
676        assert_eq!(config.docs.path, "/swagger");
677        assert_eq!(config.admin.path, "/console");
678        assert_eq!(config.public.dir, "site");
679        assert_eq!(config.public.not_found.as_deref(), Some("oops.html"));
680        assert_eq!(
681            config.database.resolved_url(),
682            "postgres://db.example/custom"
683        );
684
685        fs::remove_dir_all(dir).unwrap();
686    }
687
688    #[test]
689    fn resolved_url_is_assembled_from_parts_when_url_is_empty() {
690        let config = DatabaseConfig {
691            url: String::new(),
692            host: "db".into(),
693            port: 5433,
694            name: "plants".into(),
695            user: "alice".into(),
696            password: "secret".into(),
697            max_connections: 16,
698            auto_migrate: true,
699        };
700
701        assert_eq!(
702            config.resolved_url(),
703            "postgres://alice:secret@db:5433/plants"
704        );
705    }
706}