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