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(ShippedTemplate),
29}
30
31/// A shipped screen's source, and the program its crate's build script compiled from it with the
32/// source-language text of each `t(…)`, which an app built without `i18n` writes.
33#[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
62/// The source-language text of each `t(…)` a generated screen writes, for an app without `i18n`.
63const 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
67/// Runs before the routes are checked against the screens.
68pub(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/// What `sofie scaffold screen` writes out for a module to override.
87#[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    /// Rendered from its template unless the module writes `screens/<name>.html`.
167    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    /// Offered only for an aggregate with `SoftDelete`.
221    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    /// The edit form's, from the update input.
248    edited: &'static [FormField],
249    /// The record's own, which its list and show screens present.
250    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    /// Under `/<plural of its name>`; the owner of a new record is the principal.
259    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    /// A value of several columns no input describes, shown as its parts' text.
366    Joined,
367}
368
369/// What the record holds, not what its input takes: a state the record sets itself is shown, and
370/// ids, parents, owners and sensitive values are not. An input field of the same name tells how
371/// a value is shown. Leaked once per resource, while the app is built.
372fn 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
404/// An `Id` of this or another record, such as a parent or an owner.
405fn 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
414/// A sensitive field is edited blank and a blank one keeps what the record stores, so it is
415/// required only on create. Leaked once per resource, while the app is built.
416fn 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
448/// Leaked once per resource, while the app is built.
449fn 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
464/// As `sofie gen` names a resource's handlers.
465fn 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
568/// Leaked once per route while the app is built.
569fn 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}