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 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 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 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}