tag2upload_service_manager/
ui_render.rs

1//! support for defining routes - mostly, Tera templates
2//!
3//! We embed the templates in the binary because that's simple to deploy,
4//! and avoids entanglement of the build system (and asset configuration).
5
6use crate::prelude::*;
7
8pub use tera::Tera;
9pub use rocket::http::ContentType;
10
11use rocket::request::{FromRequest, Outcome, Request};
12
13pub type RenderedTemplate = Result<(ContentType, String), WebError>;
14
15pub struct EmbeddedTemplateIsJustAPart;
16
17pub struct EmbeddedTemplate {
18    pub name: &'static str,
19    pub contents: &'static str,
20    pub is_part: Option<EmbeddedTemplateIsJustAPart>,
21}
22
23define_derive_deftly! {
24    FromRequest for struct, expect items:
25
26    #[async_trait]
27    impl<'r> FromRequest<'r> for $ttype {
28        type Error = String;
29
30        async fn from_request(req: &'r Request<'_>) -> Outcome<Self, String> {
31            use Outcome as O;
32
33            O::Success($tname { $(
34                $fname: match FromRequest::from_request(req).await {
35                    O::Success(y) => y,
36                    O::Forward(x) => return O::Forward(x),
37                    O::Error(e)   => return O::Error(e),
38                },
39            ) })
40        }
41    }
42}
43
44#[derive(Deftly)]
45#[derive_deftly(FromRequest)]
46pub struct UiReqInfoUnchecked {
47    pub vhost: ui_vhost::UiResult,
48    pub send_format: SendFormat,
49}
50
51pub struct UiReqInfo {
52    pub vhost: ui_vhost::IsUi,
53    pub send_format: SendFormat,
54}
55
56pub enum SendFormat {
57    Html,
58    Json,
59}
60
61impl UiReqInfoUnchecked {
62    pub fn check(self) -> Result<UiReqInfo, WebError> {
63        let UiReqInfoUnchecked { vhost, send_format } = self;
64        let vhost = vhost.check(WE::PageNotFoundHere)?;
65        Ok(UiReqInfo { vhost, send_format })
66    }
67}
68
69#[async_trait]
70impl<'r> FromRequest<'r> for SendFormat {
71    type Error = String;
72
73    async fn from_request(req: &'r Request<'_>) -> Outcome<Self, String> {
74        FromRequest::from_request(req).await.map(|a: &rocket::http::Accept| {
75            if a.preferred().is_json() {
76                SendFormat::Json
77            } else {
78                SendFormat::Html
79            }
80        }).map_error(|(_, inf)| match inf {})
81    }
82}
83
84macro_rules! load_template_parts { { $( $name:literal ),* $(,)? } => { $(
85    inventory::submit!(EmbeddedTemplate {
86        name: $name,
87        contents: include_str!(concat!("../ui/", $name)),
88        is_part: Some(EmbeddedTemplateIsJustAPart),
89    });
90)* } }
91
92/// Return a `Template` from a build-in template
93///
94// We can't test this because it would try to read some "FILENAME.html".
95/// ```ignore
96/// template! {
97///     "NAME.html";
98///   [ content_type: CONTENT_TYPE; ]
99///     CONTEXT
100/// }
101/// ```
102///
103/// Expands to an expression returning a value of type [`RenderedTemplate`].
104///
105/// `NAME.html` corresponds to the file `ui/NAME.html`.
106/// (It doesn't need to end in `.html`.
107/// But, the extension influences tera's autoescaping.)
108/// 
109/// `{ CONTEXT_FIELD_NAME: CONTEXT_VALUE, .. }`
110/// are values for the template.
111/// Each `CONTEXT_VALUE` must be [`Serialize`].
112///
113/// `CONTEXT` is either `(EXPR)`, where `EXPR` is of type `tera::Context`,
114/// or `{ CONTEXT_FIELD_NAME [: CONTEXT_VALUE ], .. }`
115/// which is fed to `tera_context!`.
116#[macro_export]
117macro_rules! template { {
118    $name:literal, $req_info:expr;
119 $( content_type: $ctype:expr; )?
120    { $($context_always:tt)* }
121 $( { $($context_html_only:tt)* } )?
122} => {
123    $crate::template! {
124        $name, $req_info;
125     $( $content_type: $ctype )?
126        ($crate::tera_context!( $( $context_always )* ),
127         $crate::tera_context!( $( $( $context_html_only )* )? ))
128    }
129};
130{
131    $name:literal, $req_info:expr;
132 $( content_type: $ctype:expr; )?
133    ( $context_always:expr, $context_html_only:expr )
134    $(,)?
135} => {
136    {
137        use $crate::ui_render::*;
138
139        let _: $crate::ui_vhost::IsUi = $req_info.vhost;
140
141        match $req_info.send_format {
142            SendFormat::Html => {}
143            SendFormat::Json => {
144                let json = $context_always.into_json();
145                let json = serde_json::to_string(&json)
146                    .with_context(|| format!("render json for {}", $name))
147                    .map_err(IE::new_quiet)
148                    .map_err(WebError::from)?;
149                return Ok((ContentType::JSON, json));
150            }
151        }
152        let mut context = $context_always;
153        context.extend($context_html_only);
154
155        #[allow(dead_code)]
156        let content_type = $name.rsplit_once('.')
157            .and_then(|(_, ext)| ContentType::from_extension(ext));
158      $(
159        let content_type: ContentType = Some($ctype);
160      )?
161        let content_type = content_type
162            .ok_or_else(|| WebError::from(IE::new_quiet(anyhow!(
163                "cannot deduce Content-Type for template {}", $name
164            ))))?;
165
166        let s = $crate::template_html_unchecked! { $name, context }?;
167        Ok((content_type, s))
168    }
169} }
170
171/// Render an HTML page template without any checks
172///
173///  * Does not check (via `IsUi`) that we are on the right vhost
174///  * Doesn't check for Accept JSON (so always renders template)
175///  * Doesn't calculate the Content-Type
176#[macro_export]
177macro_rules! template_html_unchecked { {
178    $name:literal, $context:expr $(,)?
179} => {
180    {
181        let gl = globals();
182
183        inventory::submit!(EmbeddedTemplate {
184            name: $name,
185            contents: include_str!(concat!("../ui/", $name)),
186            is_part: None,
187        });
188
189        #[cfg(test)]
190        gl.t_note_template_rendered($name);
191
192        let s = gl.tera.render(
193            $name,
194            &$context,
195        )
196            .context($name)
197            .map_err(IE::new_quiet)
198            .map_err(WebError::from)?;
199
200        Ok::<_, WebError>(s)
201    }
202} }
203
204/// Invoke an HTML page template
205///
206/// Like `template!` but only takes the `{ }` context syntax,
207/// and additionally includes a navbar as `navbar` in the context,
208/// based on `$name` and
209/// the prevailing constant `NAVBAR` (from the calling scope).
210#[macro_export]
211macro_rules! template_page { {
212    $name:literal, $req_info:expr;
213    { $($context:tt)* }
214} => {
215    $crate::template! {
216        $name, $req_info;
217        {
218            $($context)*
219            t2usm_version: &globals().version_info,
220        }
221        {
222            navbar: $crate::ui_render::make_navbar_for($name, NAVBAR),
223            t2usm_version: &globals().version_info.to_string(),
224        }
225    }
226} }
227
228/// Reimplementation, roughly, of `rocket_dyn_templates::context!`
229///
230/// ```
231/// # use tag2upload_service_manager::tera_context;
232/// # let VALUE = "12";
233/// # let NAME_SAME_AS_VALUE = "42";
234/// let _: tera::Context = tera_context! {
235///     NAME: VALUE,
236///     NAME_SAME_AS_VALUE,
237/// };
238/// ```
239#[macro_export]
240macro_rules! tera_context { {
241    $( $k:ident $( : $v:expr )? ),* $(,)?
242} => { {
243    #[allow(unused_mut)]
244    let mut context = tera::Context::new();
245    $( $crate::tera_context!( @ context $k $( $v )? ); )*
246    context
247} }; {
248    @ $context:ident $k:ident
249} => {
250    $crate::tera_context!(@ $context $k $k)
251}; {
252    @ $context:ident $k:ident $v:expr
253} => {
254    $context.insert(stringify!($k), &$v)
255} }
256
257pub fn tera_templates(config: &Config) -> Result<Tera, StartupError> {
258    if let Some(dir) = &config.files.template_dir {
259        let glob = format!("{dir}/*[^~#]");
260        debug!(?glob, "loading tera templates");
261        Tera::new(&glob)
262            .context(glob)
263            .map_err(StartupError::Templates)
264    } else {
265        embedded_tera_templates()
266    }
267}
268
269pub fn embedded_tera_templates() -> Result<Tera, StartupError> {
270    let mut tera = Tera::default();
271
272    tera.add_raw_templates(
273        inventory::iter::<EmbeddedTemplate>().map(
274            |EmbeddedTemplate { name, contents, is_part: _ }| {
275                trace!(name, "loading builtin templat");
276                (name, contents)
277            }
278        )
279    ).into_internal("failed to initialise templating")?;
280
281    Ok(tera)
282}
283
284/// title text; template name; route path
285pub type NavbarEntry<'s> = (&'s str, &'s str, &'s str);
286
287pub fn make_navbar_for(for_templ: &str, navbar: &[NavbarEntry]) -> String {
288    let mut out = String::new();
289    let mut delim = "";
290    for (title, templ, path) in navbar.iter().copied() {
291        write_string!(out, "{}", mem::replace(&mut delim, " | "));
292        let post = if templ != for_templ {
293            write_string!(out, "<a href={path}>");
294            "</a>"
295        } else {
296            ""
297        };
298        write_string!(out, "<b>{title}</b>{post}");
299    }
300    out
301}
302
303inventory::collect!(EmbeddedTemplate);
304
305#[test]
306fn check_embedded_tera_templates() {
307    let t: Tera = embedded_tera_templates().expect("bad templates?");
308    println!("{t:?}");
309}