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