Skip to main content

apiplant_server/
admin.rs

1//! The admin dashboard: its manifest, and baking a static copy of it.
2//!
3//! The dashboard is embedded in the binary and [served live](crate::run) for
4//! every app, so the common case generates nothing. [`build`] writes the same
5//! files out as a plain directory (`index.html`, `app.js`, `app.css` and a
6//! manifest) for **hosting it somewhere other than the API** — a CDN, a bucket,
7//! a different origin entirely. That copy is never read back by the server:
8//! the running dashboard always describes the running app. Everything it needs
9//! to know about the app — which resources exist, what to call them, which
10//! fields to show, who may see what — is resolved *here*, at build time, and
11//! written into `apiplant-admin.json`. The shipped JavaScript is the same for
12//! every app.
13//!
14//! Two things are kept firmly apart, and it matters:
15//!
16//! * `[permissions]` / a function's `permission` decide what the **API**
17//!   allows. They are enforced by the server on every request.
18//! * `[admin]` decides what an **operator is shown**. It is presentation, and
19//!   this generator treats it as such — hiding a resource here does not protect
20//!   it, and the manifest never carries anything a signed-in caller could not
21//!   already read from the API.
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::fs;
25use std::path::{Path, PathBuf};
26
27use anyhow::{anyhow, bail, Context, Result};
28use apiplant_abi::{FunctionAccess, HttpMethod};
29use apiplant_core::schema::{
30    is_auth_resource, relation_name, titleize, Access, ContentFormat, Field, FieldType, OnDelete,
31    Resource, Widget,
32};
33use apiplant_core::App;
34use serde::Serialize;
35use serde_json::Value;
36
37use crate::functions::FunctionRegistry;
38
39/// Name of the manifest file the dashboard fetches on load.
40pub const MANIFEST_FILE: &str = "apiplant-admin.json";
41
42#[derive(Debug, Clone)]
43pub struct Options {
44    pub api: String,
45    pub out: Option<PathBuf>,
46}
47
48#[derive(Debug, Serialize)]
49struct AdminManifest {
50    title: String,
51    app_name: String,
52    /// URL of the app's own mark, when it configured one.
53    logo: Option<String>,
54    api_base_url: String,
55    docs_url: Option<String>,
56    auth: AuthManifest,
57    resources: Vec<ResourceManifest>,
58    functions: Vec<FunctionManifest>,
59}
60
61#[derive(Debug, Serialize)]
62struct AuthManifest {
63    /// The field a person logs in with, and a label to put above the box.
64    identity_field: String,
65    identity_label: String,
66    allow_registration: bool,
67    /// Extra fields the register form should collect, so nobody has to type
68    /// JSON to create an account.
69    signup_fields: Vec<FieldManifest>,
70    /// Fields the account screen lets someone edit about themselves.
71    profile_fields: Vec<FieldManifest>,
72    /// Roles seen anywhere in the app's permissions, so role pickers can offer
73    /// real choices instead of a free-text box.
74    known_roles: Vec<String>,
75}
76
77#[derive(Debug, Serialize)]
78struct ResourceManifest {
79    name: String,
80    /// Singular human label ("Purchase order").
81    label: String,
82    /// Collection human label ("Purchase orders").
83    plural: String,
84    /// Sidebar grouping, or `None` for the ungrouped tail.
85    group: Option<String>,
86    order: i64,
87    builtin: bool,
88    /// One of the auth/tenancy resources the dashboard manages with a dedicated
89    /// screen rather than a generic table.
90    auth_resource: bool,
91    /// Whether it belongs in the resource navigation at all.
92    visible: bool,
93    /// Organisation roles that may see it; empty means "anyone who can list it".
94    roles: Vec<String>,
95    scope: &'static str,
96    owner_field: String,
97    /// Field whose value names a record in tables, pickers and headings.
98    display_field: Option<String>,
99    /// Field the list search box filters on.
100    search_field: Option<String>,
101    /// Columns for the list table, in order.
102    columns: Vec<String>,
103    fields: Vec<FieldManifest>,
104    /// `belongs_to` edges out of this resource.
105    relations: Vec<RelationManifest>,
106    /// `has_many` edges into it — the record screen lists these inline.
107    children: Vec<ChildManifest>,
108    permissions: ActionPermissionsManifest,
109}
110
111#[derive(Debug, Serialize)]
112struct ActionPermissionsManifest {
113    list: ActionPermissionManifest,
114    read: ActionPermissionManifest,
115    create: ActionPermissionManifest,
116    update: ActionPermissionManifest,
117    delete: ActionPermissionManifest,
118}
119
120#[derive(Debug, Serialize)]
121struct ActionPermissionManifest {
122    value: String,
123    /// The role name when `value` is `role:<name>`, so the UI needn't re-parse.
124    role: Option<String>,
125    note: String,
126    requires_org: bool,
127}
128
129#[derive(Debug, Serialize)]
130struct FieldManifest {
131    name: String,
132    label: String,
133    #[serde(rename = "type")]
134    ty: &'static str,
135    /// The input to render; `auto` lets the interface choose from `type`.
136    widget: &'static str,
137    help: Option<String>,
138    placeholder: Option<String>,
139    /// What the text is: `plain`, `markdown` or `html`. Presentation only —
140    /// the dashboard highlights and previews the markup.
141    format: &'static str,
142    options: Vec<FieldOption>,
143    required: bool,
144    unique: bool,
145    /// Stripped from API responses entirely (a password hash, say).
146    hidden: bool,
147    /// Present in the API but deliberately not shown in the dashboard.
148    admin_visible: bool,
149    readonly: bool,
150    max_length: Option<u32>,
151    references: Option<String>,
152    relation: Option<String>,
153    on_delete: Option<&'static str>,
154    default_value: Option<Value>,
155    /// Whether the dashboard may submit this field on create/update.
156    writable: bool,
157}
158
159#[derive(Debug, Serialize)]
160struct FieldOption {
161    value: String,
162    label: String,
163}
164
165#[derive(Debug, Serialize)]
166struct RelationManifest {
167    field: String,
168    relation: String,
169    target: String,
170    /// Human label for the link ("Customer").
171    label: String,
172    required: bool,
173}
174
175/// A resource that points *at* this one — rendered as a related list on the
176/// record screen, which is what turns a table of foreign keys into something a
177/// non-technical operator can actually navigate.
178#[derive(Debug, Serialize)]
179struct ChildManifest {
180    resource: String,
181    /// The child's field that points here.
182    field: String,
183    label: String,
184}
185
186#[derive(Debug, Serialize)]
187struct FunctionManifest {
188    name: String,
189    label: String,
190    description: String,
191    group: Option<String>,
192    order: i64,
193    method: &'static str,
194    /// Effective access policy, in the shared `[permissions]` grammar.
195    permission: String,
196    role: Option<String>,
197    permission_note: String,
198    requires_org: bool,
199    /// Whether it belongs in the dashboard's action list.
200    visible: bool,
201    roles: Vec<String>,
202    /// Text for the confirmation step, or `None` to run without one.
203    confirm: Option<String>,
204    run_label: String,
205    /// JSON Schema for the request body; the dashboard renders a form from it.
206    input_schema: Option<Value>,
207    output_schema: Option<Value>,
208}
209
210/// The `admin { … }` block of a function manifest, as carried over the ABI.
211#[derive(Debug, Default, serde::Deserialize)]
212#[serde(default)]
213struct FunctionAdmin {
214    visible: Option<bool>,
215    roles: Vec<String>,
216    label: Option<String>,
217    group: Option<String>,
218    description: Option<String>,
219    confirm: Option<String>,
220    run_label: Option<String>,
221    order: Option<i64>,
222}
223
224pub fn build(app_dir: &Path, options: Options) -> Result<PathBuf> {
225    let app = App::load(app_dir)?;
226    let api_base_url = normalize_api_base(
227        &options.api,
228        &app.config.server.base_path,
229        app.tls.is_some(),
230    )?;
231    let output_dir = options.out.unwrap_or_else(|| app_dir.join("admin"));
232    let registry = FunctionRegistry::load(&app);
233    let manifest = build_manifest(&app, &registry, api_base_url.clone())?;
234
235    fs::create_dir_all(&output_dir)
236        .with_context(|| format!("failed to create {}", output_dir.display()))?;
237
238    for (relative, _) in apiplant_assets::ADMIN {
239        let path = output_dir.join(relative);
240        if let Some(parent) = path.parent() {
241            fs::create_dir_all(parent)
242                .with_context(|| format!("failed to create {}", parent.display()))?;
243        }
244        let bytes = asset(relative).expect("listed asset");
245        write_bytes(path, &bytes)?;
246    }
247    write_json(output_dir.join(MANIFEST_FILE), &manifest)?;
248
249    Ok(output_dir)
250}
251
252/// One file of the embedded dashboard, ready to serve or write out.
253///
254/// The stylesheet is rewritten on the way past: Vite emits absolute
255/// `url(/head.png)` references, and the dashboard is never at the site root —
256/// it is under `/admin/`, or in a directory someone hosts wherever they like.
257pub fn asset(path: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
258    use std::borrow::Cow;
259
260    let bytes = apiplant_assets::find(apiplant_assets::ADMIN, path)?;
261    if path.trim_matches('/') == "app.css" {
262        let css = String::from_utf8_lossy(bytes)
263            .replace("url(/head.png)", "url(./head.png)")
264            .replace("url(/head-inverted.png)", "url(./head-inverted.png)");
265        return Some(Cow::Owned(css.into_bytes()));
266    }
267    Some(Cow::Borrowed(bytes))
268}
269
270/// The manifest for an app already loaded by the server, as JSON.
271///
272/// `api_base_url` is the prefix the dashboard puts in front of every request;
273/// served from the app's own origin that is just the API's `base_path`.
274pub fn manifest_json(
275    app: &App,
276    functions: &FunctionRegistry,
277    api_base_url: String,
278) -> Result<String> {
279    let manifest = build_manifest(app, functions, api_base_url)?;
280    Ok(serde_json::to_string(&manifest)?)
281}
282
283fn build_manifest(
284    app: &App,
285    functions: &FunctionRegistry,
286    api_base_url: String,
287) -> Result<AdminManifest> {
288    let app_name = app.display_name();
289    let user = app.resources.get("user");
290    let identity_field = user
291        .and_then(|resource| resource.auth.as_ref())
292        .map(|auth| auth.identity_field.clone())
293        .unwrap_or_else(|| "email".to_string());
294    let password_field = user
295        .and_then(|resource| resource.auth.as_ref())
296        .map(|auth| auth.password_field.clone())
297        .unwrap_or_else(|| "password_hash".to_string());
298    let docs_url = if app.config.docs.enabled {
299        Some(format!("{}{}", api_base_url, app.config.docs.path))
300    } else {
301        None
302    };
303
304    // Functions bound to a resource's lifecycle are machinery, not operator
305    // actions; they never appear as something to "run" even when their
306    // permission would allow it.
307    let hook_functions = app
308        .resources
309        .values()
310        .flat_map(|resource| {
311            resource
312                .hooks
313                .iter()
314                .map(|(_, function)| function.to_string())
315        })
316        .collect::<BTreeSet<_>>();
317
318    // Reverse index: which resources point at each resource, so a record screen
319    // can offer its related lists.
320    let mut children: BTreeMap<String, Vec<ChildManifest>> = BTreeMap::new();
321    for child in app.resources.values() {
322        let references = child.references();
323        for reference in &references {
324            // A tenancy column is plumbing — every org-scoped row has one, and
325            // "this organization → all its products" is not a relationship
326            // anyone wants to browse from the organisation screen.
327            if reference.field == "organization_id" {
328                continue;
329            }
330            if !app.resources.contains_key(&reference.target) {
331                continue;
332            }
333            // When a child points at the same parent twice — an order's billing
334            // *and* shipping address — the resource name alone names both
335            // lists, so the relation has to disambiguate them.
336            let ambiguous = references
337                .iter()
338                .filter(|other| other.target == reference.target)
339                .count()
340                > 1;
341            let label = if ambiguous {
342                format!(
343                    "{} ({})",
344                    child.admin_plural(),
345                    titleize(&reference.relation).to_lowercase()
346                )
347            } else {
348                child.admin_plural()
349            };
350            children
351                .entry(reference.target.clone())
352                .or_default()
353                .push(ChildManifest {
354                    resource: child.meta.name.clone(),
355                    field: reference.field.clone(),
356                    label,
357                });
358        }
359    }
360
361    let resources = app
362        .resources
363        .values()
364        .map(|resource| {
365            resource_manifest(
366                resource,
367                &password_field,
368                children
369                    .remove(resource.meta.name.as_str())
370                    .unwrap_or_default(),
371            )
372        })
373        .collect::<Vec<_>>();
374
375    let mut loaded_functions = functions
376        .iter()
377        .filter(|entry| !hook_functions.contains(entry.manifest.name.as_str()))
378        .map(|entry| function_manifest(&entry.manifest))
379        // A private function has no endpoint at all, so there is nothing for an
380        // operator to run and nothing to show.
381        .filter(|manifest| manifest.permission != "private")
382        .collect::<Vec<_>>();
383    loaded_functions.sort_by(|left, right| {
384        left.group
385            .cmp(&right.group)
386            .then(left.order.cmp(&right.order))
387            .then(left.label.cmp(&right.label))
388    });
389
390    let signup_fields = user
391        .map(|resource| {
392            resource
393                .fields
394                .iter()
395                .filter(|(name, field)| {
396                    // The identity and password have their own inputs on the
397                    // form; everything else the model *requires* has to be
398                    // collected too, or registration simply fails.
399                    *name != &identity_field
400                        && *name != &password_field
401                        && field.required
402                        && !field.hidden
403                        && field.admin.visible
404                        && name.as_str() != "organization_id"
405                })
406                .map(|(name, field)| field_manifest(name, field, resource))
407                .collect::<Vec<_>>()
408        })
409        .unwrap_or_default();
410
411    let profile_fields = user
412        .map(|resource| {
413            resource
414                .fields
415                .iter()
416                .filter(|(name, field)| {
417                    !field.hidden && field.admin.visible && *name != &password_field
418                })
419                .map(|(name, field)| field_manifest(name, field, resource))
420                .collect::<Vec<_>>()
421        })
422        .unwrap_or_default();
423
424    Ok(AdminManifest {
425        title: format!("{app_name} admin"),
426        app_name,
427        logo: app.config.admin.logo.clone(),
428        api_base_url,
429        docs_url,
430        auth: AuthManifest {
431            identity_label: titleize(&identity_field),
432            identity_field,
433            allow_registration: app.config.auth.allow_registration,
434            signup_fields,
435            profile_fields,
436            known_roles: known_roles(app, functions),
437        },
438        resources,
439        functions: loaded_functions,
440    })
441}
442
443/// Every role named anywhere in the app — resource permissions, function
444/// permissions, `[admin] roles` — so the team screen can offer a dropdown
445/// rather than asking someone to remember how "admin" is spelled.
446fn known_roles(app: &App, functions: &FunctionRegistry) -> Vec<String> {
447    let mut roles: BTreeSet<String> = BTreeSet::new();
448    // `member` is the role the built-in membership defaults describe, and every
449    // app has one whether or not a permission names it.
450    roles.insert("member".to_string());
451    roles.insert("admin".to_string());
452
453    for resource in app.resources.values() {
454        for access in [
455            &resource.permissions.list,
456            &resource.permissions.read,
457            &resource.permissions.create,
458            &resource.permissions.update,
459            &resource.permissions.delete,
460        ] {
461            if let Access::Role(role) = access {
462                roles.insert(role.clone());
463            }
464        }
465        roles.extend(resource.admin.roles.iter().cloned());
466    }
467    for entry in functions.iter() {
468        if let FunctionAccess::Role(role) = entry.manifest.access() {
469            roles.insert(role);
470        }
471        roles.extend(parse_function_admin(&entry.manifest).roles);
472    }
473    roles.into_iter().collect()
474}
475
476fn resource_manifest(
477    resource: &Resource,
478    password_field: &str,
479    children: Vec<ChildManifest>,
480) -> ResourceManifest {
481    let fields = resource
482        .fields
483        .iter()
484        .map(|(name, field)| field_manifest(name, field, resource))
485        .collect::<Vec<_>>();
486    let relations = resource
487        .references()
488        .into_iter()
489        .filter(|reference| reference.field != "organization_id")
490        .map(|reference| RelationManifest {
491            label: titleize(&reference.relation),
492            field: reference.field,
493            relation: reference.relation,
494            target: reference.target,
495            required: reference.required,
496        })
497        .collect::<Vec<_>>();
498    let org_scoped = resource.is_org_scoped();
499
500    ResourceManifest {
501        label: resource.admin_label(),
502        plural: resource.admin_plural(),
503        group: resource.admin.group.clone(),
504        order: resource.admin.order,
505        builtin: is_builtin_resource(&resource.meta.name),
506        auth_resource: is_auth_resource(&resource.meta.name),
507        visible: resource.admin.is_visible(&resource.meta.name),
508        roles: resource.admin.roles.clone(),
509        scope: if org_scoped { "organization" } else { "global" },
510        owner_field: resource.meta.owner_field.clone(),
511        display_field: resource.admin_display_field(),
512        search_field: resource.admin_search_field(),
513        columns: resource
514            .admin_columns()
515            .into_iter()
516            // A password column would never render usefully and, on a resource
517            // that names one, is exactly the thing not to put in a table.
518            .filter(|column| column != password_field || resource.meta.name != "user")
519            .collect(),
520        permissions: ActionPermissionsManifest {
521            list: permission_manifest(&resource.permissions.list, org_scoped),
522            read: permission_manifest(&resource.permissions.read, org_scoped),
523            create: permission_manifest(&resource.permissions.create, org_scoped),
524            update: permission_manifest(&resource.permissions.update, org_scoped),
525            delete: permission_manifest(&resource.permissions.delete, org_scoped),
526        },
527        name: resource.meta.name.clone(),
528        fields,
529        relations,
530        children,
531    }
532}
533
534fn is_builtin_resource(name: &str) -> bool {
535    is_auth_resource(name)
536}
537
538fn field_manifest(name: &str, field: &Field, resource: &Resource) -> FieldManifest {
539    let references = field.references.clone();
540    let relation = references.as_ref().map(|_| relation_name(name).to_string());
541    // The framework stamps the owner and the tenant itself; offering either as
542    // an input invites someone to fill in a value the server will overwrite.
543    let stamped = name == resource.meta.owner_field || name == "organization_id";
544
545    FieldManifest {
546        label: field
547            .admin
548            .label
549            .clone()
550            .unwrap_or_else(|| titleize(name))
551            .to_string(),
552        ty: field_type_name(field.ty),
553        widget: resolve_widget(field),
554        help: field.admin.help.clone(),
555        placeholder: field.admin.placeholder.clone(),
556        format: field.admin.format.as_str(),
557        options: field
558            .admin
559            .options
560            .iter()
561            .map(|option| match option.split_once('|') {
562                Some((value, label)) => FieldOption {
563                    value: value.to_string(),
564                    label: label.to_string(),
565                },
566                None => FieldOption {
567                    value: option.clone(),
568                    label: titleize(option),
569                },
570            })
571            .collect(),
572        required: field.required,
573        unique: field.unique,
574        hidden: field.hidden,
575        admin_visible: field.admin.visible && !field.hidden,
576        readonly: field.admin.readonly,
577        max_length: field.max_length,
578        references,
579        relation,
580        on_delete: field.on_delete.map(on_delete_name),
581        default_value: field.default.clone(),
582        writable: !field.hidden && !field.admin.readonly && !stamped,
583        name: name.to_string(),
584    }
585}
586
587/// Resolve `widget = "auto"` against the field's type, so the interface always
588/// receives a concrete instruction and never has to duplicate this mapping.
589fn resolve_widget(field: &Field) -> &'static str {
590    if field.admin.widget != Widget::Auto {
591        return field.admin.widget.as_str();
592    }
593    if !field.admin.options.is_empty() {
594        return "select";
595    }
596    // Markup needs room and a preview beside it, whatever the column type.
597    if field.admin.format != ContentFormat::Plain {
598        return "textarea";
599    }
600    match field.ty {
601        FieldType::Text => "textarea",
602        FieldType::Boolean => "switch",
603        FieldType::Json => "json",
604        FieldType::Timestamp => "date_time",
605        FieldType::Reference => "reference",
606        FieldType::Integer | FieldType::BigInt | FieldType::Float => "number",
607        FieldType::Uuid => "text",
608        FieldType::String => "text",
609    }
610}
611
612fn permission_manifest(access: &Access, org_scoped: bool) -> ActionPermissionManifest {
613    ActionPermissionManifest {
614        value: access_value(access),
615        role: match access {
616            Access::Role(role) => Some(role.clone()),
617            _ => None,
618        },
619        note: access_note(access, org_scoped),
620        requires_org: org_scoped || matches!(access, Access::Role(_) | Access::Member),
621    }
622}
623
624fn parse_function_admin(manifest: &apiplant_abi::FunctionManifest) -> FunctionAdmin {
625    if manifest.admin.is_empty() {
626        return FunctionAdmin::default();
627    }
628    serde_json::from_str(manifest.admin.as_str()).unwrap_or_default()
629}
630
631fn function_manifest(manifest: &apiplant_abi::FunctionManifest) -> FunctionManifest {
632    let access = manifest.access();
633    let admin = parse_function_admin(manifest);
634    let name = manifest.name.to_string();
635    let label = admin.label.unwrap_or_else(|| titleize(&name));
636
637    FunctionManifest {
638        label: label.clone(),
639        description: admin
640            .description
641            .unwrap_or_else(|| manifest.description.to_string()),
642        group: admin.group,
643        order: admin.order.unwrap_or(0),
644        method: method_name(manifest.method),
645        permission: access.as_string(),
646        role: match &access {
647            FunctionAccess::Role(role) => Some(role.clone()),
648            _ => None,
649        },
650        permission_note: function_access_note(&access),
651        requires_org: matches!(access, FunctionAccess::Role(_) | FunctionAccess::Member),
652        visible: admin.visible.unwrap_or(true),
653        roles: admin.roles,
654        confirm: admin.confirm,
655        run_label: admin.run_label.unwrap_or(label),
656        input_schema: parse_schema(manifest.input_schema.as_str()),
657        output_schema: parse_schema(manifest.output_schema.as_str()),
658        name,
659    }
660}
661
662/// A manifest's schemas are optional and may be malformed (they come from
663/// another language's library). An unreadable one simply means "no form", not a
664/// failed build.
665fn parse_schema(raw: &str) -> Option<Value> {
666    if raw.trim().is_empty() {
667        return None;
668    }
669    serde_json::from_str(raw).ok()
670}
671
672fn access_value(access: &Access) -> String {
673    match access {
674        Access::Public => "public".to_string(),
675        Access::Authenticated => "authenticated".to_string(),
676        Access::Member => "member".to_string(),
677        Access::Owner => "owner".to_string(),
678        Access::Role(role) => format!("role:{role}"),
679        Access::Private => "private".to_string(),
680    }
681}
682
683fn access_note(access: &Access, org_scoped: bool) -> String {
684    if org_scoped {
685        return match access {
686            Access::Private => "Not available.".to_string(),
687            Access::Owner => "Limited to records you created.".to_string(),
688            Access::Role(role) => format!("Needs the {role} role."),
689            _ => "Available to everyone in this organization.".to_string(),
690        };
691    }
692
693    match access {
694        Access::Public => "Available to anyone.".to_string(),
695        Access::Authenticated | Access::Member => "Available once you sign in.".to_string(),
696        Access::Owner => "Limited to records you created.".to_string(),
697        Access::Role(role) => format!("Needs the {role} role."),
698        Access::Private => "Not available.".to_string(),
699    }
700}
701
702fn function_access_note(access: &FunctionAccess) -> String {
703    match access {
704        FunctionAccess::Public => "Anyone can run this.".to_string(),
705        FunctionAccess::Authenticated => "Available once you sign in.".to_string(),
706        FunctionAccess::Member => "Available to everyone in this organization.".to_string(),
707        FunctionAccess::Role(role) => format!("Needs the {role} role."),
708        FunctionAccess::Private => "Not available.".to_string(),
709    }
710}
711
712fn field_type_name(ty: FieldType) -> &'static str {
713    match ty {
714        FieldType::String => "string",
715        FieldType::Text => "text",
716        FieldType::Integer => "integer",
717        FieldType::BigInt => "big_int",
718        FieldType::Float => "float",
719        FieldType::Boolean => "boolean",
720        FieldType::Uuid => "uuid",
721        FieldType::Timestamp => "timestamp",
722        FieldType::Json => "json",
723        FieldType::Reference => "reference",
724    }
725}
726
727fn on_delete_name(on_delete: OnDelete) -> &'static str {
728    match on_delete {
729        OnDelete::Restrict => "restrict",
730        OnDelete::SetNull => "set_null",
731        OnDelete::Cascade => "cascade",
732        OnDelete::NoAction => "no_action",
733    }
734}
735
736fn method_name(method: HttpMethod) -> &'static str {
737    match method {
738        HttpMethod::Get => "GET",
739        HttpMethod::Post => "POST",
740        HttpMethod::Put => "PUT",
741        HttpMethod::Delete => "DELETE",
742    }
743}
744
745fn normalize_api_base(raw: &str, base_path: &str, prefer_https: bool) -> Result<String> {
746    let trimmed = raw.trim();
747    if trimmed.is_empty() {
748        bail!("--api requires a domain or full API URL");
749    }
750
751    let mut url = if trimmed.contains("://") {
752        trimmed.to_string()
753    } else {
754        format!(
755            "{}://{}",
756            if prefer_https { "https" } else { "http" },
757            trimmed
758        )
759    };
760
761    if !url.starts_with("http://") && !url.starts_with("https://") {
762        bail!("--api must resolve to an http:// or https:// URL");
763    }
764
765    let scheme_end = url
766        .find("://")
767        .map(|index| index + 3)
768        .ok_or_else(|| anyhow!("invalid API URL"))?;
769
770    match url[scheme_end..].find('/') {
771        None => {
772            if !base_path.is_empty() {
773                url.push_str(base_path);
774            }
775        }
776        Some(relative_start) => {
777            let path_start = scheme_end + relative_start;
778            let path = &url[path_start..];
779            if path == "/" {
780                url.truncate(path_start);
781                if !base_path.is_empty() {
782                    url.push_str(base_path);
783                }
784            } else {
785                while url.ends_with('/') {
786                    url.pop();
787                }
788            }
789        }
790    }
791
792    while url.ends_with('/') {
793        url.pop();
794    }
795
796    Ok(url)
797}
798
799fn write_bytes(path: PathBuf, bytes: &[u8]) -> Result<()> {
800    fs::write(&path, bytes).with_context(|| format!("failed to write {}", path.display()))
801}
802
803fn write_json(path: PathBuf, manifest: &AdminManifest) -> Result<()> {
804    let bytes = serde_json::to_vec_pretty(manifest)?;
805    fs::write(&path, bytes).with_context(|| format!("failed to write {}", path.display()))
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811    use std::time::{SystemTime, UNIX_EPOCH};
812
813    fn temp_dir(label: &str) -> PathBuf {
814        let mut dir = std::env::temp_dir();
815        let stamp = SystemTime::now()
816            .duration_since(UNIX_EPOCH)
817            .unwrap()
818            .as_nanos();
819        dir.push(format!(
820            "apiplant-admin-{label}-{}-{stamp}",
821            std::process::id()
822        ));
823        fs::create_dir_all(&dir).unwrap();
824        dir
825    }
826
827    fn build_manifest_for(models: &[(&str, &str)]) -> Value {
828        build_manifest_with_config(
829            "[server]\nbase_path = \"/api\"\n\n[auth]\nallow_registration = true\n",
830            models,
831        )
832    }
833
834    fn build_manifest_with_config(main_toml: &str, models: &[(&str, &str)]) -> Value {
835        let app_dir = temp_dir("app");
836        let out_dir = temp_dir("out");
837        fs::create_dir_all(app_dir.join("models")).unwrap();
838        fs::write(app_dir.join("main.toml"), main_toml).unwrap();
839        for (name, src) in models {
840            fs::write(app_dir.join(format!("models/{name}.toml")), src).unwrap();
841        }
842
843        build(
844            &app_dir,
845            Options {
846                api: "https://example.com".to_string(),
847                out: Some(out_dir.clone()),
848            },
849        )
850        .unwrap();
851
852        let manifest: Value =
853            serde_json::from_slice(&fs::read(out_dir.join("apiplant-admin.json")).unwrap())
854                .unwrap();
855        fs::remove_dir_all(app_dir).unwrap();
856        fs::remove_dir_all(out_dir).unwrap();
857        manifest
858    }
859
860    fn resource<'a>(manifest: &'a Value, name: &str) -> &'a Value {
861        manifest["resources"]
862            .as_array()
863            .unwrap()
864            .iter()
865            .find(|resource| resource["name"] == name)
866            .unwrap_or_else(|| panic!("no `{name}` in manifest"))
867    }
868
869    /// The header an operator reads is the app's to choose; the directory it
870    /// happens to live in is only the fallback.
871    #[test]
872    fn app_name_comes_from_config_and_falls_back_to_the_directory() {
873        let named = build_manifest_with_config(
874            "[app]\nname = \"Acme Logistics\"\n\n[server]\nbase_path = \"/api\"\n",
875            &[],
876        );
877        assert_eq!(named["app_name"], "Acme Logistics");
878        assert_eq!(named["title"], "Acme Logistics admin");
879
880        // A blank name is not a name: it would render as a header with nothing
881        // in it, so it falls back like an absent one.
882        let blank = build_manifest_with_config(
883            "[app]\nname = \"   \"\n\n[server]\nbase_path = \"/api\"\n",
884            &[],
885        );
886        assert!(blank["app_name"]
887            .as_str()
888            .unwrap()
889            .starts_with("apiplant-admin-app-"));
890
891        let unnamed = build_manifest_for(&[]);
892        assert!(unnamed["app_name"]
893            .as_str()
894            .unwrap()
895            .starts_with("apiplant-admin-app-"));
896    }
897
898    #[test]
899    fn api_base_uses_app_base_path_when_only_a_domain_is_given() {
900        assert_eq!(
901            normalize_api_base("admin.example.com", "/api", true).unwrap(),
902            "https://admin.example.com/api"
903        );
904        assert_eq!(
905            normalize_api_base("127.0.0.1:8099", "", false).unwrap(),
906            "http://127.0.0.1:8099"
907        );
908    }
909
910    #[test]
911    fn explicit_api_paths_are_preserved() {
912        assert_eq!(
913            normalize_api_base("https://example.com/custom/", "/api", true).unwrap(),
914            "https://example.com/custom"
915        );
916        assert_eq!(
917            normalize_api_base("https://example.com/", "/api", true).unwrap(),
918            "https://example.com/api"
919        );
920    }
921
922    #[test]
923    fn build_writes_static_admin_files_and_manifest() {
924        let app_dir = temp_dir("files");
925        let out_dir = temp_dir("files-out");
926        fs::create_dir_all(app_dir.join("models")).unwrap();
927        fs::write(
928            app_dir.join("main.toml"),
929            "[server]\nbase_path = \"/api\"\n",
930        )
931        .unwrap();
932
933        let written = build(
934            &app_dir,
935            Options {
936                api: "https://example.com".to_string(),
937                out: Some(out_dir.clone()),
938            },
939        )
940        .unwrap();
941
942        assert_eq!(written, out_dir);
943        for file in [
944            "index.html",
945            "app.js",
946            "app.css",
947            "head.png",
948            "head-inverted.png",
949            "apiplant-admin.json",
950        ] {
951            assert!(out_dir.join(file).exists(), "{file} was not written");
952        }
953
954        fs::remove_dir_all(app_dir).unwrap();
955        fs::remove_dir_all(out_dir).unwrap();
956    }
957
958    #[test]
959    fn auth_resources_are_hidden_from_the_resource_navigation_by_default() {
960        let manifest = build_manifest_for(&[(
961            "post",
962            "[resource]\nname = \"post\"\n\n[fields.title]\ntype = \"string\"\n",
963        )]);
964
965        for name in ["user", "organization", "membership", "api_key"] {
966            let auth = resource(&manifest, name);
967            assert_eq!(auth["visible"], false, "{name} should be hidden");
968            assert_eq!(auth["auth_resource"], true);
969        }
970        assert_eq!(resource(&manifest, "post")["visible"], true);
971        assert_eq!(resource(&manifest, "post")["auth_resource"], false);
972    }
973
974    #[test]
975    fn admin_section_overrides_labels_columns_and_role_visibility() {
976        let manifest = build_manifest_for(&[(
977            "product",
978            r#"
979[resource]
980name = "product"
981
982[admin]
983visible = true
984roles = ["manager"]
985label = "Item"
986plural = "Catalogue items"
987group = "Catalogue"
988order = 3
989display_field = "title"
990columns = ["title", "status"]
991
992[fields.title]
993type = "string"
994required = true
995
996[fields.status]
997type = "string"
998default = "draft"
999
1000[fields.status.admin]
1001label = "Lifecycle"
1002widget = "select"
1003options = ["draft", "active|Live"]
1004help = "Only live items are sold."
1005
1006[fields.internal_note]
1007type = "text"
1008
1009[fields.internal_note.admin]
1010visible = false
1011"#,
1012        )]);
1013
1014        let product = resource(&manifest, "product");
1015        assert_eq!(product["label"], "Item");
1016        assert_eq!(product["plural"], "Catalogue items");
1017        assert_eq!(product["group"], "Catalogue");
1018        assert_eq!(product["order"], 3);
1019        assert_eq!(product["roles"][0], "manager");
1020        assert_eq!(product["display_field"], "title");
1021        assert_eq!(product["columns"][0], "title");
1022        assert_eq!(product["columns"][1], "status");
1023
1024        let field = |name: &str| {
1025            product["fields"]
1026                .as_array()
1027                .unwrap()
1028                .iter()
1029                .find(|field| field["name"] == name)
1030                .unwrap()
1031        };
1032        let status = field("status");
1033        assert_eq!(status["label"], "Lifecycle");
1034        assert_eq!(status["widget"], "select");
1035        assert_eq!(status["help"], "Only live items are sold.");
1036        assert_eq!(status["options"][0]["value"], "draft");
1037        assert_eq!(status["options"][0]["label"], "Draft");
1038        // `value|Label` splits into an explicit caption.
1039        assert_eq!(status["options"][1]["value"], "active");
1040        assert_eq!(status["options"][1]["label"], "Live");
1041
1042        // Hidden in the dashboard, still part of the API.
1043        assert_eq!(field("internal_note")["admin_visible"], false);
1044        assert_eq!(field("internal_note")["hidden"], false);
1045
1046        // The injected tenancy column is never an input.
1047        assert_eq!(field("organization_id")["writable"], false);
1048        assert_eq!(field("organization_id")["admin_visible"], false);
1049    }
1050
1051    #[test]
1052    fn content_format_reaches_the_manifest_and_forces_a_textarea() {
1053        let manifest = build_manifest_for(&[(
1054            "article",
1055            r#"
1056[resource]
1057name = "article"
1058
1059[fields.body]
1060type = "text"
1061
1062[fields.body.admin]
1063format = "markdown"
1064
1065[fields.summary]
1066type = "string"
1067
1068[fields.summary.admin]
1069format = "html"
1070
1071[fields.slug]
1072type = "string"
1073"#,
1074        )]);
1075
1076        let article = resource(&manifest, "article");
1077        let field = |name: &str| {
1078            article["fields"]
1079                .as_array()
1080                .unwrap()
1081                .iter()
1082                .find(|field| field["name"] == name)
1083                .unwrap()
1084                .clone()
1085        };
1086
1087        assert_eq!(field("body")["format"], "markdown");
1088        assert_eq!(field("body")["widget"], "textarea");
1089        // Markup needs the room even when the column is a plain string.
1090        assert_eq!(field("summary")["format"], "html");
1091        assert_eq!(field("summary")["widget"], "textarea");
1092        assert_eq!(field("slug")["format"], "plain");
1093        assert_eq!(field("slug")["widget"], "text");
1094    }
1095
1096    #[test]
1097    fn labels_and_columns_are_inferred_when_admin_says_nothing() {
1098        let manifest = build_manifest_for(&[(
1099            "purchase_order",
1100            r#"
1101[resource]
1102name = "purchase_order"
1103
1104[fields.name]
1105type = "string"
1106
1107[fields.notes]
1108type = "text"
1109
1110[fields.settings]
1111type = "json"
1112"#,
1113        )]);
1114
1115        let purchase_order = resource(&manifest, "purchase_order");
1116        assert_eq!(purchase_order["label"], "Purchase order");
1117        assert_eq!(purchase_order["plural"], "Purchase orders");
1118        assert_eq!(purchase_order["display_field"], "name");
1119        assert_eq!(purchase_order["search_field"], "name");
1120
1121        // `text` and `json` never read well in a table cell, so they are left
1122        // out of the inferred column set.
1123        let columns: Vec<&str> = purchase_order["columns"]
1124            .as_array()
1125            .unwrap()
1126            .iter()
1127            .map(|column| column.as_str().unwrap())
1128            .collect();
1129        assert_eq!(columns, vec!["name"]);
1130    }
1131
1132    #[test]
1133    fn related_lists_are_derived_from_incoming_references() {
1134        let manifest = build_manifest_for(&[
1135            (
1136                "order",
1137                "[resource]\nname = \"order\"\n\n[fields.number]\ntype = \"string\"\n",
1138            ),
1139            (
1140                "order_line",
1141                r#"
1142[resource]
1143name = "order_line"
1144
1145[fields.order_id]
1146type = "reference"
1147references = "order"
1148required = true
1149
1150[fields.quantity]
1151type = "integer"
1152"#,
1153            ),
1154        ]);
1155
1156        let order = resource(&manifest, "order");
1157        let children = order["children"].as_array().unwrap();
1158        assert_eq!(children.len(), 1);
1159        assert_eq!(children[0]["resource"], "order_line");
1160        assert_eq!(children[0]["field"], "order_id");
1161        assert_eq!(children[0]["label"], "Order lines");
1162
1163        // …and the child knows which way its own reference points.
1164        let line = resource(&manifest, "order_line");
1165        let relation = line["relations"]
1166            .as_array()
1167            .unwrap()
1168            .iter()
1169            .find(|relation| relation["field"] == "order_id")
1170            .unwrap();
1171        assert_eq!(relation["target"], "order");
1172        assert_eq!(relation["label"], "Order");
1173        assert_eq!(relation["required"], true);
1174    }
1175
1176    #[test]
1177    fn known_roles_collect_every_role_the_app_names() {
1178        let manifest = build_manifest_for(&[(
1179            "product",
1180            r#"
1181[resource]
1182name = "product"
1183
1184[permissions]
1185create = "role:buyer"
1186delete = "role:auditor"
1187
1188[fields.name]
1189type = "string"
1190"#,
1191        )]);
1192
1193        let roles: Vec<&str> = manifest["auth"]["known_roles"]
1194            .as_array()
1195            .unwrap()
1196            .iter()
1197            .map(|role| role.as_str().unwrap())
1198            .collect();
1199        assert!(roles.contains(&"buyer"));
1200        assert!(roles.contains(&"auditor"));
1201        // The two roles every app has, whether or not a permission names them.
1202        assert!(roles.contains(&"admin"));
1203        assert!(roles.contains(&"member"));
1204    }
1205
1206    #[test]
1207    fn signup_collects_required_profile_fields_so_nobody_types_json() {
1208        let manifest = build_manifest_for(&[(
1209            "user",
1210            r#"
1211[resource]
1212name = "user"
1213scope = "global"
1214
1215[auth]
1216identity_field = "email"
1217password_field = "password_hash"
1218
1219[fields.email]
1220type = "string"
1221required = true
1222unique = true
1223
1224[fields.password_hash]
1225type = "string"
1226hidden = true
1227
1228[fields.full_name]
1229type = "string"
1230required = true
1231
1232[fields.nickname]
1233type = "string"
1234"#,
1235        )]);
1236
1237        let signup: Vec<&str> = manifest["auth"]["signup_fields"]
1238            .as_array()
1239            .unwrap()
1240            .iter()
1241            .map(|field| field["name"].as_str().unwrap())
1242            .collect();
1243        // Required extras only: the identity and password have their own inputs,
1244        // and an optional field would just be noise on a sign-up form.
1245        assert_eq!(signup, vec!["full_name"]);
1246        assert_eq!(manifest["auth"]["identity_label"], "Email");
1247
1248        // The account screen offers everything editable, including optional
1249        // fields — but never the password hash.
1250        let profile: Vec<&str> = manifest["auth"]["profile_fields"]
1251            .as_array()
1252            .unwrap()
1253            .iter()
1254            .map(|field| field["name"].as_str().unwrap())
1255            .collect();
1256        assert!(profile.contains(&"nickname"));
1257        assert!(!profile.contains(&"password_hash"));
1258    }
1259}