Skip to main content

tablo_core/panel/
build.rs

1//! Mounting a panel: [`RouterBuilderPanelExt::panel`], the declaration
2//! checks it runs, and the route-path helpers.
3
4use std::sync::Arc;
5
6use toasty::Db;
7use topcoat::{
8    Result,
9    asset::AssetConfig,
10    context::Cx,
11    cookie::RouterBuilderCookieExt,
12    router::{
13        Body, LayoutFn, Path, RouteFn, RouteFuture, RouterBuilder, RouterBuilderDirectoryExt,
14        error::redirect,
15    },
16    runtime::{PrefetchMode, RouterBuilderRuntimeExt, RuntimeSetup},
17    session::{RouterBuilderSessionExt, SessionConfig},
18};
19
20use super::{
21    Panel, Root,
22    forms::MAX_FORM_BYTES,
23    headers,
24    register::Registry,
25    state::{PanelState, Panels, current, under_prefix},
26};
27use crate::{
28    ActionInputFault, DeclarationError, DeclarationErrorKind, ManyToManyFault, MountError, Page,
29    Site,
30    auth::{PanelGate, RuntimeGate, SESSION_LIFETIME},
31    declaration::segment_fault,
32    form::RecordForm,
33    page::MountedPage,
34    policy::Ability,
35    resource::{InputSpec, MountScope, Mounted, Mounts, Resource, require_mounted},
36    tenancy::TenantSource,
37    toasty_compat::{
38        join::JoinTable,
39        model::{self, AppSchema},
40    },
41    topcoat_compat::RUNTIME_PREFIX,
42};
43
44/// Mounts a [`Panel`] on a router the app owns.
45///
46/// ```rust,no_run
47/// # use tablo_core::{Auth, NoForm, Panel, PanelUser, Resource, RouterBuilderPanelExt, auth::Authenticator};
48/// # use topcoat::{context::Cx, router::{Router, RouterBuilderDiscoverExt}};
49/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
50/// # struct User { #[key] #[auto] id: uuid::Uuid, name: String }
51/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
52/// # struct Order { #[key] #[auto] id: uuid::Uuid, total: i64 }
53/// # struct UserResource;
54/// # impl Resource for UserResource { type Model = User; type Form = NoForm<User>; }
55/// # struct OrderResource;
56/// # impl Resource for OrderResource { type Model = Order; type Form = NoForm<Order>; }
57/// # struct Member;
58/// # impl PanelUser for Member {
59/// #     fn user_id(&self) -> String { String::new() }
60/// #     fn display_name(&self) -> &str { "" }
61/// # }
62/// # struct Members;
63/// # impl Authenticator for Members {
64/// #     type User = Member;
65/// #     async fn verify(&self, _: &Cx, _: &str, _: &str) -> topcoat::Result<Option<Member>> { Ok(None) }
66/// #     async fn find_by_id(&self, _: &Cx, _: &str) -> topcoat::Result<Option<Member>> { Ok(None) }
67/// # }
68/// # fn main() -> topcoat::Result<()> {
69/// # let db: toasty::Db = todo!();
70/// let router = Router::builder()
71///     .discover()
72///     .app_context(db)
73///     .panel(Panel::new("admin").resource::<UserResource>())?
74///     .panel(Panel::new("portal").auth(Auth::custom(Members)).resource::<OrderResource>())?
75///     .build();
76/// # let _ = router;
77/// # Ok(())
78/// # }
79/// ```
80pub trait RouterBuilderPanelExt: Sized {
81    /// Mounts `panel` at its prefix with its routes, shell layout, and gating layers.
82    fn panel(self, panel: Panel) -> Result<Self>;
83
84    /// The panel mounted at `prefix`, for code that runs outside a request; `None` when no panel
85    /// is mounted there. `prefix` is spelled as for [`Panel::new`]: `"admin"` and `"/admin/"`
86    /// name the same panel.
87    fn panel_handle(&self, prefix: &str) -> Option<PanelHandle>;
88}
89
90impl RouterBuilderPanelExt for RouterBuilder {
91    fn panel(self, panel: Panel) -> Result<Self> {
92        panel.mount(self)
93    }
94
95    fn panel_handle(&self, prefix: &str) -> Option<PanelHandle> {
96        let panel = self
97            .get_app_context::<Panels>()?
98            .by_prefix(&super::normalize_prefix(prefix))?;
99        Some(PanelHandle {
100            db: self.get_app_context::<Db>()?.clone(),
101            mounts: Arc::clone(&panel.mounts),
102        })
103    }
104}
105
106/// A panel's mounted resources and its database, which a background job or a test keeps to build
107/// a context per run without declaring the panel again. A router's mounted panel gives it through
108/// [`panel_handle`](RouterBuilderPanelExt::panel_handle), a panel no router mounts through
109/// [`Panel::handle`]. Cloning it is cheap.
110///
111/// ```rust,no_run
112/// # use tablo_core::{NoForm, Panel, Resource, RouterBuilderPanelExt, Tenant, scoped_query};
113/// # use topcoat::router::{Router, RouterBuilderDiscoverExt};
114/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
115/// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
116/// # struct PostResource;
117/// # impl Resource for PostResource { type Model = Post; type Form = NoForm<Post>; }
118/// # async fn run(db: toasty::Db, tenant: uuid::Uuid) -> topcoat::Result<()> {
119/// let builder = Router::builder()
120///     .discover()
121///     .app_context(db)
122///     .panel(Panel::new("admin").resource::<PostResource>())?;
123/// let admin = builder.panel_handle("admin").expect("mounted above");
124/// let router = builder.build();
125///
126/// // In the job, once per run:
127/// let cx = admin.context().with(Tenant(tenant));
128/// let posts = scoped_query::<PostResource>(&cx)?;
129/// # let _ = (router, posts);
130/// # Ok(())
131/// # }
132/// ```
133#[derive(Clone)]
134pub struct PanelHandle {
135    db: Db,
136    mounts: Arc<Mounts>,
137}
138
139impl PanelHandle {
140    /// A context outside any request in which the panel's resources answer as the panel mounted
141    /// them: what a job or a test passes to [`scoped_query`](crate::scoped_query),
142    /// [`can`](crate::can), [`write_create`](crate::write_create) and the other entry points that
143    /// answer from a mounted def. It holds the database and the resources, and no request,
144    /// session or tenant. Add a tenant with `cx.with(Tenant(id))`.
145    ///
146    /// The context is one unit of work, as a request is: loads it memoizes stay cached for its
147    /// lifetime, so a job builds one per run.
148    pub fn context(&self) -> Cx {
149        validation_cx(&self.db, &self.mounts)
150    }
151}
152
153impl Panel {
154    /// The panel's resources declared and checked against `db` without a router: what a test, or
155    /// a job that runs with no router, builds its contexts from with [`PanelHandle::context`].
156    /// A job that runs beside a router takes the mounted panel's handle from
157    /// [`panel_handle`](RouterBuilderPanelExt::panel_handle) instead.
158    ///
159    /// # Errors
160    ///
161    /// The declaration errors [`panel`](RouterBuilderPanelExt::panel) refuses the resources with.
162    pub fn handle(self, db: &Db) -> Result<PanelHandle> {
163        let Panel {
164            prefix,
165            registrations,
166            configuration_errors,
167            uploads,
168            ..
169        } = self;
170        let mut registry = Registry::new(prefix.clone(), Some(AppSchema::of_db(db)));
171        let (mounts, mut errors) = registry.register_all(registrations, configuration_errors);
172        registry.check_all(db, &mounts, uploads.is_some(), &mut errors);
173        if !errors.is_empty() {
174            return Err(MountError::new(&prefix, errors).into());
175        }
176        Ok(PanelHandle {
177            db: db.clone(),
178            mounts,
179        })
180    }
181
182    fn mount(self, mut builder: RouterBuilder) -> Result<RouterBuilder> {
183        let db = builder.get_app_context::<Db>().cloned();
184        let mut registry = Registry::new(self.prefix.clone(), db.as_ref().map(AppSchema::of_db));
185        let Panel {
186            prefix,
187            shell_assets,
188            brand,
189            dark_mode,
190            layout,
191            registrations,
192            frame_ancestors,
193            configuration_errors,
194            uploads,
195            served_dirs,
196            login_hint,
197            auth,
198        } = self;
199        let (mounts, mut errors) = registry.register_all(registrations, configuration_errors);
200        errors.extend(mount_errors(
201            &builder,
202            &prefix,
203            shell_assets.is_some(),
204            &served_dirs,
205        ));
206        match &db {
207            Some(db) => {
208                registry.check_all(db, &mounts, uploads.is_some(), &mut errors);
209                if let Err(kind) = crate::auth::check_models_registered(db, &auth) {
210                    errors.push(DeclarationError::panel(kind));
211                }
212                errors.extend(
213                    crate::auth::check_registration(&auth)
214                        .into_iter()
215                        .map(DeclarationError::panel),
216                );
217            }
218            None => errors.push(DeclarationError::panel(DeclarationErrorKind::MissingDb)),
219        }
220        if !errors.is_empty() {
221            return Err(MountError::new(&prefix, errors).into());
222        }
223        if builder.get_app_context::<Panels>().is_none() {
224            builder = install_shared(builder);
225        }
226        let Registry {
227            urls,
228            mounted_pages,
229            nav_items,
230            pages,
231            routes,
232            root,
233            children,
234            ..
235        } = registry;
236        let root_redirect = match root {
237            Some(Root::Redirect(target)) => Some(target),
238            Some(Root::Home) | None => None,
239        };
240        let served_paths = served_dirs.iter().map(|(path, _)| path.clone()).collect();
241        let state = Arc::new(PanelState {
242            prefix: prefix.clone(),
243            nav_items,
244            brand,
245            dark_mode: dark_mode.unwrap_or(false),
246            shell_assets,
247            children,
248            mounts,
249            root_redirect: root_redirect.clone(),
250            auth,
251            login_hint,
252            uploads,
253            served_paths,
254            urls,
255            mounted_pages,
256        });
257        let prefix_path = route_path(&prefix);
258        builder =
259            builder.layer(topcoat::router::BodyLimit::max(MAX_FORM_BYTES).at(prefix_path.clone()));
260        if let Some(directive) = frame_ancestors {
261            builder = builder.layer(headers::FrameAncestors::new(directive, prefix.clone()));
262        }
263        // Registered last of the prefix's layers, so it runs first.
264        builder = builder.layer(PanelGate::new(Arc::clone(&state)));
265        // The login route carries its own cap scoped by path.
266        if state.gates() {
267            let login_path = route_path(&format!("{prefix}/login"));
268            let logout_path = route_path(&format!("{prefix}/logout"));
269            let tenant_path = route_path(&format!("{prefix}/tenant"));
270            builder = builder
271                .layer(
272                    topcoat::router::BodyLimit::max(crate::auth::MAX_LOGIN_BYTES)
273                        .at(login_path.clone()),
274                )
275                .route(RouteFn::new(
276                    http::Method::GET,
277                    login_path.clone(),
278                    crate::auth::login_page,
279                ))
280                .route(RouteFn::new(
281                    http::Method::POST,
282                    login_path,
283                    crate::auth::login_post,
284                ))
285                .route(RouteFn::new(
286                    http::Method::POST,
287                    logout_path,
288                    crate::auth::logout_post,
289                ))
290                .route(RouteFn::new(
291                    http::Method::POST,
292                    tenant_path,
293                    crate::auth::tenant_post,
294                ));
295        }
296        if state.auth.registrar().is_some() {
297            let register_path = route_path(&format!("{prefix}/register"));
298            builder = builder
299                .layer(
300                    topcoat::router::BodyLimit::max(crate::auth::MAX_LOGIN_BYTES)
301                        .at(register_path.clone()),
302                )
303                .route(RouteFn::new(
304                    http::Method::GET,
305                    register_path.clone(),
306                    crate::auth::register_page,
307                ))
308                .route(RouteFn::new(
309                    http::Method::POST,
310                    register_path,
311                    crate::auth::register_post,
312                ));
313        }
314        builder = builder.layout(LayoutFn::new(
315            prefix_path.clone(),
316            layout.unwrap_or(Panel::layout_shell),
317        ));
318        for (path, dir) in served_dirs {
319            builder = builder
320                .layer(headers::ServedFileHeaders::new(&path))
321                .serve_dir(route_path(&path), dir);
322        }
323        for page in pages {
324            builder = builder.page(page);
325        }
326        for route in routes {
327            builder = builder.route(route);
328        }
329        if root_redirect.is_some() {
330            builder = builder.route(RouteFn::new(
331                http::Method::GET,
332                prefix_path,
333                panel_root_redirect,
334            ));
335        }
336        builder
337            .get_app_context_mut::<Panels>()
338            .expect("the shared panel state is installed above")
339            .0
340            .push(state);
341        Ok(builder)
342    }
343}
344
345impl Registry {
346    /// Registers `registrations` and links their relations, returning the mounts and every error,
347    /// `configuration_errors` first.
348    fn register_all(
349        &mut self,
350        registrations: Vec<Box<dyn super::register::Registration>>,
351        configuration_errors: Vec<DeclarationError>,
352    ) -> (Arc<Mounts>, Vec<DeclarationError>) {
353        for registration in registrations {
354            registration.register(self);
355        }
356        self.link_relations();
357        let mut errors = configuration_errors;
358        errors.append(&mut self.errors);
359        (Arc::new(std::mem::take(&mut self.mounts)), errors)
360    }
361
362    /// Checks every registered resource against `db` and whether the panel installs an
363    /// uploader.
364    fn check_all(
365        &self,
366        db: &Db,
367        mounts: &Arc<Mounts>,
368        has_uploader: bool,
369        errors: &mut Vec<DeclarationError>,
370    ) {
371        let cx = validation_cx(db, mounts);
372        for check in &self.page_checks {
373            check(&cx, errors);
374        }
375        for registered in &self.resources {
376            (registered.check)(&cx, errors);
377            // Without an uploader a file field would accept a file and keep only its name.
378            if !has_uploader {
379                errors.extend(registered.file_fields.iter().map(|field| DeclarationError {
380                    resource: Some(registered.name),
381                    site: Site::Form,
382                    kind: DeclarationErrorKind::FileFieldWithoutUploader {
383                        field: field.clone(),
384                    },
385                }));
386            }
387        }
388    }
389}
390
391/// What refuses a panel at `prefix` before its resources are checked.
392fn mount_errors(
393    builder: &RouterBuilder,
394    prefix: &str,
395    shell_assets: bool,
396    served_dirs: &[(String, std::path::PathBuf)],
397) -> Vec<DeclarationError> {
398    let mut errors = Vec::new();
399    let mut refuse = |kind| errors.push(DeclarationError::panel(kind));
400    if shell_assets && builder.get_app_context::<AssetConfig>().is_none() {
401        refuse(DeclarationErrorKind::ShellAssetsWithoutBundle);
402    }
403    if under_prefix(RUNTIME_PREFIX, prefix) || under_prefix(prefix, RUNTIME_PREFIX) {
404        refuse(DeclarationErrorKind::PrefixOverlapsRuntime {
405            prefix: prefix.to_string(),
406        });
407    }
408    for (index, (path, _)) in served_dirs.iter().enumerate() {
409        let root = served_root(path);
410        if served_dirs[..index]
411            .iter()
412            .any(|(seen, _)| served_root(seen) == root)
413        {
414            refuse(DeclarationErrorKind::ServeDirTwice { path: path.clone() });
415        }
416    }
417    if let Some(panels) = builder.get_app_context::<Panels>() {
418        for other in &panels.0 {
419            for (path, _) in served_dirs {
420                if other
421                    .served_paths
422                    .iter()
423                    .any(|served| served_root(served) == served_root(path))
424                {
425                    refuse(DeclarationErrorKind::ServeDirTaken {
426                        path: path.clone(),
427                        panel: other.prefix.clone(),
428                    });
429                }
430            }
431            if under_prefix(&other.prefix, prefix) || under_prefix(prefix, &other.prefix) {
432                refuse(DeclarationErrorKind::PrefixOverlapsPanel {
433                    other: other.prefix.clone(),
434                });
435            }
436        }
437    }
438    errors
439}
440
441/// Installs what every panel on a router shares.
442fn install_shared(mut builder: RouterBuilder) -> RouterBuilder {
443    builder = builder.cookies();
444    if builder.get_app_context::<SessionConfig>().is_none() {
445        builder = builder.sessions(SessionConfig::builder().lifetime(SESSION_LIFETIME).build());
446    }
447    builder = builder
448        .layer(RuntimeGate::new())
449        .app_context(Panels::default())
450        .app_context(MountScope(|cx| current(cx).map(|panel| &*panel.mounts)))
451        .app_context(TenantSource(crate::auth::session_tenant));
452    // The runtime layer has no path, so a page re-run reaches the panel's layers already rewritten
453    // to a `GET`.
454    if builder.get_app_context::<RuntimeSetup>().is_none() {
455        builder = builder.runtime();
456    }
457    if builder.get_app_context::<PrefetchMode>().is_none() {
458        builder = builder.prefetch(PrefetchMode::Never);
459    }
460    builder
461}
462
463/// Redirects the panel root of a panel with no [`home`](Panel::home) page to the first declared
464/// resource's list.
465pub(crate) fn panel_root_redirect(cx: &Cx, _body: Body) -> RouteFuture<'_> {
466    Box::pin(async move {
467        // Re-checks the resolved user so a mis-mounted gate cannot leak the slug.
468        crate::auth::guard(cx)?;
469        let target = current(cx)
470            .and_then(|panel| panel.root_redirect.clone())
471            .ok_or_else(topcoat::router::error::not_found)?;
472        Err(redirect(target).into())
473    })
474}
475
476/// Reports whether `path` is a route pattern ending in a catch-all.
477pub(super) fn is_directory_pattern(path: &str) -> bool {
478    Path::from_str(path)
479        .ok()
480        .and_then(|parsed| parsed.segments().next_back())
481        .is_some_and(|segment| segment.as_catch_all().is_some())
482}
483
484/// Validates one path segment a panel derives routes from, refusing anything that cannot serve as a
485/// literal URL segment.
486pub(super) fn validate_route_segment(
487    item: &'static str,
488    segment: &str,
489) -> Result<(), DeclarationErrorKind> {
490    match segment_fault(segment) {
491        Some(fault) => Err(DeclarationErrorKind::InvalidSegment {
492            item,
493            segment: segment.to_string(),
494            fault,
495        }),
496        None => Ok(()),
497    }
498}
499
500/// A resource's declaration check: monomorphized once per registered resource, run by
501/// [`RouterBuilderPanelExt::panel`] with the app's values, the panel's mounts and no request.
502pub(super) type ResourceCheck = fn(&Cx, &mut Vec<DeclarationError>);
503
504/// Checks what a mounted resource promises before the panel serves it.
505pub(super) fn check_resource<R: Resource>(cx: &Cx, errors: &mut Vec<DeclarationError>) {
506    let Ok(declared) = require_mounted::<R>(cx) else {
507        return;
508    };
509    let tenancy = &declared.tenancy;
510    if let Some(Err(kind)) = tenancy.column_field() {
511        errors.push(DeclarationError::of::<R>(Site::Tenancy, kind));
512    }
513    if tenancy.via_is_single() == Some(true) {
514        errors.push(DeclarationError::of::<R>(
515            Site::Tenancy,
516            DeclarationErrorKind::TenancyViaOwnColumn,
517        ));
518    }
519    let mut form_errors = declared.form.declaration_errors();
520    form_errors.extend(declared.form.empty_choices());
521    let form_is_sound = form_errors.is_empty();
522    for (site, kinds) in [
523        (Site::Table, declared.table.declaration_errors()),
524        (Site::Form, form_errors),
525        (Site::Detail, declared.detail.declaration_errors()),
526    ] {
527        errors.extend(
528            kinds
529                .into_iter()
530                .map(|kind| DeclarationError::of::<R>(site.clone(), kind)),
531        );
532    }
533    for (site, kinds) in [
534        (Site::Table, declared.table.unavailable_sources(cx)),
535        (Site::Detail, declared.detail.unavailable_sources(cx)),
536    ] {
537        errors.extend(
538            kinds
539                .into_iter()
540                .map(|kind| DeclarationError::of::<R>(site.clone(), kind)),
541        );
542    }
543    check_actions(cx, &declared, errors);
544    check_form_declaration(cx, &declared, form_is_sound, errors);
545}
546
547/// Every custom and header action's name is distinct among the resource's actions: the routes
548/// dispatch by it. [`check_inputs`] checks their inputs.
549/// [`ResourceDef::action`](crate::ResourceDef::action) checks each name is a route segment as
550/// it compiles.
551fn check_actions<R: Resource>(cx: &Cx, declared: &Mounted<R>, errors: &mut Vec<DeclarationError>) {
552    let actions = declared
553        .actions
554        .entries()
555        .iter()
556        .map(|action| (action.name, action.input))
557        .chain(
558            declared
559                .header_actions
560                .entries()
561                .iter()
562                .map(|action| (action.name, action.input)),
563        );
564    check_inputs::<R>(cx, actions, errors);
565}
566
567/// A page's declaration check, run by [`RouterBuilderPanelExt::panel`] as a resource's is.
568pub(super) type PageCheck = Box<dyn Fn(&Cx, &mut Vec<DeclarationError>) + Send>;
569
570/// Checks the header actions of `page`, the page `P` as the panel mounted it, as a resource's are.
571pub(super) fn check_page<P: Page>(page: Arc<MountedPage>) -> PageCheck {
572    Box::new(move |cx, errors| {
573        check_inputs::<P>(
574            cx,
575            page.header_actions
576                .entries()
577                .iter()
578                .map(|action| (action.name, action.input)),
579            errors,
580        );
581    })
582}
583
584/// Each of `T`'s actions has a name no other shares, and an input that declares no misdeclared
585/// field, none the action's POST carries itself and no file field; an input with no field parses
586/// an empty submission.
587fn check_inputs<T: 'static>(
588    cx: &Cx,
589    actions: impl Iterator<Item = (&'static str, InputSpec)>,
590    errors: &mut Vec<DeclarationError>,
591) {
592    let mut seen = std::collections::HashSet::new();
593    for (name, spec) in actions {
594        if !seen.insert(name) {
595            errors.push(DeclarationError::of::<T>(
596                Site::Registration,
597                DeclarationErrorKind::DuplicateAction { name },
598            ));
599        }
600        let input = (spec.schema)();
601        let mut kinds = input.declaration_errors();
602        kinds.extend(input.empty_choices());
603        let mut faults: Vec<ActionInputFault> = input
604            .fields()
605            .filter_map(|field| {
606                let name = field.name().to_string();
607                if crate::resource::RESERVED_KEYS.contains(&name.as_str()) {
608                    Some(ActionInputFault::ReservedField(name))
609                } else if field.is_file() {
610                    Some(ActionInputFault::FileField(name))
611                } else if field.parent_key().is_some() {
612                    Some(ActionInputFault::DependentChoice(name))
613                } else {
614                    None
615                }
616            })
617            .collect();
618        if !spec.takes_input && (spec.parse)(cx, &crate::form::FormValues::new()).is_err() {
619            faults.push(ActionInputFault::RefusesEmpty);
620        }
621        kinds.extend(
622            faults
623                .into_iter()
624                .map(|fault| DeclarationErrorKind::ActionInput {
625                    action: name,
626                    fault,
627                }),
628        );
629        errors.extend(
630            kinds
631                .into_iter()
632                .map(|kind| DeclarationError::of::<T>(Site::Registration, kind)),
633        );
634    }
635}
636
637/// A mistake in `R`'s form.
638fn form_error<R: Resource>(kind: DeclarationErrorKind) -> DeclarationError {
639    DeclarationError::of::<R>(Site::Form, kind)
640}
641
642/// Checks a resource's form declaration against its record form.
643fn check_form_declaration<R: Resource>(
644    cx: &Cx,
645    declared: &Mounted<R>,
646    form_is_sound: bool,
647    errors: &mut Vec<DeclarationError>,
648) {
649    // A misdeclared form, such as one placing a control twice, would only echo its mistakes.
650    if !form_is_sound {
651        return;
652    }
653    if <R::Form as RecordForm>::HAS_FORM {
654        check_form_inner(cx, declared, errors);
655    } else if declared.can(cx, Ability::Create) {
656        errors.push(form_error::<R>(DeclarationErrorKind::CreateWithoutForm));
657    }
658}
659
660fn check_form_inner<R: Resource>(
661    cx: &Cx,
662    declared: &Mounted<R>,
663    errors: &mut Vec<DeclarationError>,
664) {
665    let (fields, form) = (declared.fields.as_slice(), &*declared.form);
666    // The mount renders every field the form does not place, through `RecordForm::control`.
667    for field in fields {
668        for key in &field.keys {
669            if !form.fields().any(|control| control.name() == key) {
670                errors.push(form_error::<R>(DeclarationErrorKind::MissingControl {
671                    field: field.name.to_string(),
672                    key: key.clone(),
673                }));
674            }
675        }
676    }
677    // The framework stamps the tenant column on create.
678    if let Some(column) = tenant_column(declared)
679        && let Some(field) = fields.iter().find(|field| field.keys.contains(&column))
680    {
681        errors.push(form_error::<R>(
682            DeclarationErrorKind::FormClaimsTenantColumn {
683                field: field.name.to_string(),
684                column,
685            },
686        ));
687    }
688    for field in form.fields().filter(|field| {
689        field
690            .as_choice()
691            .is_some_and(|choice| choice.has_composite_source())
692    }) {
693        errors.push(form_error::<R>(DeclarationErrorKind::CompositeKeyChoice {
694            field: field.name().to_string(),
695        }));
696    }
697    for field in form.fields() {
698        if let Some(source) = field
699            .as_choice()
700            .and_then(|choice| choice.unavailable_source(cx))
701        {
702            errors.push(form_error::<R>(
703                DeclarationErrorKind::UnregisteredOptionSource {
704                    field: field.name().to_string(),
705                    source,
706                },
707            ));
708        }
709    }
710    if declared.tenancy.via_is_single() == Some(false) {
711        check_via_foreign_keys(cx, declared, errors);
712    }
713    check_many_to_many(cx, declared, errors);
714    if declared.can(cx, Ability::Create) {
715        check_create_columns(declared, errors);
716    }
717    let columns = model::fields::<R::Model>();
718    for field in form.fields().filter(|field| field.is_unique()) {
719        let name = field.name();
720        let backed = columns
721            .iter()
722            .any(|column| column.name == name && column.unique);
723        if !backed {
724            errors.push(form_error::<R>(DeclarationErrorKind::UniqueWithoutIndex {
725                field: name.to_string(),
726            }));
727        }
728        if !field.is_required() && !field.is_nullable() {
729            errors.push(form_error::<R>(DeclarationErrorKind::OptionalUnique {
730                field: name.to_string(),
731            }));
732        }
733    }
734}
735
736/// Each many-to-many field of `R`'s form is a multiple choice over the records its join model
737/// links, through a join model a link can write; and each multiple choice is such a field, since
738/// no column stores a list of keys.
739fn check_many_to_many<R: Resource>(
740    cx: &Cx,
741    declared: &Mounted<R>,
742    errors: &mut Vec<DeclarationError>,
743) {
744    let Some(schema) = AppSchema::of(cx) else {
745        return;
746    };
747    for field in declared.form.fields() {
748        let name = field.name();
749        let choice = field
750            .as_choice()
751            .filter(|choice| choice.is_multiple() && choice.is_relationship());
752        let fault = if model::is_via::<R::Model>(name) {
753            match (choice, JoinTable::of::<R::Model>(&schema, name)) {
754                (_, Err(fault)) => Some(fault),
755                (None, Ok(_)) => Some(ManyToManyFault::NotMultipleChoice),
756                (Some(choice), Ok(join)) => (choice.source_model() != Some(join.target_model()))
757                    .then_some(ManyToManyFault::OtherSource),
758            }
759        } else {
760            field
761                .as_choice()
762                .is_some_and(|choice| choice.is_multiple())
763                .then_some(ManyToManyFault::NoJoin)
764        };
765        if let Some(fault) = fault {
766            errors.push(form_error::<R>(DeclarationErrorKind::ManyToMany {
767                field: name.to_string(),
768                fault,
769            }));
770        }
771    }
772}
773
774/// Names `R`'s own tenant column.
775fn tenant_column<R: Resource>(declared: &Mounted<R>) -> Option<String> {
776    declared
777        .tenancy
778        .column_field()
779        .and_then(Result::ok)
780        .map(|field| field.name.clone())
781}
782
783/// Checks that every non-nullable column a create needs has a writer.
784fn check_create_columns<R: Resource>(declared: &Mounted<R>, errors: &mut Vec<DeclarationError>) {
785    let fields = &declared.fields;
786    let mut create_columns = Vec::new();
787    for column in &declared.create_columns {
788        match column {
789            Ok(name) => create_columns.push(name.as_str()),
790            Err(kind) => errors.push(DeclarationError::of::<R>(Site::Registration, kind.clone())),
791        }
792    }
793    let prefilled = model::prefilled_fields::<R::Model>();
794    let tenant = tenant_column(declared);
795    if let Some(column) = &tenant
796        && create_columns.contains(&column.as_str())
797    {
798        errors.push(DeclarationError::of::<R>(
799            Site::Registration,
800            DeclarationErrorKind::CreateColumnsNameTenant {
801                column: column.clone(),
802            },
803        ));
804    }
805    for field in &model::fields::<R::Model>() {
806        let name = field.name.as_str();
807        let filled = field.nullable
808            || field.relation
809            || prefilled.get(field.index).copied().unwrap_or(false)
810            || tenant.as_deref() == Some(name)
811            || fields.iter().any(|claim| claim.name == name)
812            || create_columns.contains(&name);
813        if !filled {
814            errors.push(form_error::<R>(DeclarationErrorKind::UnwrittenColumn {
815                column: name.to_string(),
816            }));
817        }
818    }
819}
820
821/// Builds the context for the mount-time declaration checks and [`PanelHandle::context`] from `db`
822/// and
823/// the panel's `mounts`, with no request.
824fn validation_cx(db: &Db, mounts: &Arc<Mounts>) -> Cx {
825    let mut app_context = topcoat::context::AppContext::new();
826    app_context.insert(db.clone());
827    app_context.insert(Arc::clone(mounts));
828    app_context.insert(MountScope(|cx| {
829        topcoat::context::try_app_context::<Arc<Mounts>>(cx).map(|mounts| &**mounts)
830    }));
831    Cx::new(Arc::new(app_context))
832}
833
834/// A `Tenancy::via` resource inherits its tenant from the parent its foreign key names, so the
835/// form must write that key through a relationship field over the parent's tenant-scoped
836/// resource: the write re-checks only such a field's key against the request's tenant.
837fn check_via_foreign_keys<R: Resource>(
838    cx: &Cx,
839    declared: &Mounted<R>,
840    errors: &mut Vec<DeclarationError>,
841) {
842    let form = &declared.form;
843    // The `belongs_to` relation the `via` lens steps through first.
844    let Some(model::BelongsTo {
845        name: relation,
846        target: parent,
847        foreign_keys: keys,
848    }) = declared
849        .tenancy
850        .via_hop()
851        .and_then(model::belongs_to::<R::Model>)
852    else {
853        errors.push(DeclarationError::of::<R>(
854            Site::Tenancy,
855            DeclarationErrorKind::TenancyViaWithoutBelongsTo,
856        ));
857        return;
858    };
859    let unguarded: Vec<String> = keys
860        .into_iter()
861        .filter(|key| {
862            !form.fields().any(|field| {
863                field.name() == key
864                    && field
865                        .as_choice()
866                        .and_then(|choice| choice.tenant_scoped_model(cx))
867                        == Some(parent)
868            })
869        })
870        .collect();
871    if !unguarded.is_empty() {
872        errors.push(form_error::<R>(DeclarationErrorKind::UnguardedForeignKey {
873            relation,
874            keys: unguarded,
875        }));
876    }
877}
878
879/// A served directory's pattern without its catch-all's name: the router treats
880/// `/uploads/{*file}` and `/uploads/{*path}` as one route.
881fn served_root(path: &str) -> &str {
882    path.rsplit_once("{*").map_or(path, |(root, _)| root)
883}
884
885/// Parses a panel route path, panicking on malformed input.
886pub(crate) fn route_path(path: &str) -> topcoat::router::PathBuf {
887    Path::from_str(path)
888        .expect("panel route paths are well-formed")
889        .to_owned()
890}
891
892#[cfg(test)]
893mod tests;