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