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