1use std::collections::BTreeMap;
2use std::fs;
3
4use anyhow::{Context, Result, bail};
5use base64::Engine;
6use serde_json::{Map, Value};
7use url::Url;
8
9use crate::PreparedHttpData;
10use crate::schema::{GraphqlOperationTemplate, GraphqlTemplate, HttpOperationTemplate};
11use earl_core::render::{TemplateRenderer, render_key_value_map};
12use earl_core::schema::MultipartPartTemplate;
13use earl_core::{PreparedBody, PreparedMultipartPart};
14
15pub fn build_http_request(
19 http: &HttpOperationTemplate,
20 context: &Value,
21 renderer: &dyn TemplateRenderer,
22 command_key: &str,
23) -> Result<PreparedHttpData> {
24 let (url, query, headers, cookies) = render_http_primitives(
25 &http.url,
26 http.path.as_ref(),
27 &http.query,
28 &http.headers,
29 &http.cookies,
30 context,
31 renderer,
32 command_key,
33 )?;
34
35 let body = render_http_body(http, context, renderer, command_key)?;
36
37 Ok(PreparedHttpData {
38 method: parse_http_method(&http.method, None)?,
39 url,
40 query,
41 headers,
42 cookies,
43 body,
44 })
45}
46
47pub fn build_graphql_request(
51 graphql: &GraphqlOperationTemplate,
52 context: &Value,
53 renderer: &dyn TemplateRenderer,
54 command_key: &str,
55) -> Result<PreparedHttpData> {
56 let (url, query, mut headers, cookies) = render_http_primitives(
57 &graphql.url,
58 graphql.path.as_ref(),
59 &graphql.query,
60 &graphql.headers,
61 &graphql.cookies,
62 context,
63 renderer,
64 command_key,
65 )?;
66
67 ensure_header_default(&mut headers, "Accept", "application/json");
68 ensure_header_default(&mut headers, "Content-Type", "application/json");
69
70 let body = PreparedBody::Json(render_graphql_body(&graphql.graphql, context, renderer)?);
71
72 Ok(PreparedHttpData {
73 method: parse_http_method(&graphql.method, Some("POST"))?,
74 url,
75 query,
76 headers,
77 cookies,
78 body,
79 })
80}
81
82pub fn parse_http_method(method: &str, fallback: Option<&str>) -> Result<reqwest::Method> {
83 let raw = method.trim();
84 let method = if raw.is_empty() {
85 fallback.unwrap_or("")
86 } else {
87 raw
88 };
89
90 method
91 .parse::<reqwest::Method>()
92 .with_context(|| format!("invalid HTTP method `{method}`"))
93}
94
95pub fn ensure_header_default(headers: &mut Vec<(String, String)>, name: &str, value: &str) {
96 if headers.iter().any(|(k, _)| k.eq_ignore_ascii_case(name)) {
97 return;
98 }
99 headers.push((name.to_string(), value.to_string()));
100}
101
102type RenderedHttpPrimitives = (
105 Url,
106 Vec<(String, String)>,
107 Vec<(String, String)>,
108 Vec<(String, String)>,
109);
110
111#[expect(clippy::too_many_arguments)]
112fn render_http_primitives(
113 url_template: &str,
114 path_template: Option<&String>,
115 query_template: &Option<BTreeMap<String, Value>>,
116 headers_template: &Option<BTreeMap<String, Value>>,
117 cookies_template: &Option<BTreeMap<String, Value>>,
118 context: &Value,
119 renderer: &dyn TemplateRenderer,
120 command_key: &str,
121) -> Result<RenderedHttpPrimitives> {
122 let url_text = renderer.render_str(url_template, context)?;
123 let mut url = Url::parse(&url_text).with_context(|| {
124 format!("template `{command_key}` rendered invalid operation URL `{url_text}`")
125 })?;
126
127 if let Some(path_template) = path_template {
128 let rendered_path = renderer.render_str(path_template, context)?;
129 if rendered_path.starts_with('/') {
130 url.set_path(&rendered_path);
131 } else {
132 let current = url.path().trim_end_matches('/');
133 let next = format!("{current}/{rendered_path}");
134 url.set_path(&next);
135 }
136 }
137
138 let query = render_key_value_map(query_template.as_ref(), context, renderer)?;
139 let headers = render_key_value_map(headers_template.as_ref(), context, renderer)?;
140 let cookies = render_key_value_map(cookies_template.as_ref(), context, renderer)?;
141
142 Ok((url, query, headers, cookies))
143}
144
145fn render_http_body(
146 http: &HttpOperationTemplate,
147 context: &Value,
148 renderer: &dyn TemplateRenderer,
149 command_key: &str,
150) -> Result<PreparedBody> {
151 use earl_core::schema::BodyTemplate;
152
153 match http.body.as_ref().unwrap_or(&BodyTemplate::None) {
154 BodyTemplate::None => Ok(PreparedBody::Empty),
155 BodyTemplate::Json { value } => {
156 Ok(PreparedBody::Json(renderer.render_value(value, context)?))
157 }
158 BodyTemplate::FormUrlencoded { fields } => Ok(PreparedBody::Form(render_key_value_map(
159 Some(fields),
160 context,
161 renderer,
162 )?)),
163 BodyTemplate::Multipart { parts } => Ok(PreparedBody::Multipart(render_multipart_parts(
164 parts, context, renderer,
165 )?)),
166 BodyTemplate::RawText {
167 value,
168 content_type,
169 } => {
170 let rendered = renderer.render_str(value, context)?;
171 Ok(PreparedBody::RawBytes {
172 bytes: rendered.into_bytes(),
173 content_type: content_type
174 .clone()
175 .or_else(|| Some("text/plain".to_string())),
176 })
177 }
178 BodyTemplate::RawBytesBase64 {
179 value,
180 content_type,
181 } => {
182 let rendered = renderer.render_str(value, context)?;
183 let bytes = base64::engine::general_purpose::STANDARD
184 .decode(rendered)
185 .context("invalid base64 in raw_bytes_base64 body")?;
186 Ok(PreparedBody::RawBytes {
187 bytes,
188 content_type: content_type.clone(),
189 })
190 }
191 BodyTemplate::FileStream { path, content_type } => {
192 let rendered_path = renderer.render_str(path, context)?;
193 let bytes = fs::read(&rendered_path).with_context(|| {
194 format!(
195 "failed reading file_stream body data from `{rendered_path}` for command {command_key}"
196 )
197 })?;
198 Ok(PreparedBody::RawBytes {
199 bytes,
200 content_type: content_type.clone(),
201 })
202 }
203 }
204}
205
206fn render_multipart_parts(
207 parts: &[MultipartPartTemplate],
208 context: &Value,
209 renderer: &dyn TemplateRenderer,
210) -> Result<Vec<PreparedMultipartPart>> {
211 let mut out = Vec::new();
212 for part in parts {
213 let bytes = if let Some(value) = &part.value {
214 renderer.render_str(value, context)?.into_bytes()
215 } else if let Some(value) = &part.bytes_base64 {
216 let rendered = renderer.render_str(value, context)?;
217 base64::engine::general_purpose::STANDARD
218 .decode(rendered)
219 .context("invalid multipart bytes_base64")?
220 } else if let Some(path) = &part.file_path {
221 let rendered_path = renderer.render_str(path, context)?;
222 fs::read(&rendered_path)
223 .with_context(|| format!("failed reading multipart file `{rendered_path}`"))?
224 } else {
225 bail!(
226 "multipart part `{}` does not contain any data source",
227 part.name
228 );
229 };
230
231 let filename = part
232 .filename
233 .as_ref()
234 .map(|name| renderer.render_str(name, context))
235 .transpose()?;
236
237 out.push(PreparedMultipartPart {
238 name: part.name.clone(),
239 bytes,
240 content_type: part.content_type.clone(),
241 filename,
242 });
243 }
244 Ok(out)
245}
246
247fn render_graphql_body(
248 graphql: &GraphqlTemplate,
249 context: &Value,
250 renderer: &dyn TemplateRenderer,
251) -> Result<Value> {
252 let mut payload = Map::new();
253 payload.insert(
254 "query".to_string(),
255 Value::String(renderer.render_str(&graphql.query, context)?),
256 );
257
258 if let Some(operation_name) = &graphql.operation_name {
259 payload.insert(
260 "operationName".to_string(),
261 Value::String(renderer.render_str(operation_name, context)?),
262 );
263 }
264
265 if let Some(variables) = &graphql.variables {
266 payload.insert(
267 "variables".to_string(),
268 renderer.render_value(variables, context)?,
269 );
270 }
271
272 Ok(Value::Object(payload))
273}