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