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::{Agent, App};
34use serde::Serialize;
35use serde_json::Value;
36
37use crate::auth_routes::VERIFIED_AT_FIELD;
38use crate::functions::FunctionRegistry;
39
40pub const MANIFEST_FILE: &str = "apiplant-admin.json";
42
43#[derive(Debug, Clone)]
44pub struct Options {
45 pub api: String,
46 pub out: Option<PathBuf>,
47}
48
49#[derive(Debug, Serialize)]
50struct AdminManifest {
51 title: String,
52 app_name: String,
53 logo: Option<String>,
55 api_base_url: String,
56 docs_url: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
60 ai_assistance: Option<AdminAiAssistanceManifest>,
61 auth: AuthManifest,
62 resources: Vec<ResourceManifest>,
63 functions: Vec<FunctionManifest>,
64 agents: Vec<AgentManifest>,
65 #[serde(skip_serializing_if = "Option::is_none")]
69 billing: Option<BillingManifest>,
70}
71
72#[derive(Debug, Serialize)]
74struct BillingManifest {
75 provider: String,
77 publishable_key: String,
79 currency: String,
81 automatic_tax: bool,
85 tax_id_collection: bool,
87 webhooks_configured: bool,
91}
92
93#[derive(Debug, Serialize)]
94struct AdminAiAssistanceManifest {
95 prompt_placeholder: String,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 system: Option<String>,
98}
99
100#[derive(Debug, Serialize)]
101struct AuthManifest {
102 identity_field: String,
104 identity_label: String,
105 allow_registration: bool,
106 email_enabled: bool,
111 require_email_verification: bool,
115 invitations_enabled: bool,
117 password_reset_enabled: bool,
119 signup_fields: Vec<FieldManifest>,
122 profile_fields: Vec<FieldManifest>,
124 known_roles: Vec<String>,
127}
128
129#[derive(Debug, Serialize)]
130struct ResourceManifest {
131 name: String,
132 label: String,
134 plural: String,
136 group: Option<String>,
138 order: i64,
139 builtin: bool,
140 auth_resource: bool,
143 visible: bool,
145 roles: Vec<String>,
147 scope: &'static str,
148 owner_field: String,
149 display_field: Option<String>,
151 search_field: Option<String>,
153 columns: Vec<String>,
155 fields: Vec<FieldManifest>,
156 relations: Vec<RelationManifest>,
158 children: Vec<ChildManifest>,
160 permissions: ActionPermissionsManifest,
161}
162
163#[derive(Debug, Serialize)]
164struct ActionPermissionsManifest {
165 list: ActionPermissionManifest,
166 read: ActionPermissionManifest,
167 create: ActionPermissionManifest,
168 update: ActionPermissionManifest,
169 delete: ActionPermissionManifest,
170}
171
172#[derive(Debug, Serialize)]
173struct ActionPermissionManifest {
174 value: String,
175 role: Option<String>,
177 note: String,
178 requires_org: bool,
179}
180
181#[derive(Debug, Serialize)]
182struct FieldManifest {
183 name: String,
184 label: String,
185 #[serde(rename = "type")]
186 ty: &'static str,
187 widget: &'static str,
189 help: Option<String>,
190 placeholder: Option<String>,
191 format: &'static str,
194 options: Vec<FieldOption>,
195 required: bool,
196 unique: bool,
197 hidden: bool,
199 admin_visible: bool,
201 readonly: bool,
202 max_length: Option<u32>,
203 references: Option<String>,
204 relation: Option<String>,
205 on_delete: Option<&'static str>,
206 default_value: Option<Value>,
207 writable: bool,
209}
210
211#[derive(Debug, Serialize)]
212struct FieldOption {
213 value: String,
214 label: String,
215}
216
217#[derive(Debug, Serialize)]
218struct RelationManifest {
219 field: String,
220 relation: String,
221 target: String,
222 label: String,
224 required: bool,
225}
226
227#[derive(Debug, Serialize)]
231struct ChildManifest {
232 resource: String,
233 field: String,
235 label: String,
236}
237
238#[derive(Debug, Serialize)]
239struct FunctionManifest {
240 name: String,
241 label: String,
242 description: String,
243 group: Option<String>,
244 order: i64,
245 method: &'static str,
246 permission: String,
248 role: Option<String>,
249 permission_note: String,
250 requires_org: bool,
251 visible: bool,
253 roles: Vec<String>,
254 confirm: Option<String>,
256 run_label: String,
257 input_schema: Option<Value>,
259 output_schema: Option<Value>,
260}
261
262#[derive(Debug, Serialize)]
263struct AgentManifest {
264 name: String,
265 label: String,
266 description: String,
267 scope: &'static str,
268 storage: bool,
269 thread_resource: Option<String>,
270 message_resource: Option<String>,
271 chat: ActionPermissionManifest,
272 history: ActionPermissionManifest,
273 delete_history: ActionPermissionManifest,
274}
275
276#[derive(Debug, Default, serde::Deserialize)]
278#[serde(default)]
279struct FunctionAdmin {
280 visible: Option<bool>,
281 roles: Vec<String>,
282 label: Option<String>,
283 group: Option<String>,
284 description: Option<String>,
285 confirm: Option<String>,
286 run_label: Option<String>,
287 order: Option<i64>,
288}
289
290pub fn build(app_dir: &Path, options: Options) -> Result<PathBuf> {
291 let app = App::load(app_dir)?;
292 let api_base_url = normalize_api_base(
293 &options.api,
294 &app.config.server.base_path,
295 app.tls.is_some(),
296 )?;
297 let output_dir = options.out.unwrap_or_else(|| app_dir.join("admin"));
298 let registry = FunctionRegistry::load(&app);
299 let email_enabled = apiplant_email::Mailer::from_config(&app.config.email)
302 .map(|mailer| mailer.is_some())
303 .unwrap_or(false);
304 let manifest = build_manifest(&app, ®istry, api_base_url.clone(), email_enabled)?;
305
306 fs::create_dir_all(&output_dir)
307 .with_context(|| format!("failed to create {}", output_dir.display()))?;
308
309 for (relative, _) in apiplant_assets::ADMIN {
310 let path = output_dir.join(relative);
311 if let Some(parent) = path.parent() {
312 fs::create_dir_all(parent)
313 .with_context(|| format!("failed to create {}", parent.display()))?;
314 }
315 let bytes = asset(relative).expect("listed asset");
316 write_bytes(path, &bytes)?;
317 }
318 write_json(output_dir.join(MANIFEST_FILE), &manifest)?;
319
320 Ok(output_dir)
321}
322
323pub fn asset(path: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
329 use std::borrow::Cow;
330
331 let bytes = apiplant_assets::find(apiplant_assets::ADMIN, path)?;
332 if path.trim_matches('/') == "app.css" {
333 let css = String::from_utf8_lossy(bytes)
334 .replace("url(/head.png)", "url(./head.png)")
335 .replace("url(/head-inverted.png)", "url(./head-inverted.png)");
336 return Some(Cow::Owned(css.into_bytes()));
337 }
338 Some(Cow::Borrowed(bytes))
339}
340
341pub fn manifest_json(
346 app: &App,
347 functions: &FunctionRegistry,
348 api_base_url: String,
349 email_enabled: bool,
350) -> Result<String> {
351 let manifest = build_manifest(app, functions, api_base_url, email_enabled)?;
352 Ok(serde_json::to_string(&manifest)?)
353}
354
355fn build_manifest(
356 app: &App,
357 functions: &FunctionRegistry,
358 api_base_url: String,
359 email_enabled: bool,
360) -> Result<AdminManifest> {
361 let app_name = app.display_name();
362 let user = app.resources.get("user");
363 let identity_field = user
364 .and_then(|resource| resource.auth.as_ref())
365 .map(|auth| auth.identity_field.clone())
366 .unwrap_or_else(|| "email".to_string());
367 let password_field = user
368 .and_then(|resource| resource.auth.as_ref())
369 .map(|auth| auth.password_field.clone())
370 .unwrap_or_else(|| "password_hash".to_string());
371 let docs_url = if app.config.docs.enabled {
372 Some(format!("{}{}", api_base_url, app.config.docs.path))
373 } else {
374 None
375 };
376
377 let hook_functions = app
381 .resources
382 .values()
383 .flat_map(|resource| {
384 resource
385 .hooks
386 .iter()
387 .map(|(_, function)| function.to_string())
388 })
389 .collect::<BTreeSet<_>>();
390
391 let mut children: BTreeMap<String, Vec<ChildManifest>> = BTreeMap::new();
394 for child in app.resources.values() {
395 let references = child.references();
396 for reference in &references {
397 if reference.field == "organization_id" {
401 continue;
402 }
403 if !app.resources.contains_key(&reference.target) {
404 continue;
405 }
406 let ambiguous = references
410 .iter()
411 .filter(|other| other.target == reference.target)
412 .count()
413 > 1;
414 let label = if ambiguous {
415 format!(
416 "{} ({})",
417 child.admin_plural(),
418 titleize(&reference.relation).to_lowercase()
419 )
420 } else {
421 child.admin_plural()
422 };
423 children
424 .entry(reference.target.clone())
425 .or_default()
426 .push(ChildManifest {
427 resource: child.meta.name.clone(),
428 field: reference.field.clone(),
429 label,
430 });
431 }
432 }
433
434 let resources = app
435 .resources
436 .values()
437 .map(|resource| {
438 resource_manifest(
439 resource,
440 &password_field,
441 children
442 .remove(resource.meta.name.as_str())
443 .unwrap_or_default(),
444 )
445 })
446 .collect::<Vec<_>>();
447
448 let mut loaded_functions = functions
449 .iter()
450 .filter(|entry| !hook_functions.contains(entry.manifest.name.as_str()))
451 .map(|entry| function_manifest(&entry.manifest))
452 .filter(|manifest| manifest.permission != "private")
455 .collect::<Vec<_>>();
456 loaded_functions.sort_by(|left, right| {
457 left.group
458 .cmp(&right.group)
459 .then(left.order.cmp(&right.order))
460 .then(left.label.cmp(&right.label))
461 });
462
463 let mut agents = app.agents.values().map(agent_manifest).collect::<Vec<_>>();
464 agents.sort_by(|left, right| left.label.cmp(&right.label).then(left.name.cmp(&right.name)));
465
466 let signup_fields = user
467 .map(|resource| {
468 resource
469 .fields
470 .iter()
471 .filter(|(name, field)| {
472 *name != &identity_field
479 && *name != &password_field
480 && field.admin.in_signup(field)
481 && !field.hidden
482 && field.admin.visible
483 && name.as_str() != "organization_id"
484 && name.as_str() != VERIFIED_AT_FIELD
485 })
486 .map(|(name, field)| field_manifest(name, field, resource))
487 .collect::<Vec<_>>()
488 })
489 .unwrap_or_default();
490
491 let profile_fields = user
492 .map(|resource| {
493 resource
494 .fields
495 .iter()
496 .filter(|(name, field)| {
497 !field.hidden && field.admin.visible && *name != &password_field
498 })
499 .map(|(name, field)| field_manifest(name, field, resource))
500 .collect::<Vec<_>>()
501 })
502 .unwrap_or_default();
503
504 Ok(AdminManifest {
505 title: format!("{app_name} admin"),
506 app_name,
507 logo: app.config.admin.logo.clone(),
508 api_base_url,
509 docs_url,
510 ai_assistance: admin_ai_assistance_manifest(app),
511 auth: AuthManifest {
512 identity_label: titleize(&identity_field),
513 identity_field,
514 allow_registration: app.config.auth.allow_registration,
515 email_enabled,
516 require_email_verification: app.config.auth.requires_email_verification(email_enabled),
517 invitations_enabled: app.config.auth.invitations_enabled(email_enabled),
518 password_reset_enabled: app.config.auth.password_reset_enabled(email_enabled),
519 signup_fields,
520 profile_fields,
521 known_roles: known_roles(app, functions),
522 },
523 resources,
524 functions: loaded_functions,
525 agents,
526 billing: billing_manifest(app),
527 })
528}
529
530fn admin_ai_assistance_manifest(app: &App) -> Option<AdminAiAssistanceManifest> {
531 let assistance = &app.config.admin.ai_assistance;
532 (app.config.ai.enabled() && assistance.enabled).then(|| AdminAiAssistanceManifest {
533 prompt_placeholder: assistance.prompt_placeholder.trim().to_string(),
534 system: (!assistance.system.trim().is_empty()).then(|| assistance.system.trim().to_string()),
535 })
536}
537
538fn billing_manifest(app: &App) -> Option<BillingManifest> {
551 let payments = &app.config.payments;
552 payments.enabled().then(|| BillingManifest {
553 provider: payments.provider.trim().to_ascii_lowercase(),
554 publishable_key: payments.publishable_key.trim().to_string(),
555 currency: payments.default_currency(),
556 automatic_tax: payments.automatic_tax,
557 tax_id_collection: payments.collects_tax_ids(),
558 webhooks_configured: payments.webhooks_enabled(),
559 })
560}
561
562fn known_roles(app: &App, functions: &FunctionRegistry) -> Vec<String> {
563 let mut roles: BTreeSet<String> = BTreeSet::new();
564 roles.insert("member".to_string());
567 roles.insert("admin".to_string());
568
569 for resource in app.resources.values() {
570 for access in [
571 &resource.permissions.list,
572 &resource.permissions.read,
573 &resource.permissions.create,
574 &resource.permissions.update,
575 &resource.permissions.delete,
576 ] {
577 if let Access::Role(role) = access {
578 roles.insert(role.clone());
579 }
580 }
581 roles.extend(resource.admin.roles.iter().cloned());
582 }
583 for entry in functions.iter() {
584 if let FunctionAccess::Role(role) = entry.manifest.access() {
585 roles.insert(role);
586 }
587 roles.extend(parse_function_admin(&entry.manifest).roles);
588 }
589 for agent in app.agents.values() {
590 for access in [
591 &agent.permissions.chat,
592 &agent.permissions.history,
593 &agent.permissions.delete_history,
594 ] {
595 if let Access::Role(role) = access {
596 roles.insert(role.clone());
597 }
598 }
599 }
600 roles.into_iter().collect()
601}
602
603fn agent_manifest(agent: &Agent) -> AgentManifest {
604 let org_scoped = agent.meta.scope == apiplant_core::Scope::Organization;
605 AgentManifest {
606 name: agent.meta.name.clone(),
607 label: agent.label(),
608 description: agent.meta.description.clone(),
609 scope: if org_scoped { "organization" } else { "global" },
610 storage: agent.meta.storage.enabled,
611 thread_resource: agent
612 .meta
613 .storage
614 .enabled
615 .then(|| agent.thread_resource_name()),
616 message_resource: agent
617 .meta
618 .storage
619 .enabled
620 .then(|| agent.message_resource_name()),
621 chat: permission_manifest(&agent.permissions.chat, org_scoped),
622 history: permission_manifest(&agent.permissions.history, org_scoped),
623 delete_history: permission_manifest(&agent.permissions.delete_history, org_scoped),
624 }
625}
626
627fn resource_manifest(
628 resource: &Resource,
629 password_field: &str,
630 children: Vec<ChildManifest>,
631) -> ResourceManifest {
632 let fields = resource
633 .fields
634 .iter()
635 .map(|(name, field)| field_manifest(name, field, resource))
636 .collect::<Vec<_>>();
637 let relations = resource
638 .references()
639 .into_iter()
640 .filter(|reference| reference.field != "organization_id")
641 .map(|reference| RelationManifest {
642 label: titleize(&reference.relation),
643 field: reference.field,
644 relation: reference.relation,
645 target: reference.target,
646 required: reference.required,
647 })
648 .collect::<Vec<_>>();
649 let org_scoped = resource.is_org_scoped();
650
651 ResourceManifest {
652 label: resource.admin_label(),
653 plural: resource.admin_plural(),
654 group: resource.admin.group.clone(),
655 order: resource.admin.order,
656 builtin: is_builtin_resource(&resource.meta.name),
657 auth_resource: is_auth_resource(&resource.meta.name),
658 visible: resource.admin.is_visible(&resource.meta.name),
659 roles: resource.admin.roles.clone(),
660 scope: if org_scoped { "organization" } else { "global" },
661 owner_field: resource.meta.owner_field.clone(),
662 display_field: resource.admin_display_field(),
663 search_field: resource.admin_search_field(),
664 columns: resource
665 .admin_columns()
666 .into_iter()
667 .filter(|column| column != password_field || resource.meta.name != "user")
670 .collect(),
671 permissions: ActionPermissionsManifest {
672 list: permission_manifest(&resource.permissions.list, org_scoped),
673 read: permission_manifest(&resource.permissions.read, org_scoped),
674 create: permission_manifest(&resource.permissions.create, org_scoped),
675 update: permission_manifest(&resource.permissions.update, org_scoped),
676 delete: permission_manifest(&resource.permissions.delete, org_scoped),
677 },
678 name: resource.meta.name.clone(),
679 fields,
680 relations,
681 children,
682 }
683}
684
685fn is_builtin_resource(name: &str) -> bool {
686 is_auth_resource(name)
687}
688
689fn field_manifest(name: &str, field: &Field, resource: &Resource) -> FieldManifest {
690 let references = field.references.clone();
691 let relation = references.as_ref().map(|_| relation_name(name).to_string());
692 let stamped = name == resource.meta.owner_field || name == "organization_id";
695
696 FieldManifest {
697 label: field
698 .admin
699 .label
700 .clone()
701 .unwrap_or_else(|| titleize(name))
702 .to_string(),
703 ty: field_type_name(field.ty),
704 widget: resolve_widget(field),
705 help: field.admin.help.clone(),
706 placeholder: field.admin.placeholder.clone(),
707 format: field.admin.format.as_str(),
708 options: field
709 .admin
710 .options
711 .iter()
712 .map(|option| match option.split_once('|') {
713 Some((value, label)) => FieldOption {
714 value: value.to_string(),
715 label: label.to_string(),
716 },
717 None => FieldOption {
718 value: option.clone(),
719 label: titleize(option),
720 },
721 })
722 .collect(),
723 required: field.required,
724 unique: field.unique,
725 hidden: field.hidden,
726 admin_visible: field.admin.visible && !field.hidden,
727 readonly: field.admin.readonly,
728 max_length: field.max_length,
729 references,
730 relation,
731 on_delete: field.on_delete.map(on_delete_name),
732 default_value: field.default.clone(),
733 writable: !field.hidden && !field.admin.readonly && !stamped,
734 name: name.to_string(),
735 }
736}
737
738fn resolve_widget(field: &Field) -> &'static str {
741 if field.admin.widget != Widget::Auto {
742 return field.admin.widget.as_str();
743 }
744 if !field.admin.options.is_empty() {
745 return "select";
746 }
747 if field.admin.format != ContentFormat::Plain {
749 return "textarea";
750 }
751 match field.ty {
752 FieldType::Text => "textarea",
753 FieldType::Boolean => "switch",
754 FieldType::Json => "json",
755 FieldType::Timestamp => "date_time",
756 FieldType::Reference => "reference",
757 FieldType::Integer | FieldType::BigInt | FieldType::Float => "number",
758 FieldType::Uuid => "text",
759 FieldType::String => "text",
760 }
761}
762
763fn permission_manifest(access: &Access, org_scoped: bool) -> ActionPermissionManifest {
764 ActionPermissionManifest {
765 value: access_value(access),
766 role: match access {
767 Access::Role(role) => Some(role.clone()),
768 _ => None,
769 },
770 note: access_note(access, org_scoped),
771 requires_org: org_scoped || matches!(access, Access::Role(_) | Access::Member),
772 }
773}
774
775fn parse_function_admin(manifest: &apiplant_abi::FunctionManifest) -> FunctionAdmin {
776 if manifest.admin.is_empty() {
777 return FunctionAdmin::default();
778 }
779 serde_json::from_str(manifest.admin.as_str()).unwrap_or_default()
780}
781
782fn function_manifest(manifest: &apiplant_abi::FunctionManifest) -> FunctionManifest {
783 let access = manifest.access();
784 let admin = parse_function_admin(manifest);
785 let name = manifest.name.to_string();
786 let label = admin.label.unwrap_or_else(|| titleize(&name));
787
788 FunctionManifest {
789 label: label.clone(),
790 description: admin
791 .description
792 .unwrap_or_else(|| manifest.description.to_string()),
793 group: admin.group,
794 order: admin.order.unwrap_or(0),
795 method: method_name(manifest.method),
796 permission: access.as_string(),
797 role: match &access {
798 FunctionAccess::Role(role) => Some(role.clone()),
799 _ => None,
800 },
801 permission_note: function_access_note(&access),
802 requires_org: matches!(access, FunctionAccess::Role(_) | FunctionAccess::Member),
803 visible: admin.visible.unwrap_or(true),
804 roles: admin.roles,
805 confirm: admin.confirm,
806 run_label: admin.run_label.unwrap_or(label),
807 input_schema: parse_schema(manifest.input_schema.as_str()),
808 output_schema: parse_schema(manifest.output_schema.as_str()),
809 name,
810 }
811}
812
813fn parse_schema(raw: &str) -> Option<Value> {
817 if raw.trim().is_empty() {
818 return None;
819 }
820 serde_json::from_str(raw).ok()
821}
822
823fn access_value(access: &Access) -> String {
824 match access {
825 Access::Public => "public".to_string(),
826 Access::Authenticated => "authenticated".to_string(),
827 Access::Member => "member".to_string(),
828 Access::Owner => "owner".to_string(),
829 Access::Role(role) => format!("role:{role}"),
830 Access::Private => "private".to_string(),
831 }
832}
833
834fn access_note(access: &Access, org_scoped: bool) -> String {
835 if org_scoped {
836 return match access {
837 Access::Private => "Not available.".to_string(),
838 Access::Owner => "Limited to records you created.".to_string(),
839 Access::Role(role) => format!("Needs the {role} role."),
840 _ => "Available to everyone in this organization.".to_string(),
841 };
842 }
843
844 match access {
845 Access::Public => "Available to anyone.".to_string(),
846 Access::Authenticated | Access::Member => "Available once you sign in.".to_string(),
847 Access::Owner => "Limited to records you created.".to_string(),
848 Access::Role(role) => format!("Needs the {role} role."),
849 Access::Private => "Not available.".to_string(),
850 }
851}
852
853fn function_access_note(access: &FunctionAccess) -> String {
854 match access {
855 FunctionAccess::Public => "Anyone can run this.".to_string(),
856 FunctionAccess::Authenticated => "Available once you sign in.".to_string(),
857 FunctionAccess::Member => "Available to everyone in this organization.".to_string(),
858 FunctionAccess::Role(role) => format!("Needs the {role} role."),
859 FunctionAccess::Private => "Not available.".to_string(),
860 }
861}
862
863fn field_type_name(ty: FieldType) -> &'static str {
864 match ty {
865 FieldType::String => "string",
866 FieldType::Text => "text",
867 FieldType::Integer => "integer",
868 FieldType::BigInt => "big_int",
869 FieldType::Float => "float",
870 FieldType::Boolean => "boolean",
871 FieldType::Uuid => "uuid",
872 FieldType::Timestamp => "timestamp",
873 FieldType::Json => "json",
874 FieldType::Reference => "reference",
875 }
876}
877
878fn on_delete_name(on_delete: OnDelete) -> &'static str {
879 match on_delete {
880 OnDelete::Restrict => "restrict",
881 OnDelete::SetNull => "set_null",
882 OnDelete::Cascade => "cascade",
883 OnDelete::NoAction => "no_action",
884 }
885}
886
887fn method_name(method: HttpMethod) -> &'static str {
888 match method {
889 HttpMethod::Get => "GET",
890 HttpMethod::Post => "POST",
891 HttpMethod::Put => "PUT",
892 HttpMethod::Delete => "DELETE",
893 }
894}
895
896fn normalize_api_base(raw: &str, base_path: &str, prefer_https: bool) -> Result<String> {
897 let trimmed = raw.trim();
898 if trimmed.is_empty() {
899 bail!("--api requires a domain or full API URL");
900 }
901
902 let mut url = if trimmed.contains("://") {
903 trimmed.to_string()
904 } else {
905 format!(
906 "{}://{}",
907 if prefer_https { "https" } else { "http" },
908 trimmed
909 )
910 };
911
912 if !url.starts_with("http://") && !url.starts_with("https://") {
913 bail!("--api must resolve to an http:// or https:// URL");
914 }
915
916 let scheme_end = url
917 .find("://")
918 .map(|index| index + 3)
919 .ok_or_else(|| anyhow!("invalid API URL"))?;
920
921 match url[scheme_end..].find('/') {
922 None => {
923 if !base_path.is_empty() {
924 url.push_str(base_path);
925 }
926 }
927 Some(relative_start) => {
928 let path_start = scheme_end + relative_start;
929 let path = &url[path_start..];
930 if path == "/" {
931 url.truncate(path_start);
932 if !base_path.is_empty() {
933 url.push_str(base_path);
934 }
935 } else {
936 while url.ends_with('/') {
937 url.pop();
938 }
939 }
940 }
941 }
942
943 while url.ends_with('/') {
944 url.pop();
945 }
946
947 Ok(url)
948}
949
950fn write_bytes(path: PathBuf, bytes: &[u8]) -> Result<()> {
951 fs::write(&path, bytes).with_context(|| format!("failed to write {}", path.display()))
952}
953
954fn write_json(path: PathBuf, manifest: &AdminManifest) -> Result<()> {
955 let bytes = serde_json::to_vec_pretty(manifest)?;
956 fs::write(&path, bytes).with_context(|| format!("failed to write {}", path.display()))
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use std::time::{SystemTime, UNIX_EPOCH};
963
964 fn temp_dir(label: &str) -> PathBuf {
965 let mut dir = std::env::temp_dir();
966 let stamp = SystemTime::now()
967 .duration_since(UNIX_EPOCH)
968 .unwrap()
969 .as_nanos();
970 dir.push(format!(
971 "apiplant-admin-{label}-{}-{stamp}",
972 std::process::id()
973 ));
974 fs::create_dir_all(&dir).unwrap();
975 dir
976 }
977
978 fn build_manifest_for(models: &[(&str, &str)]) -> Value {
979 build_manifest_with_config(
980 "[server]\nbase_path = \"/api\"\n\n[auth]\nallow_registration = true\n",
981 models,
982 )
983 }
984
985 fn build_manifest_with_config(main_toml: &str, models: &[(&str, &str)]) -> Value {
986 let app_dir = temp_dir("app");
987 let out_dir = temp_dir("out");
988 fs::create_dir_all(app_dir.join("models")).unwrap();
989 fs::write(app_dir.join("main.toml"), main_toml).unwrap();
990 for (name, src) in models {
991 fs::write(app_dir.join(format!("models/{name}.toml")), src).unwrap();
992 }
993
994 build(
995 &app_dir,
996 Options {
997 api: "https://example.com".to_string(),
998 out: Some(out_dir.clone()),
999 },
1000 )
1001 .unwrap();
1002
1003 let manifest: Value =
1004 serde_json::from_slice(&fs::read(out_dir.join("apiplant-admin.json")).unwrap())
1005 .unwrap();
1006 fs::remove_dir_all(app_dir).unwrap();
1007 fs::remove_dir_all(out_dir).unwrap();
1008 manifest
1009 }
1010
1011 fn resource<'a>(manifest: &'a Value, name: &str) -> &'a Value {
1012 manifest["resources"]
1013 .as_array()
1014 .unwrap()
1015 .iter()
1016 .find(|resource| resource["name"] == name)
1017 .unwrap_or_else(|| panic!("no `{name}` in manifest"))
1018 }
1019
1020 #[test]
1023 fn app_name_comes_from_config_and_falls_back_to_the_directory() {
1024 let named = build_manifest_with_config(
1025 "[app]\nname = \"Acme Logistics\"\n\n[server]\nbase_path = \"/api\"\n",
1026 &[],
1027 );
1028 assert_eq!(named["app_name"], "Acme Logistics");
1029 assert_eq!(named["title"], "Acme Logistics admin");
1030
1031 let blank = build_manifest_with_config(
1034 "[app]\nname = \" \"\n\n[server]\nbase_path = \"/api\"\n",
1035 &[],
1036 );
1037 assert!(blank["app_name"]
1038 .as_str()
1039 .unwrap()
1040 .starts_with("apiplant-admin-app-"));
1041
1042 let unnamed = build_manifest_for(&[]);
1043 assert!(unnamed["app_name"]
1044 .as_str()
1045 .unwrap()
1046 .starts_with("apiplant-admin-app-"));
1047 }
1048
1049 #[test]
1050 fn api_base_uses_app_base_path_when_only_a_domain_is_given() {
1051 assert_eq!(
1052 normalize_api_base("admin.example.com", "/api", true).unwrap(),
1053 "https://admin.example.com/api"
1054 );
1055 assert_eq!(
1056 normalize_api_base("127.0.0.1:8099", "", false).unwrap(),
1057 "http://127.0.0.1:8099"
1058 );
1059 }
1060
1061 #[test]
1062 fn explicit_api_paths_are_preserved() {
1063 assert_eq!(
1064 normalize_api_base("https://example.com/custom/", "/api", true).unwrap(),
1065 "https://example.com/custom"
1066 );
1067 assert_eq!(
1068 normalize_api_base("https://example.com/", "/api", true).unwrap(),
1069 "https://example.com/api"
1070 );
1071 }
1072
1073 #[test]
1074 fn build_writes_static_admin_files_and_manifest() {
1075 let app_dir = temp_dir("files");
1076 let out_dir = temp_dir("files-out");
1077 fs::create_dir_all(app_dir.join("models")).unwrap();
1078 fs::write(
1079 app_dir.join("main.toml"),
1080 "[server]\nbase_path = \"/api\"\n",
1081 )
1082 .unwrap();
1083
1084 let written = build(
1085 &app_dir,
1086 Options {
1087 api: "https://example.com".to_string(),
1088 out: Some(out_dir.clone()),
1089 },
1090 )
1091 .unwrap();
1092
1093 assert_eq!(written, out_dir);
1094 for file in [
1095 "index.html",
1096 "app.js",
1097 "app.css",
1098 "head.png",
1099 "head-inverted.png",
1100 "apiplant-admin.json",
1101 ] {
1102 assert!(out_dir.join(file).exists(), "{file} was not written");
1103 }
1104
1105 fs::remove_dir_all(app_dir).unwrap();
1106 fs::remove_dir_all(out_dir).unwrap();
1107 }
1108
1109 #[test]
1110 fn auth_resources_are_hidden_from_the_resource_navigation_by_default() {
1111 let manifest = build_manifest_for(&[(
1112 "post",
1113 "[resource]\nname = \"post\"\n\n[fields.title]\ntype = \"string\"\n",
1114 )]);
1115
1116 for name in ["user", "organization", "membership", "api_key"] {
1117 let auth = resource(&manifest, name);
1118 assert_eq!(auth["visible"], false, "{name} should be hidden");
1119 assert_eq!(auth["auth_resource"], true);
1120 }
1121 assert_eq!(resource(&manifest, "post")["visible"], true);
1122 assert_eq!(resource(&manifest, "post")["auth_resource"], false);
1123 }
1124
1125 #[test]
1126 fn admin_section_overrides_labels_columns_and_role_visibility() {
1127 let manifest = build_manifest_for(&[(
1128 "product",
1129 r#"
1130[resource]
1131name = "product"
1132
1133[admin]
1134visible = true
1135roles = ["manager"]
1136label = "Item"
1137plural = "Catalogue items"
1138group = "Catalogue"
1139order = 3
1140display_field = "title"
1141columns = ["title", "status"]
1142
1143[fields.title]
1144type = "string"
1145required = true
1146
1147[fields.status]
1148type = "string"
1149default = "draft"
1150
1151[fields.status.admin]
1152label = "Lifecycle"
1153widget = "select"
1154options = ["draft", "active|Live"]
1155help = "Only live items are sold."
1156
1157[fields.internal_note]
1158type = "text"
1159
1160[fields.internal_note.admin]
1161visible = false
1162"#,
1163 )]);
1164
1165 let product = resource(&manifest, "product");
1166 assert_eq!(product["label"], "Item");
1167 assert_eq!(product["plural"], "Catalogue items");
1168 assert_eq!(product["group"], "Catalogue");
1169 assert_eq!(product["order"], 3);
1170 assert_eq!(product["roles"][0], "manager");
1171 assert_eq!(product["display_field"], "title");
1172 assert_eq!(product["columns"][0], "title");
1173 assert_eq!(product["columns"][1], "status");
1174
1175 let field = |name: &str| {
1176 product["fields"]
1177 .as_array()
1178 .unwrap()
1179 .iter()
1180 .find(|field| field["name"] == name)
1181 .unwrap()
1182 };
1183 let status = field("status");
1184 assert_eq!(status["label"], "Lifecycle");
1185 assert_eq!(status["widget"], "select");
1186 assert_eq!(status["help"], "Only live items are sold.");
1187 assert_eq!(status["options"][0]["value"], "draft");
1188 assert_eq!(status["options"][0]["label"], "Draft");
1189 assert_eq!(status["options"][1]["value"], "active");
1191 assert_eq!(status["options"][1]["label"], "Live");
1192
1193 assert_eq!(field("internal_note")["admin_visible"], false);
1195 assert_eq!(field("internal_note")["hidden"], false);
1196
1197 assert_eq!(field("organization_id")["writable"], false);
1199 assert_eq!(field("organization_id")["admin_visible"], false);
1200 }
1201
1202 #[test]
1203 fn content_format_reaches_the_manifest_and_forces_a_textarea() {
1204 let manifest = build_manifest_for(&[(
1205 "article",
1206 r#"
1207[resource]
1208name = "article"
1209
1210[fields.body]
1211type = "text"
1212
1213[fields.body.admin]
1214format = "markdown"
1215
1216[fields.summary]
1217type = "string"
1218
1219[fields.summary.admin]
1220format = "html"
1221
1222[fields.slug]
1223type = "string"
1224"#,
1225 )]);
1226
1227 let article = resource(&manifest, "article");
1228 let field = |name: &str| {
1229 article["fields"]
1230 .as_array()
1231 .unwrap()
1232 .iter()
1233 .find(|field| field["name"] == name)
1234 .unwrap()
1235 .clone()
1236 };
1237
1238 assert_eq!(field("body")["format"], "markdown");
1239 assert_eq!(field("body")["widget"], "textarea");
1240 assert_eq!(field("summary")["format"], "html");
1242 assert_eq!(field("summary")["widget"], "textarea");
1243 assert_eq!(field("slug")["format"], "plain");
1244 assert_eq!(field("slug")["widget"], "text");
1245 }
1246
1247 #[test]
1248 fn admin_ai_assistance_appears_only_when_both_admin_and_ai_are_configured() {
1249 let enabled = build_manifest_with_config(
1250 r#"
1251[server]
1252base_path = "/api"
1253
1254[ai]
1255provider = "openai"
1256api_key = "test"
1257
1258[admin.ai_assistance]
1259enabled = true
1260system = "Return only the field value."
1261prompt_placeholder = "Prompt AI to fill this field"
1262"#,
1263 &[],
1264 );
1265 assert_eq!(
1266 enabled["ai_assistance"]["prompt_placeholder"],
1267 "Prompt AI to fill this field"
1268 );
1269 assert_eq!(
1270 enabled["ai_assistance"]["system"],
1271 "Return only the field value."
1272 );
1273
1274 let no_ai = build_manifest_with_config(
1275 r#"
1276[server]
1277base_path = "/api"
1278
1279[admin.ai_assistance]
1280enabled = true
1281"#,
1282 &[],
1283 );
1284 assert!(no_ai["ai_assistance"].is_null());
1285
1286 let no_admin = build_manifest_with_config(
1287 r#"
1288[server]
1289base_path = "/api"
1290
1291[ai]
1292provider = "openai"
1293api_key = "test"
1294"#,
1295 &[],
1296 );
1297 assert!(no_admin["ai_assistance"].is_null());
1298 }
1299
1300 #[test]
1301 fn labels_and_columns_are_inferred_when_admin_says_nothing() {
1302 let manifest = build_manifest_for(&[(
1303 "purchase_order",
1304 r#"
1305[resource]
1306name = "purchase_order"
1307
1308[fields.name]
1309type = "string"
1310
1311[fields.notes]
1312type = "text"
1313
1314[fields.settings]
1315type = "json"
1316"#,
1317 )]);
1318
1319 let purchase_order = resource(&manifest, "purchase_order");
1320 assert_eq!(purchase_order["label"], "Purchase order");
1321 assert_eq!(purchase_order["plural"], "Purchase orders");
1322 assert_eq!(purchase_order["display_field"], "name");
1323 assert_eq!(purchase_order["search_field"], "name");
1324
1325 let columns: Vec<&str> = purchase_order["columns"]
1328 .as_array()
1329 .unwrap()
1330 .iter()
1331 .map(|column| column.as_str().unwrap())
1332 .collect();
1333 assert_eq!(columns, vec!["name"]);
1334 }
1335
1336 #[test]
1337 fn related_lists_are_derived_from_incoming_references() {
1338 let manifest = build_manifest_for(&[
1339 (
1340 "order",
1341 "[resource]\nname = \"order\"\n\n[fields.number]\ntype = \"string\"\n",
1342 ),
1343 (
1344 "order_line",
1345 r#"
1346[resource]
1347name = "order_line"
1348
1349[fields.order_id]
1350type = "reference"
1351references = "order"
1352required = true
1353
1354[fields.quantity]
1355type = "integer"
1356"#,
1357 ),
1358 ]);
1359
1360 let order = resource(&manifest, "order");
1361 let children = order["children"].as_array().unwrap();
1362 assert_eq!(children.len(), 1);
1363 assert_eq!(children[0]["resource"], "order_line");
1364 assert_eq!(children[0]["field"], "order_id");
1365 assert_eq!(children[0]["label"], "Order lines");
1366
1367 let line = resource(&manifest, "order_line");
1369 let relation = line["relations"]
1370 .as_array()
1371 .unwrap()
1372 .iter()
1373 .find(|relation| relation["field"] == "order_id")
1374 .unwrap();
1375 assert_eq!(relation["target"], "order");
1376 assert_eq!(relation["label"], "Order");
1377 assert_eq!(relation["required"], true);
1378 }
1379
1380 #[test]
1381 fn known_roles_collect_every_role_the_app_names() {
1382 let manifest = build_manifest_for(&[(
1383 "product",
1384 r#"
1385[resource]
1386name = "product"
1387
1388[permissions]
1389create = "role:buyer"
1390delete = "role:auditor"
1391
1392[fields.name]
1393type = "string"
1394"#,
1395 )]);
1396
1397 let roles: Vec<&str> = manifest["auth"]["known_roles"]
1398 .as_array()
1399 .unwrap()
1400 .iter()
1401 .map(|role| role.as_str().unwrap())
1402 .collect();
1403 assert!(roles.contains(&"buyer"));
1404 assert!(roles.contains(&"auditor"));
1405 assert!(roles.contains(&"admin"));
1407 assert!(roles.contains(&"member"));
1408 }
1409
1410 #[test]
1411 fn signup_collects_required_profile_fields_so_nobody_types_json() {
1412 let manifest = build_manifest_for(&[(
1413 "user",
1414 r#"
1415[resource]
1416name = "user"
1417scope = "global"
1418
1419[auth]
1420identity_field = "email"
1421password_field = "password_hash"
1422
1423[fields.email]
1424type = "string"
1425required = true
1426unique = true
1427
1428[fields.password_hash]
1429type = "string"
1430hidden = true
1431
1432[fields.full_name]
1433type = "string"
1434required = true
1435
1436[fields.nickname]
1437type = "string"
1438"#,
1439 )]);
1440
1441 let signup: Vec<&str> = manifest["auth"]["signup_fields"]
1442 .as_array()
1443 .unwrap()
1444 .iter()
1445 .map(|field| field["name"].as_str().unwrap())
1446 .collect();
1447 assert_eq!(signup, vec!["full_name"]);
1450 assert_eq!(manifest["auth"]["identity_label"], "Email");
1451
1452 let profile: Vec<&str> = manifest["auth"]["profile_fields"]
1455 .as_array()
1456 .unwrap()
1457 .iter()
1458 .map(|field| field["name"].as_str().unwrap())
1459 .collect();
1460 assert!(profile.contains(&"nickname"));
1461 assert!(!profile.contains(&"password_hash"));
1462 }
1463}