Skip to main content

sofie_http/gate/optional/resource/
mod.rs

1//! A module overrides a built-in screen with `screens/<name>.html`, such as `order_index.html`.
2
3mod 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
36/// Runs before the routes are checked against the screens.
37pub(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/// What `sofie scaffold screen` writes out for a module to override.
56#[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    /// Rendered from `source` unless the module writes `screens/<name>.html`.
113    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    /// Offered only for an aggregate with `SoftDelete`.
167    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    /// The edit form's, from the update input.
194    edited: &'static [FormField],
195    /// The record's own, which its list and show screens present.
196    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    /// Under `/<plural of its name>`; the owner of a new record is the principal.
205    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    /// A value of several columns no input describes, shown as its parts' text.
312    Joined,
313}
314
315/// What the record holds, not what its input takes: a state the record sets itself is shown, and
316/// ids, parents, owners and sensitive values are not. An input field of the same name tells how
317/// a value is shown. Leaked once per resource, while the app is built.
318fn 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
350/// An `Id` of this or another record, such as a parent or an owner.
351fn 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
360/// A sensitive field is edited blank and a blank one keeps what the record stores, so it is
361/// required only on create. Leaked once per resource, while the app is built.
362fn 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
394/// Leaked once per resource, while the app is built.
395fn 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
410/// As `sofie gen` names a resource's handlers.
411fn 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
514/// Leaked once per route while the app is built.
515fn 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}