1use crate::config::Config;
4use crate::schema::{Field, FieldAdmin, FieldType, OnDelete, Resource};
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone)]
10pub struct App {
11 pub root: PathBuf,
13 pub config: Config,
14 pub resources: BTreeMap<String, Resource>,
16 pub tls: Option<TlsPaths>,
18 pub functions_dir: PathBuf,
20}
21
22#[derive(Debug, Clone)]
24pub struct TlsPaths {
25 pub cert: PathBuf,
26 pub key: PathBuf,
27}
28
29impl App {
30 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 for (name, src) in crate::defaults::builtins() {
40 resources.insert(name.to_string(), crate::defaults::parse_builtin(src));
41 }
42
43 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 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 admin: FieldAdmin {
83 visible: false,
84 ..FieldAdmin::default()
85 },
86 },
87 );
88 }
89 }
90
91 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 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 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 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 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 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 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 #[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 fs::write(dir.join("main.toml"), "[app]\nname = \" \"\n").unwrap();
231 assert_eq!(App::load(&dir).unwrap().display_name(), directory);
232
233 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}