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