1mod handlers;
4#[cfg(feature = "history")]
5mod history;
6mod screens;
7
8use http::Method;
9use sofie_core::{Constraint, SqlType};
10use sofie_db::{Aggregate, Create, Field, ReplyData, Resolve, Update};
11use sofie_values::{Control, FormField, FormFields, Part};
12
13pub use self::handlers::OwnerOf;
14use self::handlers::{Edited, Owner, Posted};
15use crate::handler::{Endpoint, Loader, endpoint, loader_endpoint};
16use std::sync::Arc;
17
18use crate::routes::{Refresh, RouteRules, ScreenView, View};
19use crate::{Area, RequestContext, Routes, screen_key};
20
21pub(crate) enum Builtin {
22 Resource {
23 description: Arc<Description>,
24 kept: Arc<[Resource]>,
25 screen: Resource,
26 permission: Option<String>,
27 },
28 Shipped {
29 source: &'static str,
30 describe: Option<Describe>,
31 },
32}
33
34pub type Describe = fn(&mut crate::Screens, &str) -> sofie_core::Result<()>;
35
36pub(crate) fn install(
38 screens: &mut crate::Screens,
39 routes: &[crate::routes::Route],
40) -> sofie_core::Result<()> {
41 for route in routes {
42 let Some((key, builtin)) = built_in(route) else {
43 continue;
44 };
45 if !screens.contains(key) {
46 screens.add_source(key, &builtin.source(&route.path))?;
47 }
48 if let Some(describe) = builtin.describe() {
49 describe(screens, key)?;
50 }
51 }
52 Ok(())
53}
54
55#[must_use]
57pub fn built_in_screen_source(app: &crate::App, key: &str) -> Option<String> {
58 app.routes.iter().find_map(|route| {
59 let (candidate, builtin) = built_in(route)?;
60 (candidate == key).then(|| builtin.source(&route.path))
61 })
62}
63
64fn built_in(route: &crate::routes::Route) -> Option<(&str, &Builtin)> {
65 let View::Screen(ScreenView {
66 key,
67 builtin: Some(builtin),
68 ..
69 }) = &route.view
70 else {
71 return None;
72 };
73 Some((key, builtin))
74}
75
76impl Builtin {
77 fn source(&self, path: &str) -> String {
78 match self {
79 Self::Shipped { source, .. } => (*source).to_owned(),
80 Self::Resource {
81 description,
82 kept,
83 screen,
84 permission,
85 } => screens::Plan {
86 description,
87 at: list_path(path, *screen, description.name),
88 kept,
89 permission: permission.as_deref(),
90 }
91 .source(*screen),
92 }
93 }
94
95 fn describe(&self) -> Option<Describe> {
96 match self {
97 Self::Shipped { describe, .. } => *describe,
98 Self::Resource {
99 description,
100 screen,
101 ..
102 } => match screen {
103 Resource::Create => Some(description.create),
104 Resource::Edit => Some(description.edit),
105 _ => None,
106 },
107 }
108 }
109}
110
111pub trait Shipped<A: Area> {
112 fn shipped<Args: 'static, H>(
114 &mut self,
115 path: &str,
116 name: &str,
117 template: (&'static str, Option<Describe>),
118 loader: H,
119 ) -> crate::PageRoute<'_, A>
120 where
121 H: Loader<A, Args>;
122}
123
124impl<A: Area> Shipped<A> for Routes<A> {
125 fn shipped<Args: 'static, H>(
126 &mut self,
127 path: &str,
128 name: &str,
129 (source, describe): (&'static str, Option<Describe>),
130 loader: H,
131 ) -> crate::PageRoute<'_, A>
132 where
133 H: Loader<A, Args>,
134 {
135 let view = View::Screen(ScreenView {
136 key: screen_key(self.module, A::NAME, name),
137 title: sofie_engine::humanize(name),
138 builtin: Some(Arc::new(Builtin::Shipped { source, describe })),
139 });
140 crate::PageRoute::new(self.push(
141 Method::GET,
142 path,
143 loader_endpoint(loader),
144 view,
145 RouteRules::default(),
146 ))
147 }
148}
149
150fn list_path(path: &str, screen: Resource, name: &str) -> String {
151 let suffix = match screen {
152 Resource::Show => format!("/{{{name}}}"),
153 Resource::Create => "/new".to_owned(),
154 Resource::Edit => format!("/{{{name}}}/edit"),
155 Resource::Index | Resource::Delete => String::new(),
156 };
157 path.strip_suffix(&suffix).unwrap_or(path).to_owned()
158}
159
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum Resource {
162 Index,
163 Show,
164 Create,
165 Edit,
166 Delete,
168}
169
170pub struct ResourceRoutes<'r, A: Area> {
171 routes: &'r mut Routes<A>,
172 kept: Vec<Resource>,
173 parts: Option<Parts>,
174}
175
176struct Parts {
177 description: Description,
178 list: Endpoint,
179 show: Endpoint,
180 blank: Endpoint,
181 create: Endpoint,
182 update: Endpoint,
183 delete: Endpoint,
184}
185
186pub(crate) struct Description {
187 #[cfg_attr(
188 not(feature = "permissions"),
189 expect(dead_code, reason = "only permissions are named by it")
190 )]
191 module: &'static str,
192 name: &'static str,
193 edited: &'static [FormField],
195 shown: &'static [ShownField],
197 create: Describe,
198 edit: Describe,
199 sortable: Vec<&'static str>,
200 filters: Vec<&'static str>,
201}
202
203pub trait Resources<A: Area> {
204 fn resource<R>(&mut self) -> ResourceRoutes<'_, A>
206 where
207 R: Aggregate + ReplyData + Create<Owner: OwnerOf<A>> + Update,
208 R: Resolve<RequestContext<A>> + Send,
209 <R as Create>::Input: FormFields + Send,
210 <R as Update>::Input: FormFields + Send;
211}
212
213impl<A: Area> Resources<A> for Routes<A> {
214 fn resource<R>(&mut self) -> ResourceRoutes<'_, A>
215 where
216 R: Aggregate + ReplyData + Create<Owner: OwnerOf<A>> + Update,
217 R: Resolve<RequestContext<A>> + Send,
218 <R as Create>::Input: FormFields + Send,
219 <R as Update>::Input: FormFields + Send,
220 {
221 let created = <<R as Create>::Input as FormFields>::FIELDS;
222 let edited = edit_fields(<<R as Update>::Input as FormFields>::FIELDS, R::FIELDS);
223 let unique = unique_indexes(R::NAME, created);
224 let shown = shown_fields(R::FIELDS, R::SENSITIVE, [created, edited]);
225 let named = |field: &ShownField| field.name;
226 let parts = Parts {
227 description: Description {
228 module: R::MODULE,
229 name: R::NAME,
230 edited,
231 shown,
232 create: |screens, key| screens.describe_form::<<R as Create>::Input>(key, "create"),
233 edit: |screens, key| {
234 let fields = <<R as Update>::Input as FormFields>::FIELDS;
235 screens.describe_fields(key, "edit", edit_fields(fields, R::FIELDS))
236 },
237 sortable: shown
238 .iter()
239 .map(named)
240 .filter(|name| sofie_db::Sort::<R>::by_field(name, false).is_some())
241 .collect(),
242 filters: shown
243 .iter()
244 .map(named)
245 .filter(|name| !sofie_db::filterable::<R>(name).is_empty())
246 .collect(),
247 },
248 list: loader_endpoint::<A, _, _>(handlers::list::<R>),
249 show: loader_endpoint::<A, _, _>(
250 move |record: handlers::Record<R>, read, parameters: handlers::Parameters| {
251 handlers::show::<R>(record, read, parameters, shown)
252 },
253 ),
254 blank: loader_endpoint::<A, _, _>(handlers::blank),
255 create: endpoint::<A, _, _>(
256 move |posted: Posted<<R as Create>::Input>, owner: Owner<R::Owner>| {
257 handlers::create::<R>(posted, owner, unique)
258 },
259 ),
260 update: endpoint::<A, _, _>(move |record: R, edited: Edited<<R as Update>::Input>| {
261 handlers::update::<R>(record, edited, unique)
262 }),
263 delete: endpoint::<A, _, _>(handlers::delete::<R>),
264 };
265 let deletable = R::BEHAVIORS
266 .iter()
267 .any(|behavior| behavior.hides() == Some("deleted_at"));
268 let mut kept = vec![
269 Resource::Index,
270 Resource::Show,
271 Resource::Create,
272 Resource::Edit,
273 ];
274 if deletable {
275 kept.push(Resource::Delete);
276 }
277 ResourceRoutes {
278 routes: self,
279 kept,
280 parts: Some(parts),
281 }
282 }
283}
284
285impl<A: Area> ResourceRoutes<'_, A> {
286 pub fn only<const N: usize>(mut self, kept: [Resource; N]) {
287 self.kept = kept.to_vec();
288 }
289}
290
291impl<A: Area> Drop for ResourceRoutes<'_, A> {
292 fn drop(&mut self) {
293 if let Some(parts) = self.parts.take() {
294 parts.add(self.routes, &self.kept);
295 }
296 }
297}
298
299#[derive(Clone, Copy, Debug, PartialEq, Eq)]
300pub(crate) struct ShownField {
301 pub(crate) name: &'static str,
302 pub(crate) look: Look,
303}
304
305#[derive(Clone, Copy, Debug, PartialEq, Eq)]
306pub(crate) enum Look {
307 Text,
308 Money,
309 Percentage,
310 Parts(&'static [Part]),
311 Joined,
313}
314
315fn shown_fields(
319 record: &'static [Field],
320 sensitive: &[&str],
321 inputs: [&'static [FormField]; 2],
322) -> &'static [ShownField] {
323 let shown: Vec<ShownField> = record
324 .iter()
325 .filter(|field| !sensitive.contains(&field.name) && !is_reference(field))
326 .filter_map(|field| {
327 let control = inputs
328 .iter()
329 .flat_map(|fields| fields.iter())
330 .find(|input| input.name == field.name)
331 .map(|input| input.input.control);
332 let look = match control {
333 Some(Control::Password | Control::File) => return None,
334 Some(Control::Money) => Look::Money,
335 Some(Control::Percentage) => Look::Percentage,
336 Some(Control::Parts(parts)) => Look::Parts(parts),
337 _ if is_money(field) => Look::Money,
338 _ if field.columns.len() > 1 => Look::Joined,
339 _ => Look::Text,
340 };
341 Some(ShownField {
342 name: field.name,
343 look,
344 })
345 })
346 .collect();
347 Box::leak(shown.into_boxed_slice())
348}
349
350fn is_reference(field: &Field) -> bool {
352 matches!(field.columns, [column] if column.ty == SqlType::Uuid)
353}
354
355fn is_money(field: &Field) -> bool {
356 matches!(field.columns, [amount, currency]
357 if amount.suffix.is_empty() && amount.ty == SqlType::Int && currency.suffix == "_currency")
358}
359
360fn edit_fields(fields: &'static [FormField], record: &[Field]) -> &'static [FormField] {
363 let kept_blank = |field: &FormField| {
364 handlers::is_secret(field)
365 && record
366 .iter()
367 .any(|stored| stored.name == field.name && (stored.handed_back)())
368 };
369 if !fields.iter().any(kept_blank) {
370 return fields;
371 }
372 let edited: Vec<FormField> = fields
373 .iter()
374 .map(|field| {
375 if !kept_blank(field) {
376 return *field;
377 }
378 let constraints: Vec<Constraint> = field
379 .constraints
380 .iter()
381 .filter(|constraint| **constraint != Constraint::Required)
382 .copied()
383 .collect();
384 FormField {
385 required: false,
386 constraints: Box::leak(constraints.into_boxed_slice()),
387 ..*field
388 }
389 })
390 .collect();
391 Box::leak(edited.into_boxed_slice())
392}
393
394fn unique_indexes(
396 table: &str,
397 fields: &'static [FormField],
398) -> &'static [(&'static str, &'static str)] {
399 let unique: Vec<(&'static str, &'static str)> = fields
400 .iter()
401 .filter(|field| field.constraints.contains(&Constraint::Unique))
402 .map(|field| {
403 let index: &'static str = Box::leak(format!("{table}_{}", field.name).into_boxed_str());
404 (index, field.name)
405 })
406 .collect();
407 Box::leak(unique.into_boxed_slice())
408}
409
410fn handler_name(name: &str, action: &str, method: &Method, listing: bool) -> String {
412 let verb = match (action, *method == Method::GET) {
413 ("read", _) if listing => return format!("list_{}", sofie_core::plural(name)),
414 ("read", _) => "show",
415 ("create", true) => "new",
416 ("update", true) => "edit",
417 ("update", false) => "update",
418 _ => action,
419 };
420 format!("{verb}_{name}")
421}
422
423fn plural_title(name: &str) -> String {
424 sofie_engine::humanize(&sofie_core::plural(name))
425}
426
427impl Parts {
428 fn add<A: Area>(self, routes: &mut Routes<A>, kept: &[Resource]) {
429 let name = self.description.name;
430 let module = routes.module;
431 let at = format!("/{}", sofie_core::plural(name).replace('_', "-"));
432 let record = format!("{at}/{{{name}}}");
433 let key = |screen: &str| screen_key(module, A::NAME, &format!("{name}_{screen}"));
434 let permission = permission_prefix::<A>(&self.description);
435 let description = Arc::new(self.description);
436 let kept: Arc<[Resource]> = kept.into();
437 let screen = |resource: Resource, name: &str, title: String| {
438 View::Screen(ScreenView {
439 key: key(name),
440 title,
441 builtin: Some(Arc::new(Builtin::Resource {
442 description: Arc::clone(&description),
443 kept: Arc::clone(&kept),
444 screen: resource,
445 permission: permission.clone(),
446 })),
447 })
448 };
449 let refresh = |resource: Resource, name: &str, fragment: String| {
450 if kept.contains(&resource) {
451 return View::Refresh(Refresh {
452 screen: key(name),
453 fragment,
454 });
455 }
456 View::Json
457 };
458 let list = || refresh(Resource::Index, "index", screens::list_id(name));
459 let mut add = |method: Method, path: String, endpoint: &Endpoint, view, action| {
460 let rules = route_rules(permission.as_deref(), action);
461 let handler = handler_name(name, action, &method, path == at);
462 routes
463 .push(method, &path, Arc::clone(endpoint), view, rules)
464 .name_as(A::NAME, &handler);
465 };
466 for resource in kept.iter() {
467 match resource {
468 Resource::Index => {
469 let view = screen(Resource::Index, "index", plural_title(name));
470 add(Method::GET, at.clone(), &self.list, view, "read");
471 }
472 Resource::Show => {
473 let view = screen(Resource::Show, "show", sofie_engine::humanize(name));
474 add(Method::GET, record.clone(), &self.show, view, "read");
475 }
476 Resource::Create => {
477 let view = screen(Resource::Create, "create", screens::titled("New", name));
478 let path = format!("{at}/new");
479 add(Method::GET, path.clone(), &self.blank, view, "create");
480 add(Method::POST, path, &self.create, list(), "create");
481 }
482 Resource::Edit => {
483 let view = screen(Resource::Edit, "edit", screens::titled("Edit", name));
484 let shown = refresh(Resource::Show, "show", "record".to_owned());
485 let path = format!("{record}/edit");
486 add(Method::GET, path.clone(), &self.show, view, "update");
487 add(Method::POST, path, &self.update, shown, "update");
488 }
489 Resource::Delete => {
490 let path = format!("{record}/delete");
491 add(Method::POST, path, &self.delete, list(), "delete");
492 }
493 }
494 }
495 }
496}
497
498#[cfg_attr(
499 not(feature = "permissions"),
500 expect(
501 clippy::extra_unused_type_parameters,
502 reason = "only `Permitted` areas ask"
503 )
504)]
505fn permission_prefix<A: Area>(description: &Description) -> Option<String> {
506 #[cfg(feature = "permissions")]
507 if <A::Principal as crate::AreaPrincipal>::ACCESS == crate::Access::Permitted {
508 return Some(format!("{}.{}.*.", description.module, description.name));
509 }
510 let _ = description;
511 None
512}
513
514fn route_rules(permission_prefix: Option<&str>, action: &str) -> RouteRules {
516 let _ = (permission_prefix, action);
517 RouteRules {
518 #[cfg(feature = "permissions")]
519 permission: permission_prefix.map(|prefix| {
520 crate::Permission::new(Box::leak(format!("{prefix}{action}").into_boxed_str()))
521 }),
522 ..RouteRules::default()
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::{Constraint, Control, Field, FormField, Shipped, built_in, edit_fields};
529 use crate::{Routes, Web};
530
531 async fn nothing() -> sofie_core::Result<()> {
532 Ok(())
533 }
534
535 #[test]
536 fn a_built_in_screen_gives_its_source_under_its_key() {
537 let mut routes = Routes::<Web>::new("shop");
538 routes.shipped("/welcome", "welcome", ("<p>Hi</p>", None), nothing);
539 routes.get("/plain", nothing);
540
541 let route = &routes.added[0];
542 let (key, builtin) = built_in(route).unwrap();
543
544 assert_eq!(key, "shop/web/welcome");
545 assert_eq!(builtin.source(&route.path), "<p>Hi</p>");
546 assert!(builtin.describe().is_none());
547 assert!(built_in(&routes.added[1]).is_none());
548 }
549
550 #[test]
551 fn a_password_the_record_does_not_store_stays_required_on_edit() {
552 const REQUIRED: &[Constraint] = &[Constraint::Required];
553 let password = |name| FormField {
554 name,
555 input: sofie_values::Input::new(Control::Password),
556 required: true,
557 constraints: REQUIRED,
558 };
559 let fields: &'static [FormField] = Box::leak(Box::new([
560 password("iban"),
561 password("password"),
562 password("api_key"),
563 ]));
564 let record = [
565 Field::new("iban", &[], false),
566 Field::new("password_hash", &[], false),
567 Field::new("api_key", &[], false).handed_back_by(|| false),
568 ];
569
570 let edited = edit_fields(fields, &record);
571
572 assert!(!edited[0].required && edited[0].constraints.is_empty());
573 assert!(edited[1].required && edited[1].constraints == REQUIRED);
574 assert!(edited[2].required && edited[2].constraints == REQUIRED);
575 }
576}