Skip to main content

sofie_http/
client_page.rs

1//! A client area's answers: the page shell holding the first screen's envelope, the envelope
2//! alone for the area's runtime, and a refused form's errors in the request's language.
3
4use http::StatusCode;
5use serde::Serialize;
6use serde_json::{Map, Value, json};
7use sofie_core::{Error, FieldError, Result};
8use sofie_db::Reply;
9use sofie_engine::{ClientBundle, ClientLinks, ClientShell, ClientView};
10
11use crate::answer::{self, JSON, Refusal, Response};
12use crate::caller::Caller;
13use crate::language::Language;
14use crate::screen::{ScreenPage, new_nonce, with_policy};
15
16#[derive(Serialize)]
17struct Envelope<'a> {
18    screen: &'a str,
19    title: &'a str,
20    language: &'a str,
21    data: Value,
22    instants: Value,
23    view: ClientView,
24    navigation: Value,
25}
26
27/// The loader's JSON is the screen's data as `application/json` answers it.
28fn envelope<'a>(screen: &'a ScreenPage<'a>, reply: Result<Reply>) -> Result<Envelope<'a>> {
29    let (mut data, typed) = match reply? {
30        Reply::Json(data) => (data, None),
31        typed => (typed.to_json()?, Some(typed)),
32    };
33    screen.withhold(&mut data);
34    let rendering = screen.rendering();
35    #[cfg(feature = "listing")]
36    rendering.translate_listing_errors(&mut data);
37    let instants = typed
38        .as_ref()
39        .and_then(Reply::shown)
40        .map_or(Value::Null, |shown| {
41            sofie_engine::instant_fields(shown, &data)
42        });
43    Ok(Envelope {
44        screen: screen.template.name(),
45        title: screen.title,
46        language: screen.language.tag(),
47        data,
48        instants,
49        view: rendering.client_view(),
50        navigation: screen.navigation_entries(),
51    })
52}
53
54fn serializing(error: serde_json::Error) -> Error {
55    Error::internal("serializing a client screen's envelope", error)
56}
57
58pub(crate) fn enveloped(screen: &ScreenPage<'_>, reply: Result<Reply>) -> Response {
59    let answered = envelope(screen, reply)
60        .and_then(|envelope| serde_json::to_string(&envelope).map_err(serializing))
61        .map(|body| answer::response(StatusCode::OK, Some(JSON), body));
62    answer::or_failure(answered, Caller::Screen)
63}
64
65pub(crate) fn shell(
66    screen: &ScreenPage<'_>,
67    bundle: &ClientBundle,
68    reply: Result<Reply>,
69) -> Result<Response> {
70    let envelope = serde_json::to_string(&envelope(screen, reply)?).map_err(serializing)?;
71    let nonce = new_nonce()?;
72    let links = links(bundle);
73    let html = ClientShell {
74        title: screen.title,
75        lang: screen.language.tag(),
76        nonce: &nonce,
77        envelope_json: &envelope,
78        links: &links,
79        #[cfg(feature = "islands")]
80        islands: screen.islands.as_ref(),
81        #[cfg(not(feature = "islands"))]
82        islands: None,
83    }
84    .render();
85    with_policy(html, screen, &nonce)
86}
87
88fn links(bundle: &ClientBundle) -> ClientLinks {
89    #[cfg(all(feature = "development", debug_assertions))]
90    if let Some(links) = crate::development::client_links(bundle.area) {
91        return links;
92    }
93    bundle.links()
94}
95
96/// Field messages and the notice are translated as a refused page would show them; each error
97/// keeps its key and arguments, as a 422 answers them.
98pub(crate) fn refused(refusal: &Refusal, language: Language<'_>) -> Response {
99    let errors: Vec<Value> = match refusal {
100        Refusal::Fields(fields) => fields
101            .iter()
102            .map(|error| field_error(error, language))
103            .collect(),
104        Refusal::Message(..) => Vec::new(),
105    };
106    let body = json!({ "errors": errors, "notice": refusal.messages(language) });
107    answer::response(refusal.status(), Some(JSON), body.to_string())
108}
109
110fn field_error(error: &FieldError, language: Language<'_>) -> Value {
111    let arguments: Map<String, Value> = error
112        .arguments
113        .iter()
114        .map(|(name, value)| (name.to_string(), Value::from(value.as_str())))
115        .collect();
116    json!({
117        "field": error.field,
118        "key": error.key,
119        "arguments": arguments,
120        "message": language.field_error(error),
121    })
122}
123
124impl crate::Screens {
125    /// The bundles the app's build script compiled, one per client area.
126    pub fn clients(&mut self, bundles: Vec<ClientBundle>) -> &mut Self {
127        self.clients = bundles;
128        self
129    }
130
131    pub(crate) fn client(&self, area: &str) -> Option<&ClientBundle> {
132        self.clients.iter().find(|bundle| bundle.area == area)
133    }
134}
135
136impl crate::assets::Assets {
137    pub(crate) fn with_clients(mut self, screens: &crate::Screens) -> Self {
138        for bundle in &screens.clients {
139            for asset in [bundle.script, bundle.stylesheet] {
140                self.served.push((asset.path(), asset));
141            }
142        }
143        self
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn a_refused_form_s_errors_keep_their_key_and_arguments() {
153        let error =
154            FieldError::new("code", "validation.max_length", "must be at most 4").with("max", "4");
155
156        let response = refused(&Refusal::Fields(vec![error]), Language::plain());
157
158        let body: Value =
159            serde_json::from_slice(&response.into_body().into_inner().unwrap()).unwrap();
160        assert_eq!(
161            body["errors"],
162            json!([{
163                "field": "code",
164                "key": "validation.max_length",
165                "arguments": { "max": "4" },
166                "message": "must be at most 4",
167            }])
168        );
169    }
170}