1use std::collections::{BTreeMap, BTreeSet};
24use std::fs;
25use std::path::{Path, PathBuf};
26
27use anyhow::{anyhow, bail, Context, Result};
28use apiplant_abi::{FunctionAccess, HttpMethod};
29use apiplant_core::schema::{
30 is_auth_resource, relation_name, titleize, Access, ContentFormat, Field, FieldType, OnDelete,
31 Resource, Widget,
32};
33use apiplant_core::App;
34use serde::Serialize;
35use serde_json::Value;
36
37use crate::functions::FunctionRegistry;
38
39pub const MANIFEST_FILE: &str = "apiplant-admin.json";
41
42#[derive(Debug, Clone)]
43pub struct Options {
44 pub api: String,
45 pub out: Option<PathBuf>,
46}
47
48#[derive(Debug, Serialize)]
49struct AdminManifest {
50 title: String,
51 app_name: String,
52 logo: Option<String>,
54 api_base_url: String,
55 docs_url: Option<String>,
56 auth: AuthManifest,
57 resources: Vec<ResourceManifest>,
58 functions: Vec<FunctionManifest>,
59}
60
61#[derive(Debug, Serialize)]
62struct AuthManifest {
63 identity_field: String,
65 identity_label: String,
66 allow_registration: bool,
67 signup_fields: Vec<FieldManifest>,
70 profile_fields: Vec<FieldManifest>,
72 known_roles: Vec<String>,
75}
76
77#[derive(Debug, Serialize)]
78struct ResourceManifest {
79 name: String,
80 label: String,
82 plural: String,
84 group: Option<String>,
86 order: i64,
87 builtin: bool,
88 auth_resource: bool,
91 visible: bool,
93 roles: Vec<String>,
95 scope: &'static str,
96 owner_field: String,
97 display_field: Option<String>,
99 search_field: Option<String>,
101 columns: Vec<String>,
103 fields: Vec<FieldManifest>,
104 relations: Vec<RelationManifest>,
106 children: Vec<ChildManifest>,
108 permissions: ActionPermissionsManifest,
109}
110
111#[derive(Debug, Serialize)]
112struct ActionPermissionsManifest {
113 list: ActionPermissionManifest,
114 read: ActionPermissionManifest,
115 create: ActionPermissionManifest,
116 update: ActionPermissionManifest,
117 delete: ActionPermissionManifest,
118}
119
120#[derive(Debug, Serialize)]
121struct ActionPermissionManifest {
122 value: String,
123 role: Option<String>,
125 note: String,
126 requires_org: bool,
127}
128
129#[derive(Debug, Serialize)]
130struct FieldManifest {
131 name: String,
132 label: String,
133 #[serde(rename = "type")]
134 ty: &'static str,
135 widget: &'static str,
137 help: Option<String>,
138 placeholder: Option<String>,
139 format: &'static str,
142 options: Vec<FieldOption>,
143 required: bool,
144 unique: bool,
145 hidden: bool,
147 admin_visible: bool,
149 readonly: bool,
150 max_length: Option<u32>,
151 references: Option<String>,
152 relation: Option<String>,
153 on_delete: Option<&'static str>,
154 default_value: Option<Value>,
155 writable: bool,
157}
158
159#[derive(Debug, Serialize)]
160struct FieldOption {
161 value: String,
162 label: String,
163}
164
165#[derive(Debug, Serialize)]
166struct RelationManifest {
167 field: String,
168 relation: String,
169 target: String,
170 label: String,
172 required: bool,
173}
174
175#[derive(Debug, Serialize)]
179struct ChildManifest {
180 resource: String,
181 field: String,
183 label: String,
184}
185
186#[derive(Debug, Serialize)]
187struct FunctionManifest {
188 name: String,
189 label: String,
190 description: String,
191 group: Option<String>,
192 order: i64,
193 method: &'static str,
194 permission: String,
196 role: Option<String>,
197 permission_note: String,
198 requires_org: bool,
199 visible: bool,
201 roles: Vec<String>,
202 confirm: Option<String>,
204 run_label: String,
205 input_schema: Option<Value>,
207 output_schema: Option<Value>,
208}
209
210#[derive(Debug, Default, serde::Deserialize)]
212#[serde(default)]
213struct FunctionAdmin {
214 visible: Option<bool>,
215 roles: Vec<String>,
216 label: Option<String>,
217 group: Option<String>,
218 description: Option<String>,
219 confirm: Option<String>,
220 run_label: Option<String>,
221 order: Option<i64>,
222}
223
224pub fn build(app_dir: &Path, options: Options) -> Result<PathBuf> {
225 let app = App::load(app_dir)?;
226 let api_base_url = normalize_api_base(
227 &options.api,
228 &app.config.server.base_path,
229 app.tls.is_some(),
230 )?;
231 let output_dir = options.out.unwrap_or_else(|| app_dir.join("admin"));
232 let registry = FunctionRegistry::load(&app);
233 let manifest = build_manifest(&app, ®istry, api_base_url.clone())?;
234
235 fs::create_dir_all(&output_dir)
236 .with_context(|| format!("failed to create {}", output_dir.display()))?;
237
238 for (relative, _) in apiplant_assets::ADMIN {
239 let path = output_dir.join(relative);
240 if let Some(parent) = path.parent() {
241 fs::create_dir_all(parent)
242 .with_context(|| format!("failed to create {}", parent.display()))?;
243 }
244 let bytes = asset(relative).expect("listed asset");
245 write_bytes(path, &bytes)?;
246 }
247 write_json(output_dir.join(MANIFEST_FILE), &manifest)?;
248
249 Ok(output_dir)
250}
251
252pub fn asset(path: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
258 use std::borrow::Cow;
259
260 let bytes = apiplant_assets::find(apiplant_assets::ADMIN, path)?;
261 if path.trim_matches('/') == "app.css" {
262 let css = String::from_utf8_lossy(bytes)
263 .replace("url(/head.png)", "url(./head.png)")
264 .replace("url(/head-inverted.png)", "url(./head-inverted.png)");
265 return Some(Cow::Owned(css.into_bytes()));
266 }
267 Some(Cow::Borrowed(bytes))
268}
269
270pub fn manifest_json(
275 app: &App,
276 functions: &FunctionRegistry,
277 api_base_url: String,
278) -> Result<String> {
279 let manifest = build_manifest(app, functions, api_base_url)?;
280 Ok(serde_json::to_string(&manifest)?)
281}
282
283fn build_manifest(
284 app: &App,
285 functions: &FunctionRegistry,
286 api_base_url: String,
287) -> Result<AdminManifest> {
288 let app_name = app.display_name();
289 let user = app.resources.get("user");
290 let identity_field = user
291 .and_then(|resource| resource.auth.as_ref())
292 .map(|auth| auth.identity_field.clone())
293 .unwrap_or_else(|| "email".to_string());
294 let password_field = user
295 .and_then(|resource| resource.auth.as_ref())
296 .map(|auth| auth.password_field.clone())
297 .unwrap_or_else(|| "password_hash".to_string());
298 let docs_url = if app.config.docs.enabled {
299 Some(format!("{}{}", api_base_url, app.config.docs.path))
300 } else {
301 None
302 };
303
304 let hook_functions = app
308 .resources
309 .values()
310 .flat_map(|resource| {
311 resource
312 .hooks
313 .iter()
314 .map(|(_, function)| function.to_string())
315 })
316 .collect::<BTreeSet<_>>();
317
318 let mut children: BTreeMap<String, Vec<ChildManifest>> = BTreeMap::new();
321 for child in app.resources.values() {
322 let references = child.references();
323 for reference in &references {
324 if reference.field == "organization_id" {
328 continue;
329 }
330 if !app.resources.contains_key(&reference.target) {
331 continue;
332 }
333 let ambiguous = references
337 .iter()
338 .filter(|other| other.target == reference.target)
339 .count()
340 > 1;
341 let label = if ambiguous {
342 format!(
343 "{} ({})",
344 child.admin_plural(),
345 titleize(&reference.relation).to_lowercase()
346 )
347 } else {
348 child.admin_plural()
349 };
350 children
351 .entry(reference.target.clone())
352 .or_default()
353 .push(ChildManifest {
354 resource: child.meta.name.clone(),
355 field: reference.field.clone(),
356 label,
357 });
358 }
359 }
360
361 let resources = app
362 .resources
363 .values()
364 .map(|resource| {
365 resource_manifest(
366 resource,
367 &password_field,
368 children
369 .remove(resource.meta.name.as_str())
370 .unwrap_or_default(),
371 )
372 })
373 .collect::<Vec<_>>();
374
375 let mut loaded_functions = functions
376 .iter()
377 .filter(|entry| !hook_functions.contains(entry.manifest.name.as_str()))
378 .map(|entry| function_manifest(&entry.manifest))
379 .filter(|manifest| manifest.permission != "private")
382 .collect::<Vec<_>>();
383 loaded_functions.sort_by(|left, right| {
384 left.group
385 .cmp(&right.group)
386 .then(left.order.cmp(&right.order))
387 .then(left.label.cmp(&right.label))
388 });
389
390 let signup_fields = user
391 .map(|resource| {
392 resource
393 .fields
394 .iter()
395 .filter(|(name, field)| {
396 *name != &identity_field
400 && *name != &password_field
401 && field.required
402 && !field.hidden
403 && field.admin.visible
404 && name.as_str() != "organization_id"
405 })
406 .map(|(name, field)| field_manifest(name, field, resource))
407 .collect::<Vec<_>>()
408 })
409 .unwrap_or_default();
410
411 let profile_fields = user
412 .map(|resource| {
413 resource
414 .fields
415 .iter()
416 .filter(|(name, field)| {
417 !field.hidden && field.admin.visible && *name != &password_field
418 })
419 .map(|(name, field)| field_manifest(name, field, resource))
420 .collect::<Vec<_>>()
421 })
422 .unwrap_or_default();
423
424 Ok(AdminManifest {
425 title: format!("{app_name} admin"),
426 app_name,
427 logo: app.config.admin.logo.clone(),
428 api_base_url,
429 docs_url,
430 auth: AuthManifest {
431 identity_label: titleize(&identity_field),
432 identity_field,
433 allow_registration: app.config.auth.allow_registration,
434 signup_fields,
435 profile_fields,
436 known_roles: known_roles(app, functions),
437 },
438 resources,
439 functions: loaded_functions,
440 })
441}
442
443fn known_roles(app: &App, functions: &FunctionRegistry) -> Vec<String> {
447 let mut roles: BTreeSet<String> = BTreeSet::new();
448 roles.insert("member".to_string());
451 roles.insert("admin".to_string());
452
453 for resource in app.resources.values() {
454 for access in [
455 &resource.permissions.list,
456 &resource.permissions.read,
457 &resource.permissions.create,
458 &resource.permissions.update,
459 &resource.permissions.delete,
460 ] {
461 if let Access::Role(role) = access {
462 roles.insert(role.clone());
463 }
464 }
465 roles.extend(resource.admin.roles.iter().cloned());
466 }
467 for entry in functions.iter() {
468 if let FunctionAccess::Role(role) = entry.manifest.access() {
469 roles.insert(role);
470 }
471 roles.extend(parse_function_admin(&entry.manifest).roles);
472 }
473 roles.into_iter().collect()
474}
475
476fn resource_manifest(
477 resource: &Resource,
478 password_field: &str,
479 children: Vec<ChildManifest>,
480) -> ResourceManifest {
481 let fields = resource
482 .fields
483 .iter()
484 .map(|(name, field)| field_manifest(name, field, resource))
485 .collect::<Vec<_>>();
486 let relations = resource
487 .references()
488 .into_iter()
489 .filter(|reference| reference.field != "organization_id")
490 .map(|reference| RelationManifest {
491 label: titleize(&reference.relation),
492 field: reference.field,
493 relation: reference.relation,
494 target: reference.target,
495 required: reference.required,
496 })
497 .collect::<Vec<_>>();
498 let org_scoped = resource.is_org_scoped();
499
500 ResourceManifest {
501 label: resource.admin_label(),
502 plural: resource.admin_plural(),
503 group: resource.admin.group.clone(),
504 order: resource.admin.order,
505 builtin: is_builtin_resource(&resource.meta.name),
506 auth_resource: is_auth_resource(&resource.meta.name),
507 visible: resource.admin.is_visible(&resource.meta.name),
508 roles: resource.admin.roles.clone(),
509 scope: if org_scoped { "organization" } else { "global" },
510 owner_field: resource.meta.owner_field.clone(),
511 display_field: resource.admin_display_field(),
512 search_field: resource.admin_search_field(),
513 columns: resource
514 .admin_columns()
515 .into_iter()
516 .filter(|column| column != password_field || resource.meta.name != "user")
519 .collect(),
520 permissions: ActionPermissionsManifest {
521 list: permission_manifest(&resource.permissions.list, org_scoped),
522 read: permission_manifest(&resource.permissions.read, org_scoped),
523 create: permission_manifest(&resource.permissions.create, org_scoped),
524 update: permission_manifest(&resource.permissions.update, org_scoped),
525 delete: permission_manifest(&resource.permissions.delete, org_scoped),
526 },
527 name: resource.meta.name.clone(),
528 fields,
529 relations,
530 children,
531 }
532}
533
534fn is_builtin_resource(name: &str) -> bool {
535 is_auth_resource(name)
536}
537
538fn field_manifest(name: &str, field: &Field, resource: &Resource) -> FieldManifest {
539 let references = field.references.clone();
540 let relation = references.as_ref().map(|_| relation_name(name).to_string());
541 let stamped = name == resource.meta.owner_field || name == "organization_id";
544
545 FieldManifest {
546 label: field
547 .admin
548 .label
549 .clone()
550 .unwrap_or_else(|| titleize(name))
551 .to_string(),
552 ty: field_type_name(field.ty),
553 widget: resolve_widget(field),
554 help: field.admin.help.clone(),
555 placeholder: field.admin.placeholder.clone(),
556 format: field.admin.format.as_str(),
557 options: field
558 .admin
559 .options
560 .iter()
561 .map(|option| match option.split_once('|') {
562 Some((value, label)) => FieldOption {
563 value: value.to_string(),
564 label: label.to_string(),
565 },
566 None => FieldOption {
567 value: option.clone(),
568 label: titleize(option),
569 },
570 })
571 .collect(),
572 required: field.required,
573 unique: field.unique,
574 hidden: field.hidden,
575 admin_visible: field.admin.visible && !field.hidden,
576 readonly: field.admin.readonly,
577 max_length: field.max_length,
578 references,
579 relation,
580 on_delete: field.on_delete.map(on_delete_name),
581 default_value: field.default.clone(),
582 writable: !field.hidden && !field.admin.readonly && !stamped,
583 name: name.to_string(),
584 }
585}
586
587fn resolve_widget(field: &Field) -> &'static str {
590 if field.admin.widget != Widget::Auto {
591 return field.admin.widget.as_str();
592 }
593 if !field.admin.options.is_empty() {
594 return "select";
595 }
596 if field.admin.format != ContentFormat::Plain {
598 return "textarea";
599 }
600 match field.ty {
601 FieldType::Text => "textarea",
602 FieldType::Boolean => "switch",
603 FieldType::Json => "json",
604 FieldType::Timestamp => "date_time",
605 FieldType::Reference => "reference",
606 FieldType::Integer | FieldType::BigInt | FieldType::Float => "number",
607 FieldType::Uuid => "text",
608 FieldType::String => "text",
609 }
610}
611
612fn permission_manifest(access: &Access, org_scoped: bool) -> ActionPermissionManifest {
613 ActionPermissionManifest {
614 value: access_value(access),
615 role: match access {
616 Access::Role(role) => Some(role.clone()),
617 _ => None,
618 },
619 note: access_note(access, org_scoped),
620 requires_org: org_scoped || matches!(access, Access::Role(_) | Access::Member),
621 }
622}
623
624fn parse_function_admin(manifest: &apiplant_abi::FunctionManifest) -> FunctionAdmin {
625 if manifest.admin.is_empty() {
626 return FunctionAdmin::default();
627 }
628 serde_json::from_str(manifest.admin.as_str()).unwrap_or_default()
629}
630
631fn function_manifest(manifest: &apiplant_abi::FunctionManifest) -> FunctionManifest {
632 let access = manifest.access();
633 let admin = parse_function_admin(manifest);
634 let name = manifest.name.to_string();
635 let label = admin.label.unwrap_or_else(|| titleize(&name));
636
637 FunctionManifest {
638 label: label.clone(),
639 description: admin
640 .description
641 .unwrap_or_else(|| manifest.description.to_string()),
642 group: admin.group,
643 order: admin.order.unwrap_or(0),
644 method: method_name(manifest.method),
645 permission: access.as_string(),
646 role: match &access {
647 FunctionAccess::Role(role) => Some(role.clone()),
648 _ => None,
649 },
650 permission_note: function_access_note(&access),
651 requires_org: matches!(access, FunctionAccess::Role(_) | FunctionAccess::Member),
652 visible: admin.visible.unwrap_or(true),
653 roles: admin.roles,
654 confirm: admin.confirm,
655 run_label: admin.run_label.unwrap_or(label),
656 input_schema: parse_schema(manifest.input_schema.as_str()),
657 output_schema: parse_schema(manifest.output_schema.as_str()),
658 name,
659 }
660}
661
662fn parse_schema(raw: &str) -> Option<Value> {
666 if raw.trim().is_empty() {
667 return None;
668 }
669 serde_json::from_str(raw).ok()
670}
671
672fn access_value(access: &Access) -> String {
673 match access {
674 Access::Public => "public".to_string(),
675 Access::Authenticated => "authenticated".to_string(),
676 Access::Member => "member".to_string(),
677 Access::Owner => "owner".to_string(),
678 Access::Role(role) => format!("role:{role}"),
679 Access::Private => "private".to_string(),
680 }
681}
682
683fn access_note(access: &Access, org_scoped: bool) -> String {
684 if org_scoped {
685 return match access {
686 Access::Private => "Not available.".to_string(),
687 Access::Owner => "Limited to records you created.".to_string(),
688 Access::Role(role) => format!("Needs the {role} role."),
689 _ => "Available to everyone in this organization.".to_string(),
690 };
691 }
692
693 match access {
694 Access::Public => "Available to anyone.".to_string(),
695 Access::Authenticated | Access::Member => "Available once you sign in.".to_string(),
696 Access::Owner => "Limited to records you created.".to_string(),
697 Access::Role(role) => format!("Needs the {role} role."),
698 Access::Private => "Not available.".to_string(),
699 }
700}
701
702fn function_access_note(access: &FunctionAccess) -> String {
703 match access {
704 FunctionAccess::Public => "Anyone can run this.".to_string(),
705 FunctionAccess::Authenticated => "Available once you sign in.".to_string(),
706 FunctionAccess::Member => "Available to everyone in this organization.".to_string(),
707 FunctionAccess::Role(role) => format!("Needs the {role} role."),
708 FunctionAccess::Private => "Not available.".to_string(),
709 }
710}
711
712fn field_type_name(ty: FieldType) -> &'static str {
713 match ty {
714 FieldType::String => "string",
715 FieldType::Text => "text",
716 FieldType::Integer => "integer",
717 FieldType::BigInt => "big_int",
718 FieldType::Float => "float",
719 FieldType::Boolean => "boolean",
720 FieldType::Uuid => "uuid",
721 FieldType::Timestamp => "timestamp",
722 FieldType::Json => "json",
723 FieldType::Reference => "reference",
724 }
725}
726
727fn on_delete_name(on_delete: OnDelete) -> &'static str {
728 match on_delete {
729 OnDelete::Restrict => "restrict",
730 OnDelete::SetNull => "set_null",
731 OnDelete::Cascade => "cascade",
732 OnDelete::NoAction => "no_action",
733 }
734}
735
736fn method_name(method: HttpMethod) -> &'static str {
737 match method {
738 HttpMethod::Get => "GET",
739 HttpMethod::Post => "POST",
740 HttpMethod::Put => "PUT",
741 HttpMethod::Delete => "DELETE",
742 }
743}
744
745fn normalize_api_base(raw: &str, base_path: &str, prefer_https: bool) -> Result<String> {
746 let trimmed = raw.trim();
747 if trimmed.is_empty() {
748 bail!("--api requires a domain or full API URL");
749 }
750
751 let mut url = if trimmed.contains("://") {
752 trimmed.to_string()
753 } else {
754 format!(
755 "{}://{}",
756 if prefer_https { "https" } else { "http" },
757 trimmed
758 )
759 };
760
761 if !url.starts_with("http://") && !url.starts_with("https://") {
762 bail!("--api must resolve to an http:// or https:// URL");
763 }
764
765 let scheme_end = url
766 .find("://")
767 .map(|index| index + 3)
768 .ok_or_else(|| anyhow!("invalid API URL"))?;
769
770 match url[scheme_end..].find('/') {
771 None => {
772 if !base_path.is_empty() {
773 url.push_str(base_path);
774 }
775 }
776 Some(relative_start) => {
777 let path_start = scheme_end + relative_start;
778 let path = &url[path_start..];
779 if path == "/" {
780 url.truncate(path_start);
781 if !base_path.is_empty() {
782 url.push_str(base_path);
783 }
784 } else {
785 while url.ends_with('/') {
786 url.pop();
787 }
788 }
789 }
790 }
791
792 while url.ends_with('/') {
793 url.pop();
794 }
795
796 Ok(url)
797}
798
799fn write_bytes(path: PathBuf, bytes: &[u8]) -> Result<()> {
800 fs::write(&path, bytes).with_context(|| format!("failed to write {}", path.display()))
801}
802
803fn write_json(path: PathBuf, manifest: &AdminManifest) -> Result<()> {
804 let bytes = serde_json::to_vec_pretty(manifest)?;
805 fs::write(&path, bytes).with_context(|| format!("failed to write {}", path.display()))
806}
807
808#[cfg(test)]
809mod tests {
810 use super::*;
811 use std::time::{SystemTime, UNIX_EPOCH};
812
813 fn temp_dir(label: &str) -> PathBuf {
814 let mut dir = std::env::temp_dir();
815 let stamp = SystemTime::now()
816 .duration_since(UNIX_EPOCH)
817 .unwrap()
818 .as_nanos();
819 dir.push(format!(
820 "apiplant-admin-{label}-{}-{stamp}",
821 std::process::id()
822 ));
823 fs::create_dir_all(&dir).unwrap();
824 dir
825 }
826
827 fn build_manifest_for(models: &[(&str, &str)]) -> Value {
828 build_manifest_with_config(
829 "[server]\nbase_path = \"/api\"\n\n[auth]\nallow_registration = true\n",
830 models,
831 )
832 }
833
834 fn build_manifest_with_config(main_toml: &str, models: &[(&str, &str)]) -> Value {
835 let app_dir = temp_dir("app");
836 let out_dir = temp_dir("out");
837 fs::create_dir_all(app_dir.join("models")).unwrap();
838 fs::write(app_dir.join("main.toml"), main_toml).unwrap();
839 for (name, src) in models {
840 fs::write(app_dir.join(format!("models/{name}.toml")), src).unwrap();
841 }
842
843 build(
844 &app_dir,
845 Options {
846 api: "https://example.com".to_string(),
847 out: Some(out_dir.clone()),
848 },
849 )
850 .unwrap();
851
852 let manifest: Value =
853 serde_json::from_slice(&fs::read(out_dir.join("apiplant-admin.json")).unwrap())
854 .unwrap();
855 fs::remove_dir_all(app_dir).unwrap();
856 fs::remove_dir_all(out_dir).unwrap();
857 manifest
858 }
859
860 fn resource<'a>(manifest: &'a Value, name: &str) -> &'a Value {
861 manifest["resources"]
862 .as_array()
863 .unwrap()
864 .iter()
865 .find(|resource| resource["name"] == name)
866 .unwrap_or_else(|| panic!("no `{name}` in manifest"))
867 }
868
869 #[test]
872 fn app_name_comes_from_config_and_falls_back_to_the_directory() {
873 let named = build_manifest_with_config(
874 "[app]\nname = \"Acme Logistics\"\n\n[server]\nbase_path = \"/api\"\n",
875 &[],
876 );
877 assert_eq!(named["app_name"], "Acme Logistics");
878 assert_eq!(named["title"], "Acme Logistics admin");
879
880 let blank = build_manifest_with_config(
883 "[app]\nname = \" \"\n\n[server]\nbase_path = \"/api\"\n",
884 &[],
885 );
886 assert!(blank["app_name"]
887 .as_str()
888 .unwrap()
889 .starts_with("apiplant-admin-app-"));
890
891 let unnamed = build_manifest_for(&[]);
892 assert!(unnamed["app_name"]
893 .as_str()
894 .unwrap()
895 .starts_with("apiplant-admin-app-"));
896 }
897
898 #[test]
899 fn api_base_uses_app_base_path_when_only_a_domain_is_given() {
900 assert_eq!(
901 normalize_api_base("admin.example.com", "/api", true).unwrap(),
902 "https://admin.example.com/api"
903 );
904 assert_eq!(
905 normalize_api_base("127.0.0.1:8099", "", false).unwrap(),
906 "http://127.0.0.1:8099"
907 );
908 }
909
910 #[test]
911 fn explicit_api_paths_are_preserved() {
912 assert_eq!(
913 normalize_api_base("https://example.com/custom/", "/api", true).unwrap(),
914 "https://example.com/custom"
915 );
916 assert_eq!(
917 normalize_api_base("https://example.com/", "/api", true).unwrap(),
918 "https://example.com/api"
919 );
920 }
921
922 #[test]
923 fn build_writes_static_admin_files_and_manifest() {
924 let app_dir = temp_dir("files");
925 let out_dir = temp_dir("files-out");
926 fs::create_dir_all(app_dir.join("models")).unwrap();
927 fs::write(
928 app_dir.join("main.toml"),
929 "[server]\nbase_path = \"/api\"\n",
930 )
931 .unwrap();
932
933 let written = build(
934 &app_dir,
935 Options {
936 api: "https://example.com".to_string(),
937 out: Some(out_dir.clone()),
938 },
939 )
940 .unwrap();
941
942 assert_eq!(written, out_dir);
943 for file in [
944 "index.html",
945 "app.js",
946 "app.css",
947 "head.png",
948 "head-inverted.png",
949 "apiplant-admin.json",
950 ] {
951 assert!(out_dir.join(file).exists(), "{file} was not written");
952 }
953
954 fs::remove_dir_all(app_dir).unwrap();
955 fs::remove_dir_all(out_dir).unwrap();
956 }
957
958 #[test]
959 fn auth_resources_are_hidden_from_the_resource_navigation_by_default() {
960 let manifest = build_manifest_for(&[(
961 "post",
962 "[resource]\nname = \"post\"\n\n[fields.title]\ntype = \"string\"\n",
963 )]);
964
965 for name in ["user", "organization", "membership", "api_key"] {
966 let auth = resource(&manifest, name);
967 assert_eq!(auth["visible"], false, "{name} should be hidden");
968 assert_eq!(auth["auth_resource"], true);
969 }
970 assert_eq!(resource(&manifest, "post")["visible"], true);
971 assert_eq!(resource(&manifest, "post")["auth_resource"], false);
972 }
973
974 #[test]
975 fn admin_section_overrides_labels_columns_and_role_visibility() {
976 let manifest = build_manifest_for(&[(
977 "product",
978 r#"
979[resource]
980name = "product"
981
982[admin]
983visible = true
984roles = ["manager"]
985label = "Item"
986plural = "Catalogue items"
987group = "Catalogue"
988order = 3
989display_field = "title"
990columns = ["title", "status"]
991
992[fields.title]
993type = "string"
994required = true
995
996[fields.status]
997type = "string"
998default = "draft"
999
1000[fields.status.admin]
1001label = "Lifecycle"
1002widget = "select"
1003options = ["draft", "active|Live"]
1004help = "Only live items are sold."
1005
1006[fields.internal_note]
1007type = "text"
1008
1009[fields.internal_note.admin]
1010visible = false
1011"#,
1012 )]);
1013
1014 let product = resource(&manifest, "product");
1015 assert_eq!(product["label"], "Item");
1016 assert_eq!(product["plural"], "Catalogue items");
1017 assert_eq!(product["group"], "Catalogue");
1018 assert_eq!(product["order"], 3);
1019 assert_eq!(product["roles"][0], "manager");
1020 assert_eq!(product["display_field"], "title");
1021 assert_eq!(product["columns"][0], "title");
1022 assert_eq!(product["columns"][1], "status");
1023
1024 let field = |name: &str| {
1025 product["fields"]
1026 .as_array()
1027 .unwrap()
1028 .iter()
1029 .find(|field| field["name"] == name)
1030 .unwrap()
1031 };
1032 let status = field("status");
1033 assert_eq!(status["label"], "Lifecycle");
1034 assert_eq!(status["widget"], "select");
1035 assert_eq!(status["help"], "Only live items are sold.");
1036 assert_eq!(status["options"][0]["value"], "draft");
1037 assert_eq!(status["options"][0]["label"], "Draft");
1038 assert_eq!(status["options"][1]["value"], "active");
1040 assert_eq!(status["options"][1]["label"], "Live");
1041
1042 assert_eq!(field("internal_note")["admin_visible"], false);
1044 assert_eq!(field("internal_note")["hidden"], false);
1045
1046 assert_eq!(field("organization_id")["writable"], false);
1048 assert_eq!(field("organization_id")["admin_visible"], false);
1049 }
1050
1051 #[test]
1052 fn content_format_reaches_the_manifest_and_forces_a_textarea() {
1053 let manifest = build_manifest_for(&[(
1054 "article",
1055 r#"
1056[resource]
1057name = "article"
1058
1059[fields.body]
1060type = "text"
1061
1062[fields.body.admin]
1063format = "markdown"
1064
1065[fields.summary]
1066type = "string"
1067
1068[fields.summary.admin]
1069format = "html"
1070
1071[fields.slug]
1072type = "string"
1073"#,
1074 )]);
1075
1076 let article = resource(&manifest, "article");
1077 let field = |name: &str| {
1078 article["fields"]
1079 .as_array()
1080 .unwrap()
1081 .iter()
1082 .find(|field| field["name"] == name)
1083 .unwrap()
1084 .clone()
1085 };
1086
1087 assert_eq!(field("body")["format"], "markdown");
1088 assert_eq!(field("body")["widget"], "textarea");
1089 assert_eq!(field("summary")["format"], "html");
1091 assert_eq!(field("summary")["widget"], "textarea");
1092 assert_eq!(field("slug")["format"], "plain");
1093 assert_eq!(field("slug")["widget"], "text");
1094 }
1095
1096 #[test]
1097 fn labels_and_columns_are_inferred_when_admin_says_nothing() {
1098 let manifest = build_manifest_for(&[(
1099 "purchase_order",
1100 r#"
1101[resource]
1102name = "purchase_order"
1103
1104[fields.name]
1105type = "string"
1106
1107[fields.notes]
1108type = "text"
1109
1110[fields.settings]
1111type = "json"
1112"#,
1113 )]);
1114
1115 let purchase_order = resource(&manifest, "purchase_order");
1116 assert_eq!(purchase_order["label"], "Purchase order");
1117 assert_eq!(purchase_order["plural"], "Purchase orders");
1118 assert_eq!(purchase_order["display_field"], "name");
1119 assert_eq!(purchase_order["search_field"], "name");
1120
1121 let columns: Vec<&str> = purchase_order["columns"]
1124 .as_array()
1125 .unwrap()
1126 .iter()
1127 .map(|column| column.as_str().unwrap())
1128 .collect();
1129 assert_eq!(columns, vec!["name"]);
1130 }
1131
1132 #[test]
1133 fn related_lists_are_derived_from_incoming_references() {
1134 let manifest = build_manifest_for(&[
1135 (
1136 "order",
1137 "[resource]\nname = \"order\"\n\n[fields.number]\ntype = \"string\"\n",
1138 ),
1139 (
1140 "order_line",
1141 r#"
1142[resource]
1143name = "order_line"
1144
1145[fields.order_id]
1146type = "reference"
1147references = "order"
1148required = true
1149
1150[fields.quantity]
1151type = "integer"
1152"#,
1153 ),
1154 ]);
1155
1156 let order = resource(&manifest, "order");
1157 let children = order["children"].as_array().unwrap();
1158 assert_eq!(children.len(), 1);
1159 assert_eq!(children[0]["resource"], "order_line");
1160 assert_eq!(children[0]["field"], "order_id");
1161 assert_eq!(children[0]["label"], "Order lines");
1162
1163 let line = resource(&manifest, "order_line");
1165 let relation = line["relations"]
1166 .as_array()
1167 .unwrap()
1168 .iter()
1169 .find(|relation| relation["field"] == "order_id")
1170 .unwrap();
1171 assert_eq!(relation["target"], "order");
1172 assert_eq!(relation["label"], "Order");
1173 assert_eq!(relation["required"], true);
1174 }
1175
1176 #[test]
1177 fn known_roles_collect_every_role_the_app_names() {
1178 let manifest = build_manifest_for(&[(
1179 "product",
1180 r#"
1181[resource]
1182name = "product"
1183
1184[permissions]
1185create = "role:buyer"
1186delete = "role:auditor"
1187
1188[fields.name]
1189type = "string"
1190"#,
1191 )]);
1192
1193 let roles: Vec<&str> = manifest["auth"]["known_roles"]
1194 .as_array()
1195 .unwrap()
1196 .iter()
1197 .map(|role| role.as_str().unwrap())
1198 .collect();
1199 assert!(roles.contains(&"buyer"));
1200 assert!(roles.contains(&"auditor"));
1201 assert!(roles.contains(&"admin"));
1203 assert!(roles.contains(&"member"));
1204 }
1205
1206 #[test]
1207 fn signup_collects_required_profile_fields_so_nobody_types_json() {
1208 let manifest = build_manifest_for(&[(
1209 "user",
1210 r#"
1211[resource]
1212name = "user"
1213scope = "global"
1214
1215[auth]
1216identity_field = "email"
1217password_field = "password_hash"
1218
1219[fields.email]
1220type = "string"
1221required = true
1222unique = true
1223
1224[fields.password_hash]
1225type = "string"
1226hidden = true
1227
1228[fields.full_name]
1229type = "string"
1230required = true
1231
1232[fields.nickname]
1233type = "string"
1234"#,
1235 )]);
1236
1237 let signup: Vec<&str> = manifest["auth"]["signup_fields"]
1238 .as_array()
1239 .unwrap()
1240 .iter()
1241 .map(|field| field["name"].as_str().unwrap())
1242 .collect();
1243 assert_eq!(signup, vec!["full_name"]);
1246 assert_eq!(manifest["auth"]["identity_label"], "Email");
1247
1248 let profile: Vec<&str> = manifest["auth"]["profile_fields"]
1251 .as_array()
1252 .unwrap()
1253 .iter()
1254 .map(|field| field["name"].as_str().unwrap())
1255 .collect();
1256 assert!(profile.contains(&"nickname"));
1257 assert!(!profile.contains(&"password_hash"));
1258 }
1259}