tablo_core/declaration.rs
1//! The declaration mistakes a panel refuses to mount with, and the one place their wording lives.
2
3use std::fmt;
4
5mod action_input;
6mod auth;
7mod field_access;
8mod field_rule;
9mod locale;
10mod many_to_many;
11mod mount_error;
12mod segment;
13mod tenancy;
14mod wording;
15
16pub use action_input::ActionInputFault;
17use action_input::fmt_action_input;
18pub use auth::{
19 AuthMailFault, ProfileFault, ResetFault, SignUpFault, TenantProfileFault,
20 TenantRegistrationFault, TwoFactorFault, VerificationFault,
21};
22pub use field_access::FieldAccessFault;
23pub use field_rule::FieldRuleFault;
24use field_rule::fmt_field_rule;
25pub use locale::LocaleFault;
26pub use many_to_many::ManyToManyFault;
27use many_to_many::fmt_many_to_many;
28pub use mount_error::MountError;
29pub use segment::SegmentFault;
30pub(crate) use segment::segment_fault;
31pub use tenancy::TenancyFault;
32
33/// One declaration mistake: who declares it, where, and what is wrong.
34#[derive(Debug, Clone, PartialEq, Eq)]
35#[non_exhaustive]
36pub struct DeclarationError {
37 /// The resource or page type that declares it; `None` for the panel's own configuration.
38 pub resource: Option<&'static str>,
39 /// The part of the declaration it is in.
40 pub site: Site,
41 /// What is wrong.
42 pub kind: DeclarationErrorKind,
43}
44
45impl DeclarationError {
46 /// A mistake in the panel's own configuration.
47 pub(crate) fn panel(kind: DeclarationErrorKind) -> Self {
48 Self {
49 resource: None,
50 site: Site::Panel,
51 kind,
52 }
53 }
54
55 /// A mistake in `T`'s declaration.
56 pub(crate) fn of<T>(site: Site, kind: DeclarationErrorKind) -> Self {
57 Self {
58 resource: Some(std::any::type_name::<T>()),
59 site,
60 kind,
61 }
62 }
63}
64
65impl fmt::Display for DeclarationError {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 if let Some(resource) = self.resource {
68 write!(f, "`{resource}`")?;
69 match &self.site {
70 Site::Panel | Site::Registration => {}
71 Site::Tenancy => f.write_str(" tenancy")?,
72 Site::SoftDelete => f.write_str(" soft delete")?,
73 Site::FieldAccess(field) => write!(f, " field access `{field}`")?,
74 Site::RecordTitle => f.write_str(" record title")?,
75 Site::Table => f.write_str(" table")?,
76 Site::Form => f.write_str(" form")?,
77 Site::Detail => f.write_str(" detail")?,
78 Site::Relation(key) => write!(f, " relation `{key}`")?,
79 }
80 f.write_str(": ")?;
81 }
82 write!(f, "{}", self.kind)
83 }
84}
85
86impl std::error::Error for DeclarationError {}
87
88/// The part of a declaration a [`DeclarationError`] is in.
89#[derive(Debug, Clone, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum Site {
92 /// The panel's own configuration: its prefix, served directories, assets and auth.
93 Panel,
94 /// The resource or page itself: its registration, slug, actions or create columns, or a
95 /// second home page.
96 Registration,
97 /// `ResourceDef::tenancy`.
98 Tenancy,
99 /// `ResourceDef::soft_delete`.
100 SoftDelete,
101 /// The `ResourceDef::field_access` over this field.
102 FieldAccess(String),
103 /// `ResourceDef::record_title`.
104 RecordTitle,
105 /// `ResourceDef::table`.
106 Table,
107 /// `ResourceDef::form`, checked against the record form.
108 Form,
109 /// `ResourceDef::detail`.
110 Detail,
111 /// The `ResourceDef::relation` to the resource with this slug, or this type when the panel
112 /// does not register it.
113 Relation(String),
114}
115
116/// What is wrong with a declaration.
117#[derive(Debug, Clone, PartialEq, Eq)]
118#[non_exhaustive]
119pub enum DeclarationErrorKind {
120 /// The router holds no `toasty::Db`.
121 ///
122 /// Call `.app_context(db)` before `.panel(..)`.
123 MissingDb,
124 /// Auth is on, but the `Db` does not register the shipped models it needs: `AuthSession`,
125 /// `AdminUser` for the shipped authenticator, `ColumnPreference` while a list has a
126 /// toggleable column, `AuthToken` for a feature that mails links or two-factor sign-in, and
127 /// `AuthFactor` for two-factor sign-in.
128 ///
129 /// Build the `Db` from `tablo::models!(..)`, add them to `toasty::models!(..)`, or opt out
130 /// with `.auth(Auth::disabled())`.
131 MissingAuthModels {
132 /// The unregistered models.
133 models: Vec<&'static str>,
134 },
135 /// `Panel::shell_assets` is set, but the router has no asset bundle.
136 ///
137 /// Call `.assets(..)` before `.panel(..)`.
138 ShellAssetsWithoutBundle,
139 /// `Panel::notifications` on `Auth::disabled`, which signs nobody in to notify.
140 NotificationsWithoutAuth,
141 /// A form declares a file field, but the panel installs no `Uploader` to store its bytes.
142 ///
143 /// Install one with `Panel::uploads` or `Panel::uploads_dir`.
144 FileFieldWithoutUploader {
145 /// The file field's name.
146 field: String,
147 },
148 /// A path segment the panel routes cannot be a literal URL segment.
149 ///
150 /// Pick a plain segment such as `blog-posts`.
151 InvalidSegment {
152 /// What names the segment: `panel prefix`, `ResourceDef::slug` or `PageDef::slug`.
153 item: &'static str,
154 /// The refused segment.
155 segment: String,
156 /// Why it is refused.
157 fault: SegmentFault,
158 },
159 /// The prefix overlaps Topcoat's runtime endpoints.
160 ///
161 /// Pick another prefix.
162 PrefixOverlapsRuntime {
163 /// The panel's prefix.
164 prefix: String,
165 },
166 /// The prefix overlaps another panel's.
167 ///
168 /// Give each panel a prefix of its own.
169 PrefixOverlapsPanel {
170 /// The other panel's prefix.
171 other: String,
172 },
173 /// A `Panel::endpoint` route outside Topcoat's runtime endpoints.
174 ///
175 /// Declare only shards and procedures; the panel's prefix guards its other routes.
176 EndpointOutsideRuntime {
177 /// The route's path.
178 path: String,
179 },
180 /// A `Panel::serve_dir` pattern that does not end in a catch-all.
181 ///
182 /// End it in `{*file}`: `/uploads/{*file}`.
183 ServeDirWithoutCatchAll {
184 /// The pattern.
185 path: String,
186 },
187 /// The panel serves the same directory pattern twice.
188 ///
189 /// Drop the second `serve_dir`.
190 ServeDirTwice {
191 /// The pattern.
192 path: String,
193 },
194 /// Another panel already serves the directory pattern.
195 ///
196 /// Serve it from one panel.
197 ServeDirTaken {
198 /// The pattern.
199 path: String,
200 /// The serving panel's prefix.
201 panel: String,
202 },
203 /// A `Panel::content_security_policy` that is no valid header value: drop its line breaks.
204 InvalidContentSecurityPolicy {
205 /// The policy.
206 policy: String,
207 },
208 /// A second `Panel::home` page.
209 ///
210 /// Keep one home page; register the other with `page`.
211 SecondHome,
212 /// The resource is registered on the panel twice.
213 ///
214 /// Register it once; mount a variant under its own type.
215 DuplicateResource,
216 /// The page is registered on the panel twice, `Panel::home` included.
217 ///
218 /// Register it once; mount a variant under its own type.
219 DuplicatePage,
220 /// The resource is not mounted on the context's panel.
221 ///
222 /// It refuses no mount: a request answers 500 when app code asks for a resource its panel
223 /// does not register, through [`wired_table`](crate::wired_table),
224 /// [`scoped_query`](crate::scoped_query), [`write_create`](crate::write_create) or
225 /// [`write_update`](crate::write_update). Register the resource on that panel, or reach it
226 /// through a context of a panel that does.
227 NotMounted,
228 /// A slug naming a route the panel serves itself: `login`, `logout`, `register`, `profile`,
229 /// `forgot-password`, `reset-password` or `-`.
230 ///
231 /// Rename it with `ResourceDef::slug` or `PageDef::slug`.
232 ReservedSlug {
233 /// The slug.
234 slug: String,
235 },
236 /// A slug another resource or page already mounts at.
237 ///
238 /// Give each a distinct slug.
239 DuplicateSlug {
240 /// The slug.
241 slug: String,
242 },
243 /// Two actions share a `NAME`.
244 ///
245 /// Rename one.
246 DuplicateAction {
247 /// The shared name.
248 name: &'static str,
249 },
250 /// An action's input cannot be served.
251 ActionInput {
252 /// The action's `NAME`.
253 action: &'static str,
254 /// What is wrong with it.
255 fault: ActionInputFault,
256 },
257 /// The panel's sign-up page cannot be served.
258 SignUp(SignUpFault),
259 /// The panel's profile page cannot be served.
260 Profile(ProfileFault),
261 /// The panel's password reset cannot be served.
262 PasswordReset(ResetFault),
263 /// The panel's email verification cannot be served.
264 EmailVerification(VerificationFault),
265 /// The panel's tenant registration page cannot be served.
266 TenantRegistration(TenantRegistrationFault),
267 /// The panel's tenant settings page cannot be served.
268 TenantProfile(TenantProfileFault),
269 /// The panel's two-factor sign-in cannot be served.
270 TwoFactor(TwoFactorFault),
271 /// The panel's auth mail cannot be sent.
272 AuthMail(AuthMailFault),
273 /// The panel's locales or translations cannot be served.
274 Locale(LocaleFault),
275 /// Two relations to the same resource.
276 ///
277 /// Keep one.
278 DuplicateRelation,
279 /// A relation to a resource the panel does not register.
280 ///
281 /// Register the related resource.
282 UnregisteredRelation,
283 /// A relationship field, or a relationship filter, whose options come from a resource the
284 /// panel does not register.
285 ///
286 /// Register that resource.
287 UnregisteredOptionSource {
288 /// The field.
289 field: String,
290 /// The option source's type.
291 source: &'static str,
292 },
293 /// A relation column labelling its records by a resource the panel does not register:
294 /// `RelationColumn::{new, of, list}::<R>` or `CountColumn::of::<R>`.
295 ///
296 /// Register `R`, or use `RelationColumn::unscoped` for a model no resource administers.
297 UnregisteredLabelSource {
298 /// The column.
299 column: String,
300 /// The label source's type.
301 source: &'static str,
302 },
303 /// A lens with more than one step where a single field of the model is needed.
304 ///
305 /// Name one field of the model.
306 TraversalLens {
307 /// The lens's step count.
308 steps: usize,
309 },
310 /// A multi-step lens that resolves to no single column.
311 ///
312 /// Step only through embedded structs, enum variant fields and `#[document]` fields.
313 UnresolvedLens {
314 /// The lens's model.
315 model: &'static str,
316 /// The lens's field indices, step by step.
317 steps: Vec<usize>,
318 },
319 /// A model whose schema no driver compiles, so its embedded paths name no column. The error
320 /// gives the reason of each of Toasty's PostgreSQL, SQLite, MySQL and DynamoDB drivers.
321 ///
322 /// Fix the model for your driver.
323 Uncompiled {
324 /// The model.
325 model: &'static str,
326 /// Each driver's reason to refuse it.
327 error: String,
328 },
329 /// A resource's tenancy that cannot scope its rows.
330 Tenancy {
331 /// What is wrong.
332 fault: TenancyFault,
333 },
334 /// A field access that cannot hide its field everywhere the panel shows it.
335 FieldAccess {
336 /// What is wrong.
337 fault: FieldAccessFault,
338 },
339 /// A table with no column.
340 ///
341 /// Declare columns; a resource whose model's table lists nothing declares its own `table`.
342 NoColumns,
343 /// `Table::paginate(0)`.
344 ///
345 /// Pass a page size of at least 1.
346 ZeroPageSize,
347 /// `Table::default_sort` names no sortable column.
348 ///
349 /// Declare its column `.sortable()`.
350 UnsortableDefaultSort {
351 /// The named column.
352 column: String,
353 },
354 /// Two table columns share a name.
355 ///
356 /// Rename one.
357 DuplicateColumn {
358 /// The shared name.
359 name: String,
360 },
361 /// Two table filters share a name.
362 ///
363 /// Rename one.
364 DuplicateFilter {
365 /// The shared name.
366 name: String,
367 },
368 /// A table filter's name holds a `.`, which its URL parameters reserve for a part:
369 /// `f.<name>.<part>`.
370 ///
371 /// Rename it without the `.`.
372 DottedFilterName {
373 /// The name.
374 name: String,
375 },
376 /// A select filter option whose value the field's type does not parse, so choosing it
377 /// filters nothing.
378 ///
379 /// Spell it as the field's form value, or build the filter with `SelectFilter::of`.
380 UnparsedFilterOption {
381 /// The filter's name.
382 filter: String,
383 /// The option's value.
384 value: String,
385 },
386 /// Two schema fields share a name.
387 ///
388 /// Bind each input to a distinct field.
389 DuplicateField {
390 /// The shared name.
391 name: String,
392 },
393 /// A field disabled on the create form, with no `default` and no blank answer: no create
394 /// could fill it.
395 ///
396 /// Give it a `default`, make it optional, or use `disabled_on_edit`.
397 DisabledWithoutValue {
398 /// The disabled field.
399 field: String,
400 },
401 /// A repeater's item disables one of its row's controls or gives it a default, which a row
402 /// does not apply.
403 ///
404 /// Disable the repeater's own field.
405 RepeaterLeafModifier {
406 /// The repeater.
407 field: String,
408 /// The control in its row.
409 leaf: String,
410 },
411 /// A repeater's item has a password control, which an edit cannot render back, so every save
412 /// would overwrite the stored secret.
413 ///
414 /// Keep the secret out of the repeater's item.
415 RepeaterPassword {
416 /// The repeater.
417 field: String,
418 /// The password control in its row.
419 leaf: String,
420 },
421 /// A text field's declared length, bounds or pattern cannot work.
422 FieldRule {
423 /// The field, or `repeater.leaf` for a control in a repeater's rows.
424 field: String,
425 /// What is wrong.
426 fault: FieldRuleFault,
427 },
428 /// A field's default is a value its control never posts: one its type does not parse, one
429 /// not among a choice's options, or a list or one value where the control posts the other.
430 ///
431 /// Spell the default as the field's form value, a list for a multiple choice.
432 UnpostedDefault {
433 /// The field.
434 field: String,
435 /// The default, a list spelled `["a", "b"]`.
436 value: String,
437 },
438 /// A condition watches a field the schema does not place.
439 ///
440 /// Place the watched field in the same schema.
441 UnplacedWatchedField {
442 /// The watched field.
443 field: String,
444 },
445 /// A condition watches a field that another condition hides while what it guards still
446 /// shows.
447 ///
448 /// Place the guarded field inside the block that hides the watched one.
449 HiddenWatchedField {
450 /// The watched field.
451 field: String,
452 },
453 /// A condition names a value its watched field never posts: not one of a choice's options, or
454 /// neither `true` nor `false` for a toggle.
455 ///
456 /// Name one of its options, or `true` or `false` for a toggle.
457 UnpostedConditionValue {
458 /// The watched field.
459 field: String,
460 /// The value.
461 value: String,
462 },
463 /// A dependent choice's column belongs to another model than its relationship's source, or
464 /// the choice has no relationship.
465 ///
466 /// Declare `relationship::<R>()`, and name a column of `R`'s model in `depends_on`.
467 MisdeclaredDependentChoice {
468 /// The dependent choice.
469 field: String,
470 },
471 /// A dependent choice that takes several options, or that depends on one that does: a
472 /// multiple choice's key holds a list, which narrows nothing.
473 ///
474 /// Drop `depends_on` or `multiple`.
475 MultipleDependentChoice {
476 /// The dependent choice.
477 field: String,
478 },
479 /// A dependent choice rendered as [radios](crate::ChoiceField::radio) or
480 /// [buttons](crate::ChoiceField::buttons): the browser lists its options once, so it cannot
481 /// narrow them when the parent changes.
482 ///
483 /// Drop `depends_on`, or `radio` or `buttons`.
484 ListedDependentChoice {
485 /// The dependent choice.
486 field: String,
487 },
488 /// A dependent choice depends on a field the schema does not place.
489 ///
490 /// Place that field in the same schema.
491 UnplacedParentField {
492 /// The dependent choice.
493 field: String,
494 /// The field it depends on.
495 parent: String,
496 },
497 /// A field a condition hides has no blank answer, so a submission hiding it cannot parse.
498 ///
499 /// Make it an `Option`, or give it `#[form(blank = ..)]`.
500 RequiredConditionalField {
501 /// The field.
502 field: String,
503 },
504 /// A record-form field binding a key no control posts, after the form renders every field
505 /// it does not place: a hand-written [`RecordForm::control`](crate::RecordForm::control)
506 /// that renders no control for it.
507 ///
508 /// Render the field's control.
509 MissingControl {
510 /// The record-form field.
511 field: String,
512 /// The key it binds.
513 key: String,
514 },
515 /// A tenant-scoped resource's record form claims the tenant column.
516 ///
517 /// Drop the field: the framework stamps the tenant.
518 FormClaimsTenantColumn {
519 /// The record-form field.
520 field: String,
521 /// The tenant column.
522 column: String,
523 },
524 /// A soft-deleting resource's record form claims the soft-delete column, which only a delete
525 /// and a restore write.
526 ///
527 /// Drop the field, or mark it `#[form(skip)]` on a `ModelForm`: only a delete and a restore
528 /// write the column.
529 FormClaimsSoftDeleteColumn {
530 /// The record-form field.
531 field: String,
532 /// The soft-delete column.
533 column: String,
534 },
535 /// A form field named after a key the form's POST carries itself, such as the CSRF token
536 /// or the edit form's `record_version`.
537 ReservedFormField {
538 /// The field.
539 field: String,
540 },
541 /// A choice field with neither options nor a relationship.
542 ///
543 /// Declare `.options(..)`, `.relationship::<R>()`, or `#[form(relationship = R)]`.
544 EmptyChoice {
545 /// The field.
546 field: String,
547 },
548 /// A relationship field, or a relationship filter, over a model with a composite primary key.
549 ///
550 /// Choose over a model with a single-column key.
551 CompositeKeyChoice {
552 /// The field, or the filter's name.
553 field: String,
554 },
555 /// A field marked unique over a column no unique index covers.
556 ///
557 /// Add `#[unique]` to the model, or drop `.unique()`.
558 UniqueWithoutIndex {
559 /// The field.
560 field: String,
561 },
562 /// A unique field whose non-nullable column stores one value for every empty submission.
563 ///
564 /// Make the field required, or an `Option`.
565 OptionalUnique {
566 /// The field.
567 field: String,
568 },
569 /// [`create_column`](crate::ResourceDef::create_column) names the tenant column.
570 ///
571 /// Drop it: the framework stamps the tenant.
572 CreateColumnsNameTenant {
573 /// The tenant column.
574 column: String,
575 },
576 /// A non-nullable column nothing writes on create, for a resource that serves a create page:
577 /// not the form, not a Toasty `#[default(..)]`, not the tenant stamp, not a `create_column`.
578 ///
579 /// Add the field to the form, a default to the model, or a `create_column`; or make the
580 /// resource `edit_only`.
581 UnwrittenColumn {
582 /// The column.
583 column: String,
584 },
585 /// [`importable`](crate::ResourceDef::importable) on a resource that serves no create page:
586 /// it has no record form, or is [`edit_only`](crate::ResourceDef::edit_only).
587 ///
588 /// Drop `importable`, or serve the create page.
589 ImportWithoutCreate,
590 /// A many-to-many field or relation whose join model cannot link records.
591 ManyToMany {
592 /// The field, or the relation's `via` field.
593 field: String,
594 /// What is wrong.
595 fault: ManyToManyFault,
596 },
597}
598
599#[cfg(test)]
600mod tests;