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