Skip to main content

apiplant_core/
app.rs

1//! The loaded application: an app directory turned into config + resources.
2
3use crate::agent::Agent;
4use crate::config::Config;
5use crate::schema::{Field, FieldAdmin, FieldType, OnDelete, Resource};
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9/// Everything the server needs, assembled from an app directory.
10#[derive(Debug, Clone)]
11pub struct App {
12    /// Root of the app directory.
13    pub root: PathBuf,
14    pub config: Config,
15    /// All resources, keyed by name. Built-ins are included (and overridable).
16    pub resources: BTreeMap<String, Resource>,
17    /// All configured agents, keyed by name.
18    pub agents: BTreeMap<String, Agent>,
19    /// Present when an `https/` directory with cert + key was found.
20    pub tls: Option<TlsPaths>,
21    /// Directory scanned for compiled function libraries.
22    pub functions_dir: PathBuf,
23}
24
25/// Resolved TLS material.
26#[derive(Debug, Clone)]
27pub struct TlsPaths {
28    pub cert: PathBuf,
29    pub key: PathBuf,
30}
31
32impl App {
33    /// Load an app directory. Missing pieces fall back to safe defaults, so the
34    /// smallest valid app is an empty directory.
35    pub fn load(root: impl AsRef<Path>) -> crate::Result<App> {
36        let root = root.as_ref().to_path_buf();
37        let config = Config::load(&root)?;
38
39        let mut resources = BTreeMap::new();
40        let mut agents = BTreeMap::new();
41
42        // 1. Seed with the built-ins.
43        for (name, src) in crate::defaults::builtins() {
44            resources.insert(name.to_string(), crate::defaults::parse_builtin(src));
45        }
46
47        // 1b. The billing resources, only for an app that takes money. They
48        //     are seeded here rather than in `builtins()` so that an app with
49        //     no `[payments]` provider carries neither the tables nor the
50        //     endpoints — see `defaults::billing_builtins`.
51        if config.payments.enabled() {
52            for (name, src) in crate::defaults::billing_builtins() {
53                resources.insert(name.to_string(), crate::defaults::parse_builtin(src));
54            }
55        }
56
57        // 1b-ii. The table a sign-in lives in while somebody reads a consent
58        //        screen, only for an app that has an `[oauth]` provider.
59        if config.oauth.enabled() {
60            for (name, src) in crate::defaults::oauth_builtins() {
61                resources.insert(name.to_string(), crate::defaults::parse_builtin(src));
62            }
63        }
64
65        // 1c. Configured agents, loaded from `agents/`, each optionally adding
66        //     generated history resources.
67        let agents_dir = root.join("agents");
68        if agents_dir.is_dir() {
69            for entry in std::fs::read_dir(&agents_dir).map_err(|e| crate::Error::Io {
70                path: agents_dir.clone(),
71                source: e,
72            })? {
73                let entry = entry.map_err(|e| crate::Error::Io {
74                    path: agents_dir.clone(),
75                    source: e,
76                })?;
77                let path = entry.path();
78                if path.extension().and_then(|e| e.to_str()) != Some("toml") {
79                    continue;
80                }
81                let agent = Agent::load(&path)?;
82                for (name, resource) in agent.storage_resources()? {
83                    resources.insert(name, resource);
84                }
85                tracing::info!(agent = %agent.meta.name, "loaded agent");
86                agents.insert(agent.meta.name.clone(), agent);
87            }
88        }
89
90        // 2. Load user-defined models, overriding built-ins by name.
91        let models_dir = root.join("models");
92        if models_dir.is_dir() {
93            for entry in std::fs::read_dir(&models_dir).map_err(|e| crate::Error::Io {
94                path: models_dir.clone(),
95                source: e,
96            })? {
97                let entry = entry.map_err(|e| crate::Error::Io {
98                    path: models_dir.clone(),
99                    source: e,
100                })?;
101                let path = entry.path();
102                if path.extension().and_then(|e| e.to_str()) != Some("toml") {
103                    continue;
104                }
105                let resource = Resource::load(&path)?;
106                tracing::info!(resource = %resource.meta.name, "loaded model");
107                resources.insert(resource.meta.name.clone(), resource);
108            }
109        }
110
111        // 3. Make multitenancy automatic: every org-scoped resource carries an
112        //    `organization_id` foreign key. Inject it where the author didn't
113        //    declare one, so the column, its FK, and org filtering all just work.
114        for resource in resources.values_mut() {
115            if resource.is_org_scoped() && !resource.fields.contains_key("organization_id") {
116                resource.fields.insert(
117                    "organization_id".to_string(),
118                    Field {
119                        ty: FieldType::Reference,
120                        references: Some("organization".to_string()),
121                        required: true,
122                        unique: false,
123                        hidden: false,
124                        default: None,
125                        max_length: None,
126                        on_delete: Some(OnDelete::Cascade),
127                        // Injected and stamped by the framework — an operator
128                        // should never see, let alone type, a tenant id.
129                        admin: FieldAdmin {
130                            visible: false,
131                            ..FieldAdmin::default()
132                        },
133                    },
134                );
135            }
136        }
137
138        // 4. TLS is inferred from the presence of an `https/` directory.
139        let tls = Self::detect_tls(&root);
140        if tls.is_some() {
141            tracing::info!("https/ directory found — serving over TLS");
142        }
143
144        Ok(App {
145            functions_dir: root.join("functions"),
146            root,
147            config,
148            resources,
149            agents,
150            tls,
151        })
152    }
153
154    /// Look for a cert + key under `https/`, tolerating common filenames.
155    fn detect_tls(root: &Path) -> Option<TlsPaths> {
156        let dir = root.join("https");
157        if !dir.is_dir() {
158            return None;
159        }
160        let cert = ["cert.pem", "fullchain.pem", "certificate.pem", "server.crt"]
161            .iter()
162            .map(|f| dir.join(f))
163            .find(|p| p.exists())?;
164        let key = ["key.pem", "privkey.pem", "server.key", "private.pem"]
165            .iter()
166            .map(|f| dir.join(f))
167            .find(|p| p.exists())?;
168        Some(TlsPaths { cert, key })
169    }
170
171    /// What to call this app wherever it is named to a person — the admin
172    /// dashboard's header, the API docs, the CLI.
173    ///
174    /// `[app] name` when the app gives itself one; the directory it lives in
175    /// otherwise, which is a filing decision (`07-functions`, `backend`) rather
176    /// than a name anybody should have to read. A blank name is not a name: it
177    /// would render as a heading with nothing in it, so it falls back too.
178    pub fn display_name(&self) -> String {
179        self.config
180            .app
181            .name
182            .as_deref()
183            .map(str::trim)
184            .filter(|name| !name.is_empty())
185            .map(str::to_string)
186            .unwrap_or_else(|| {
187                self.root
188                    .file_name()
189                    .and_then(|name| name.to_str())
190                    .unwrap_or("apiplant app")
191                    .to_string()
192            })
193    }
194
195    /// Title for the API docs: `[docs] title` when set, the app's name
196    /// otherwise — so an app that names itself once is named everywhere.
197    pub fn docs_title(&self) -> String {
198        self.config
199            .docs
200            .title
201            .as_deref()
202            .map(str::trim)
203            .filter(|title| !title.is_empty())
204            .map(str::to_string)
205            .unwrap_or_else(|| self.display_name())
206    }
207
208    /// Resource names in dependency order (referenced resources first), so a
209    /// migrator can create tables without violating foreign keys.
210    pub fn resources_in_dependency_order(&self) -> Vec<&Resource> {
211        let mut ordered: Vec<&Resource> = Vec::new();
212        let mut placed: std::collections::HashSet<&str> = std::collections::HashSet::new();
213
214        // Simple repeated passes; resource graphs are tiny.
215        let mut remaining: Vec<&Resource> = self.resources.values().collect();
216        while !remaining.is_empty() {
217            let mut progressed = false;
218            remaining.retain(|r| {
219                let deps_ready = r.fields.values().all(|f| match &f.references {
220                    Some(target) => placed.contains(target.as_str()) || target == &r.meta.name,
221                    None => true,
222                });
223                if deps_ready {
224                    ordered.push(r);
225                    placed.insert(r.meta.name.as_str());
226                    progressed = true;
227                    false
228                } else {
229                    true
230                }
231            });
232            if !progressed {
233                // Cyclic or dangling reference — emit the rest as-is rather than loop forever.
234                ordered.append(&mut remaining);
235            }
236        }
237        ordered
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use std::fs;
245    use std::time::{SystemTime, UNIX_EPOCH};
246
247    fn temp_app_dir(label: &str) -> PathBuf {
248        let mut dir = std::env::temp_dir();
249        let stamp = SystemTime::now()
250            .duration_since(UNIX_EPOCH)
251            .unwrap()
252            .as_nanos();
253        dir.push(format!(
254            "apiplant-app-{label}-{}-{stamp}",
255            std::process::id()
256        ));
257        fs::create_dir_all(dir.join("models")).unwrap();
258        dir
259    }
260
261    /// One name, given once: the dashboard header and the API docs both read
262    /// it, and an app that never names itself is called after its directory.
263    #[test]
264    fn display_name_and_docs_title_share_one_source() {
265        let dir = temp_app_dir("naming");
266        let directory = dir.file_name().unwrap().to_str().unwrap().to_string();
267
268        let unnamed = App::load(&dir).unwrap();
269        assert_eq!(unnamed.display_name(), directory);
270        assert_eq!(unnamed.docs_title(), directory);
271
272        fs::write(dir.join("main.toml"), "[app]\nname = \"Acme Logistics\"\n").unwrap();
273        let named = App::load(&dir).unwrap();
274        assert_eq!(named.display_name(), "Acme Logistics");
275        assert_eq!(named.docs_title(), "Acme Logistics");
276
277        // A blank name is not a name — it would render as an empty heading.
278        fs::write(dir.join("main.toml"), "[app]\nname = \"   \"\n").unwrap();
279        assert_eq!(App::load(&dir).unwrap().display_name(), directory);
280
281        // `[docs] title` still wins for the docs alone, for an app whose API is
282        // published under a different name than the app answers to.
283        fs::write(
284            dir.join("main.toml"),
285            "[app]\nname = \"Acme Logistics\"\n\n[docs]\ntitle = \"Acme Freight API\"\n",
286        )
287        .unwrap();
288        let split = App::load(&dir).unwrap();
289        assert_eq!(split.display_name(), "Acme Logistics");
290        assert_eq!(split.docs_title(), "Acme Freight API");
291
292        fs::remove_dir_all(&dir).unwrap();
293    }
294
295    #[test]
296    fn empty_app_loads_builtins_and_no_tls() {
297        let dir = temp_app_dir("empty");
298        let app = App::load(&dir).unwrap();
299
300        assert!(app.resources.contains_key("organization"));
301        assert!(app.resources.contains_key("membership"));
302        assert!(app.resources.contains_key("user"));
303        assert!(app.resources.contains_key("api_key"));
304        assert!(app.resources.contains_key("oauth_connection"));
305        assert!(app.agents.is_empty());
306        assert!(app.tls.is_none());
307        assert_eq!(app.functions_dir, dir.join("functions"));
308
309        fs::remove_dir_all(dir).unwrap();
310    }
311
312    #[test]
313    fn org_scoped_resources_get_organization_id_injected() {
314        let dir = temp_app_dir("org-scope");
315        fs::write(
316            dir.join("models/post.toml"),
317            r#"
318[resource]
319name = "post"
320
321[fields.title]
322type = "string"
323required = true
324"#,
325        )
326        .unwrap();
327        fs::write(
328            dir.join("models/plan.toml"),
329            r#"
330[resource]
331name = "plan"
332scope = "global"
333
334[fields.name]
335type = "string"
336"#,
337        )
338        .unwrap();
339
340        let app = App::load(&dir).unwrap();
341        let post = app.resources.get("post").unwrap();
342        let plan = app.resources.get("plan").unwrap();
343
344        let org = post.fields.get("organization_id").unwrap();
345        assert_eq!(org.ty, FieldType::Reference);
346        assert_eq!(org.references.as_deref(), Some("organization"));
347        assert!(org.required);
348        assert_eq!(org.on_delete, Some(OnDelete::Cascade));
349        assert!(!plan.fields.contains_key("organization_id"));
350
351        fs::remove_dir_all(dir).unwrap();
352    }
353
354    #[test]
355    fn same_named_model_replaces_builtin_resource() {
356        let dir = temp_app_dir("override-user");
357        fs::write(
358            dir.join("models/users.toml"),
359            r#"
360[resource]
361name = "user"
362scope = "global"
363
364[auth]
365identity_field = "username"
366password_field = "password_hash"
367
368[fields.username]
369type = "string"
370required = true
371
372[fields.password_hash]
373type = "string"
374hidden = true
375"#,
376        )
377        .unwrap();
378
379        let app = App::load(&dir).unwrap();
380        let user = app.resources.get("user").unwrap();
381
382        assert!(user.fields.contains_key("username"));
383        assert!(!user.fields.contains_key("email"));
384        assert_eq!(user.auth.as_ref().unwrap().identity_field, "username");
385
386        fs::remove_dir_all(dir).unwrap();
387    }
388
389    #[test]
390    fn tls_detection_accepts_common_cert_and_key_names() {
391        let dir = temp_app_dir("tls");
392        fs::create_dir_all(dir.join("https")).unwrap();
393        fs::write(dir.join("https/fullchain.pem"), "cert").unwrap();
394        fs::write(dir.join("https/privkey.pem"), "key").unwrap();
395
396        let app = App::load(&dir).unwrap();
397        let tls = app.tls.unwrap();
398        assert_eq!(tls.cert, dir.join("https/fullchain.pem"));
399        assert_eq!(tls.key, dir.join("https/privkey.pem"));
400
401        fs::remove_dir_all(dir).unwrap();
402    }
403
404    #[test]
405    fn dependency_order_places_parents_before_children() {
406        let dir = temp_app_dir("deps");
407        fs::write(
408            dir.join("models/post.toml"),
409            r#"
410[resource]
411name = "post"
412
413[fields.owner_id]
414type = "reference"
415references = "user"
416
417[fields.title]
418type = "string"
419"#,
420        )
421        .unwrap();
422        fs::write(
423            dir.join("models/comment.toml"),
424            r#"
425[resource]
426name = "comment"
427
428[fields.post_id]
429type = "reference"
430references = "post"
431
432[fields.owner_id]
433type = "reference"
434references = "user"
435
436[fields.body]
437type = "text"
438"#,
439        )
440        .unwrap();
441
442        let app = App::load(&dir).unwrap();
443        let order: Vec<_> = app
444            .resources_in_dependency_order()
445            .into_iter()
446            .map(|r| r.meta.name.as_str())
447            .collect();
448
449        let user_idx = order.iter().position(|name| *name == "user").unwrap();
450        let post_idx = order.iter().position(|name| *name == "post").unwrap();
451        let comment_idx = order.iter().position(|name| *name == "comment").unwrap();
452
453        assert!(user_idx < post_idx);
454        assert!(post_idx < comment_idx);
455
456        fs::remove_dir_all(dir).unwrap();
457    }
458
459    #[test]
460    fn agents_are_loaded_and_seed_history_resources() {
461        let dir = temp_app_dir("agent");
462        fs::create_dir_all(dir.join("agents")).unwrap();
463        fs::write(
464            dir.join("agents/coach.toml"),
465            r#"
466[agent]
467name = "coach"
468system = "Be helpful."
469storage.enabled = true
470
471[permissions]
472chat = "authenticated"
473history = "owner"
474"#,
475        )
476        .unwrap();
477
478        let app = App::load(&dir).unwrap();
479        assert!(app.agents.contains_key("coach"));
480        assert!(app.resources.contains_key("ai_coach_thread"));
481        assert!(app.resources.contains_key("ai_coach_message"));
482
483        fs::remove_dir_all(dir).unwrap();
484    }
485}