Skip to main content

apiplant_core/
app.rs

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