catapulte_engine/loader/
http.rs

1use super::prelude::Template;
2use reqwest::{header::HeaderMap, Url};
3use serde::Deserialize;
4use serde_json::Value as JsonValue;
5use std::collections::BTreeMap;
6
7#[derive(Clone, Debug, serde::Deserialize)]
8pub struct Config {
9    pub url: String,
10    pub params: BTreeMap<String, String>,
11    pub headers: BTreeMap<String, String>,
12}
13
14impl Config {
15    pub fn build(&self) -> HttpLoader {
16        tracing::debug!("building template provider");
17        HttpLoader {
18            client: reqwest::Client::new(),
19            url: self.url.clone(),
20            params: self
21                .params
22                .iter()
23                .map(|(key, value)| (key.clone(), value.clone()))
24                .collect(),
25            headers: self
26                .headers
27                .iter()
28                .map(|(name, value)| {
29                    (
30                        reqwest::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
31                        reqwest::header::HeaderValue::from_bytes(value.as_bytes()).unwrap(),
32                    )
33                })
34                .collect(),
35        }
36    }
37}
38
39impl From<Config> for HttpLoader {
40    fn from(value: Config) -> Self {
41        HttpLoader {
42            client: reqwest::Client::new(),
43            url: value.url,
44            params: value.params.into_iter().collect(),
45            headers: value
46                .headers
47                .into_iter()
48                .map(|(name, value)| {
49                    (
50                        reqwest::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
51                        reqwest::header::HeaderValue::from_bytes(value.as_bytes()).unwrap(),
52                    )
53                })
54                .collect(),
55        }
56    }
57}
58
59#[derive(Debug, thiserror::Error)]
60pub enum Error {
61    #[error("Unable to load and parse metadata: {0:?}")]
62    MetadataLoadingFailed(reqwest::Error),
63    #[error("Unable to build metadata url: {0:?}")]
64    MetadataUrlInvalid(url::ParseError),
65    #[error("Unable to request file: {0:?}")]
66    RequestFailed(reqwest::Error),
67    #[error("Unable to load and parse template: {0:?}")]
68    TemplateLoadingFailed(reqwest::Error),
69}
70
71#[derive(Debug, Deserialize)]
72struct RemoteMetadata {
73    name: String,
74    description: String,
75    #[serde(flatten)]
76    template: RemoteMetadataTemplate,
77    attributes: JsonValue,
78}
79
80#[derive(Debug, Deserialize)]
81#[serde(untagged)]
82enum RemoteMetadataTemplate {
83    Embedded { content: String },
84    Remote { template: String },
85}
86
87#[derive(Clone, Debug)]
88pub struct HttpLoader {
89    client: reqwest::Client,
90    url: String,
91    params: Vec<(String, String)>,
92    headers: HeaderMap,
93}
94
95impl HttpLoader {
96    #[cfg(test)]
97    fn new(url: String) -> Self {
98        Self {
99            client: reqwest::Client::new(),
100            url,
101            params: Default::default(),
102            headers: Default::default(),
103        }
104    }
105
106    fn interpolate(&self, name: &str, filename: &str) -> String {
107        if self.url.ends_with('/') {
108            format!("{}{}/{}", self.url, name, filename)
109        } else {
110            format!("{}/{}/{}", self.url, name, filename)
111        }
112    }
113
114    fn build_url(&self, name: &str, filename: &str) -> Result<Url, Error> {
115        let base_url = self.interpolate(name, filename);
116        Url::parse_with_params(base_url.as_str(), self.params.iter()).map_err(|err| {
117            tracing::error!("unable to generate metadata url: {:?}", err);
118            Error::MetadataUrlInvalid(err)
119        })
120    }
121
122    async fn build_request(&self, name: &str, filename: &str) -> Result<reqwest::Response, Error> {
123        let url = self.build_url(name, filename)?;
124        self.client
125            .get(url)
126            .headers(self.headers.clone())
127            .send()
128            .await
129            .map_err(|err| {
130                tracing::error!("unable to request template {}: {:?}", filename, err);
131                Error::RequestFailed(err)
132            })
133    }
134
135    pub(super) async fn find_by_name(&self, name: &str) -> Result<Template, Error> {
136        tracing::debug!("loading template {}", name);
137        let res = self.build_request(name, "metadata.json").await?;
138        let res = res
139            .error_for_status()
140            .map_err(Error::MetadataLoadingFailed)?;
141        let metadata: RemoteMetadata = res.json().await.map_err(|err| {
142            tracing::error!("unable to load and parse metadata: {:?}", err);
143            Error::MetadataLoadingFailed(err)
144        })?;
145        let content = match metadata.template {
146            RemoteMetadataTemplate::Embedded { content } => content,
147            RemoteMetadataTemplate::Remote { template } => {
148                let res = self.build_request(name, &template).await?;
149                let res = res
150                    .error_for_status()
151                    .map_err(Error::TemplateLoadingFailed)?;
152                res.text().await.map_err(|err| {
153                    tracing::error!("unable to load template content: {:?}", err);
154                    Error::TemplateLoadingFailed(err)
155                })?
156            }
157        };
158        Ok(Template {
159            name: metadata.name,
160            description: metadata.description,
161            content,
162            attributes: metadata.attributes,
163        })
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::HttpLoader;
170    use wiremock::matchers::{method, path};
171    use wiremock::{Mock, MockServer, ResponseTemplate};
172
173    fn interpolate(url: &str, name: &str) -> String {
174        HttpLoader::new(url.into()).interpolate(name, "metadata.json")
175    }
176
177    #[test]
178    fn should_interpolate_template_name() {
179        assert_eq!(
180            interpolate(
181                "https://raw.githubusercontent.com/jdrouet/catapulte/main/template/",
182                "user-login"
183            ),
184            "https://raw.githubusercontent.com/jdrouet/catapulte/main/template/user-login/metadata.json"
185        );
186    }
187
188    #[tokio::test]
189    async fn fetch_not_found_template() {
190        let mock_server = MockServer::start().await;
191
192        let provider = HttpLoader::new(format!("{}/templates/", &mock_server.uri()));
193        let result = provider.find_by_name("user-login").await.unwrap_err();
194        assert!(matches!(result, super::Error::MetadataLoadingFailed(_)));
195    }
196
197    #[tokio::test]
198    async fn fetch_template_separate_file() {
199        let metadata: serde_json::Value = serde_json::from_str(include_str!(
200            "../../../../template/user-login/metadata.json"
201        ))
202        .unwrap();
203        let content = include_str!("../../../../template/user-login/template.mjml");
204        //
205        let mock_server = MockServer::start().await;
206        Mock::given(method("GET"))
207            .and(path("/templates/user-login/metadata.json"))
208            .respond_with(ResponseTemplate::new(200).set_body_json(metadata))
209            .mount(&mock_server)
210            .await;
211        Mock::given(method("GET"))
212            .and(path("/templates/user-login/template.mjml"))
213            .respond_with(ResponseTemplate::new(200).set_body_string(content))
214            .mount(&mock_server)
215            .await;
216
217        let provider = HttpLoader::new(format!("{}/templates/", &mock_server.uri()));
218        let result = provider.find_by_name("user-login").await.unwrap();
219        assert!(result.content.starts_with("<mjml>"));
220    }
221
222    #[tokio::test]
223    async fn fetch_template_separate_file_missing_template() {
224        let metadata: serde_json::Value = serde_json::from_str(include_str!(
225            "../../../../template/user-login/metadata.json"
226        ))
227        .unwrap();
228        //
229        let mock_server = MockServer::start().await;
230        Mock::given(method("GET"))
231            .and(path("/templates/user-login/metadata.json"))
232            .respond_with(ResponseTemplate::new(200).set_body_json(metadata))
233            .mount(&mock_server)
234            .await;
235
236        let provider = HttpLoader::new(format!("{}/templates/", &mock_server.uri()));
237        let result = provider.find_by_name("user-login").await.unwrap_err();
238        assert!(matches!(result, super::Error::TemplateLoadingFailed(_)));
239    }
240
241    #[tokio::test]
242    async fn fetch_template_same_file() {
243        let content = include_str!("../../../../template/user-login/template.mjml");
244        let metadata = serde_json::json!({
245            "name": "single-file",
246            "description": "read from single file",
247            "content": content,
248            "attributes": serde_json::json!({
249                "type": "object",
250                "properties": serde_json::json!({
251                    "token": serde_json::json!({
252                        "type": "string"
253                    })
254                }),
255                "required": serde_json::json!([
256                    "token"
257                ])
258            })
259        });
260        //
261        let mock_server = MockServer::start().await;
262        Mock::given(method("GET"))
263            .and(path("/templates/single-file/metadata.json"))
264            .respond_with(ResponseTemplate::new(200).set_body_json(metadata))
265            .mount(&mock_server)
266            .await;
267        let provider = HttpLoader::new(format!("{}/templates/", &mock_server.uri()));
268        let result = provider.find_by_name("single-file").await.unwrap();
269        assert!(result.content.starts_with("<mjml>"));
270    }
271}