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